diff --git a/.editorconfig b/.editorconfig index 70619017537..7747344767f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -14,5 +14,19 @@ trim_trailing_whitespace = true end_of_line = crlf [*.yml] -indent_style = space indent_size = 2 + +[psalm-baseline.xml] +indent_size = 2 + +[phars.xml] +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.neon] +indent_style = tab + +[*.neon.dist] +indent_style = tab diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000000..094e5ecf896 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,8 @@ +# Use HTTPS for the cakefoundation.org URL +c61ab5ee95cbf30a1720457c961337b200ab0c73 +# Run phpcbf for PSR2 CS fixers +be845a3a01e3271fc075e49dcb81d73b0ec169c5 +# CS: Trailing comma on function calls (#18032) +df42951a67281549f5ccdf9a65cfe4c2c8c69a6e +# CS: Trailing comma (#18047) +6a0a69422e86bbea3d0ea9b591949727410e6fcb diff --git a/.gitattributes b/.gitattributes index 3eb08e10984..eba5719a013 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,25 +1,8 @@ # Define the line ending behavior of the different file extensions -# Set default behaviour, in case users don't have core.autocrlf set. -* text=auto -* text eol=lf - -# Explicitly declare text files we want to always be normalized and converted -# to native line endings on checkout. -*.php text -*.default text -*.ctp text -*.sql text -*.md text -*.po text -*.js text -*.css text -*.ini text -*.properties text -*.txt text -*.xml text -*.svg text -*.yml text -.htaccess text +# Set default behavior, in case users don't have core.autocrlf set. +* text text=auto eol=lf + +.php diff=php # Declare files that will always have CRLF line endings on checkout. *.bat eol=crlf @@ -42,16 +25,44 @@ *.eot binary # Remove files for archives generated using `git archive` -appveyor.yml export-ignore -CONTRIBUTING.md export-ignore +.github export-ignore +.phive export-ignore +contrib export-ignore +tests/test_app export-ignore +tests/TestCase export-ignore + .editorconfig export-ignore .gitattributes export-ignore .gitignore export-ignore +.git-blame-ignore-revs export-ignore +.mailmap export-ignore Makefile export-ignore +phpcs.xml export-ignore +phpstan.neon.dist export-ignore +phpstan-baseline.neon export-ignore phpunit.xml.dist export-ignore -.travis.yml export-ignore -.scrutinizer.yml export-ignore -.stickler.yml export-ignore -tests/test_app export-ignore -tests/TestCase export-ignore -.github export-ignore +psalm.xml export-ignore +psalm-baseline.xml export-ignore +rector.php export-ignore +structarmed.php export-ignore + +tests/composer.lock export-ignore +tests/phpstan.neon export-ignore +tests/phpstan-baseline.neon export-ignore + +# Split package files +src/Database/.gitattributes export-ignore +src/Database/phpstan.neon.dist export-ignore +src/Database/tests/ export-ignore +src/Datasource/.gitattributes export-ignore +src/Datasource/phpstan.neon.dist export-ignore +src/Datasource/tests/ export-ignore +src/Http/.gitattributes export-ignore +src/Http/phpstan.neon.dist export-ignore +src/Http/tests/ export-ignore +src/ORM/.gitattributes export-ignore +src/ORM/phpstan.neon.dist export-ignore +src/ORM/tests/ export-ignore +src/Validation/.gitattributes export-ignore +src/Validation/phpstan.neon.dist export-ignore +src/Validation/tests/ export-ignore diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 173f7ac6463..0199bc8e717 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -25,26 +25,33 @@ Help us keep CakePHP open and inclusive. Please read and follow our [Code of Con ## Making Changes * Create a topic branch from where you want to base your work. - * This is usually the master branch. - * Only target release branches if you are certain your fix must be on that - branch. - * To quickly create a topic branch based on master; `git branch - master/my_contribution master` then checkout the new branch with `git - checkout master/my_contribution`. Better avoid working directly on the - `master` branch, to avoid conflicts if you pull in updates from origin. + * This is usually the current default branch - `5.x` right now. + * To quickly create a topic branch based on `5.x` + `git branch 5.x/my_contribution 5.x` then checkout the new branch with `git + checkout 5.x/my_contribution`. Better avoid working directly on the + `5.x` branch, to avoid conflicts if you pull in updates from origin. * Make commits of logical units. * Check for unnecessary whitespace with `git diff --check` before committing. * Use descriptive commit messages and reference the #issue number. -* Core test cases should continue to pass. You can run tests locally or enable - [travis-ci](https://travis-ci.org/) for your fork, so all tests and codesniffs - will be executed. -* Your work should apply the [CakePHP coding standards](https://book.cakephp.org/3.0/en/contributing/cakephp-coding-conventions.html). +* [Core test cases, static analysis and codesniffer](#test-cases-codesniffer-and-static-analysis) should continue to pass. +* Your work should apply the [CakePHP coding standards](https://book.cakephp.org/4/en/contributing/cakephp-coding-conventions.html). ## Which branch to base the work -* Bugfix branches will be based on master. -* New features that are backwards compatible will be based on the appropriate 'next' branch. For example if you want to contribute to the next 3.x branch, you should base your changes on `3.next`. -* New features or other non backwards compatible changes will go in the next major release branch. Development on 4.0 has not started yet, so breaking changes are unlikely to be merged in. +* Bugfix branches will be based on the current default branch - `5.x` right now. +* New features that are **backwards compatible** will be based on the appropriate `next` branch. For example if you want to contribute to the next 5.x branch, you should base your changes on `5.next`. +* New features or other **non backwards compatible** changes will go in the next major release branch. + +## What is "backwards compatible" (BC) + +`BC breaking` code changes mean, that a given PR introduces code changes which can't be performed by everyone without the need to manually adjust code. + +Here are some rules which **prevent** `BC breaking` code changes: + +* Configuration doesn't need to change +* Public API doesn't change. For example, any user land code using/overriding public methods shouldn't break. + +Also see our current [Release Policy](https://book.cakephp.org/4/en/release-policy.html) ## Submitting Changes @@ -52,41 +59,50 @@ Help us keep CakePHP open and inclusive. Please read and follow our [Code of Con * Submit a pull request to the repository in the CakePHP organization, with the correct target branch. -## Test cases and codesniffer - -CakePHP tests requires [PHPUnit](https://phpunit.de/manual/current/en/installation.html). -To install PHPUnit use composer: - - php composer.phar require "phpunit/phpunit:*" +## Test cases, codesniffer and static analysis To run the test cases locally use the following command: - vendor/bin/phpunit + composer test You can copy file `phpunit.xml.dist` to `phpunit.xml` and modify the database -driver settings as required to run tests for particular database. - -You can also register on [Travis CI](https://travis-ci.org/) and from your -[profile](https://travis-ci.org/profile) page enable the service hook for your -CakePHP fork on GitHub for automated test builds. +driver settings as required to run tests for a particular database. To run the sniffs for CakePHP coding standards: - vendor/bin/phpcs -p --extensions=php --standard=vendor/cakephp/cakephp-codesniffer/CakePHP ./src + composer cs-check Check the [cakephp-codesniffer](https://github.com/cakephp/cakephp-codesniffer) -repository to setup the CakePHP standard. The [README](https://github.com/cakephp/cakephp-codesniffer/blob/master/README.md) contains installation info +repository to set up the CakePHP standard. The [README](https://github.com/cakephp/cakephp-codesniffer/blob/master/README.md) contains installation info for the sniff and phpcs. +To run static analysis tools [PHPStan](https://github.com/phpstan/phpstan) and [Psalm](https://github.com/vimeo/psalm) you first have to install the additional packages via [phive](https://phar.io). + + composer stan-setup + +The currently used PHPStan and Psalm versions can be found in `.phive/phars.xml`. + +After that you can perform the checks via: + + composer stan + +Note that updating the baselines need to be done with the same PHP version it is run online. +That is usually the minimum version. +Make sure to "composer install" and set up the stan tools with it and then also execute them. + ## Reporting a Security Issue -If you've found a security related issue in CakePHP, please don't open an issue in github. Instead contact us at security@cakephp.org. For more information on how we handle security issues, [see the CakePHP Security Issue Process](https://book.cakephp.org/3.0/en/contributing/tickets.html#reporting-security-issues). +If you've found a security related issue in CakePHP, please don't open an issue in github. Instead, contact us at security@cakephp.org. For more information on how we handle security issues, [see the CakePHP Security Issue Process](https://book.cakephp.org/4/en/contributing/tickets.html#reporting-security-issues). # Additional Resources -* [CakePHP coding standards](https://book.cakephp.org/3.0/en/contributing/cakephp-coding-conventions.html) +* [CakePHP coding standards](https://book.cakephp.org/4/en/contributing/cakephp-coding-conventions.html) * [Existing issues](https://github.com/cakephp/cakephp/issues) * [Development Roadmaps](https://github.com/cakephp/cakephp/wiki#roadmaps) * [General GitHub documentation](https://help.github.com/) * [GitHub pull request documentation](https://help.github.com/articles/creating-a-pull-request/) -* `#cakephp` IRC channel on freenode.org +* [Forum](https://discourse.cakephp.org/) +* [Stackoverflow](https://stackoverflow.com/tags/cakephp) +* [IRC channel #cakephp](https://kiwiirc.com/client/irc.freenode.net#cakephp) +* [Slack](https://slack-invite.cakephp.org/) +* [Discord](https://discord.gg/k4trEMPebj) diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 09be6b950b7..00000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,23 +0,0 @@ -This is a (multiple allowed): - -* [x] bug -* [ ] enhancement -* [ ] feature-discussion (RFC) - -* CakePHP Version: EXACT RELEASE VERSION OR COMMIT HASH, HERE. -* Platform and Target: YOUR WEB-SERVER, DATABASE AND OTHER RELEVANT INFO AND HOW THE REQUEST IS BEING MADE, HERE. - -### What you did -EXPLAIN WHAT YOU DID, PREFERABLY WITH CODE EXAMPLES, HERE. - -### What happened -EXPLAIN WHAT IS ACTUALLY HAPPENING, HERE. - -### What you expected to happen -EXPLAIN WHAT IS TO BE EXPECTED, HERE. - -P.S. Remember, an issue is not the place to ask questions. You can use [Stack Overflow](https://stackoverflow.com/questions/tagged/cakephp) -for that or join the #cakephp channel on irc.freenode.net, where we will be more -than happy to help answer your questions. - -Before you open an issue, please check if a similar issue already exists or has been closed before. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000000..e04b3cc08a4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,29 @@ +name: Bug Report +description: Create a bug report +type: bug +labels: ["defect"] +body: + - type: textarea + attributes: + label: Description + description: "Please provide a description and way to reproduce the problem." + placeholder: | + This issue tracker is *not* a support forum. + Please use the CakePHP Slack channel, Discord channel or Discourse forum for support questions. + https://book.cakephp.org/5/en/intro/where-to-get-help.html + validations: + required: true + - type: input + attributes: + label: CakePHP Version + description: "The CakePHP version used." + placeholder: "5.0" + validations: + required: true + - type: input + attributes: + label: PHP Version + description: "The php version used, if needed." + placeholder: "8.0" + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000000..07a7d060184 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Community Support + url: https://cakephp.org/get-involved#getHelp + about: Please use the CakePHP Slack channel, Discord channel or IRC channel for questions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000000..d893e9eff95 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,18 @@ +name: Feature Request +description: Create a feature request +type: enhancement +labels: ["enhancement"] +body: + - type: textarea + attributes: + label: Description + description: "Please provide a description of the feature or enhancement." + validations: + required: true + - type: input + attributes: + label: CakePHP Version + description: "The CakePHP version if for a specific major/minor." + placeholder: "5.0" + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a3a28c33608..316acf5a64c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,5 +1,7 @@ -Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request. If it fixes a bug or resolves a feature request, be sure to link to that issue. + diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000000..a61b2a1656d --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Supported Versions + +The supported version list can be found in [GitHub wiki](https://github.com/cakephp/cakephp/wiki#supported-versions). + +## Reporting a Vulnerability + +If you’ve found a security issue in CakePHP, please use the following procedure +instead of the normal bug reporting system. Instead of using the bug tracker, +or one of the support forums please send an email to security [at] cakephp.org. Emails +sent to this address go to the CakePHP core team on a private mailing list. + +For each report, we try to first confirm the vulnerability. Once confirmed, +the CakePHP team will take the following actions: + +* Acknowledge to the reporter that we’ve received the issue, and are + working on a fix. We ask that the reporter keep the issue confidential until we announce it. +* Get a fix/patch prepared. +* Prepare a post describing the vulnerability, and the possible exploits. +* Release new versions of all affected versions. +* Prominently feature the problem in the release announcement + diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 00000000000..0d79235e357 --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,7 @@ +codecov: + require_ci_to_pass: yes + +coverage: + range: "90...100" + +comment: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..78cb4271e62 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: +- package-ecosystem: composer + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + cooldown: + default-days: 7 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + cooldown: + default-days: 7 diff --git a/.github/workflows/api-docs.yml b/.github/workflows/api-docs.yml new file mode 100644 index 00000000000..495d772f691 --- /dev/null +++ b/.github/workflows/api-docs.yml @@ -0,0 +1,33 @@ +--- +name: 'api-docs-deploy' + +on: + push: + tags: + - 4.* + - 5.* + workflow_dispatch: + +permissions: {} + +jobs: + trigger-api: + runs-on: ubuntu-24.04 + steps: + - name: Get Cakebot App Token + id: app-token + uses: getsentry/action-github-app-token@v3 + with: + app_id: ${{ secrets.CAKEBOT_APP_ID }} + private_key: ${{ secrets.CAKEBOT_APP_PRIVATE_KEY }} + + - name: Trigger API build + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: > + curl -XPOST + -H "Authorization: Bearer ${APP_TOKEN}" + -H 'Accept: application/vnd.github.v3+json' + -H 'Content-Type: application/json' + https://api.github.com/repos/cakephp/cakephp-api-docs/actions/workflows/deploy_2x.yml/dispatches + --data '{"ref":"2.x"}' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000000..e0c639864a8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,378 @@ +name: CI + +on: + push: + branches: + - '4.x' + - '5.x' + - '5.next' + pull_request: + branches: + - '*' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read # to fetch code (actions/checkout) + +jobs: + testsuite: + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php-version: ['8.2', '8.5'] + db-type: [sqlite, pgsql] + dependencies: ['highest'] + include: + - php-version: '8.2' + db-type: 'mariadb' + dependencies: highest + + - php-version: '8.2' + db-type: 'mysql' + dependencies: 'lowest' + + - php-version: '8.2' + db-type: 'mysql' + dependencies: highest + + - php-version: '8.3' + db-type: 'mysql' + dependencies: highest + + - php-version: '8.4' + db-type: 'mysql' + dependencies: highest + + - php-version: '8.5' + db-type: 'mysql' + dependencies: highest + + services: + redis: + image: redis + ports: + - 6379/tcp + memcached: + image: memcached + ports: + - 11211/tcp + redis-cluster: + image: grokzen/redis-cluster:6.2.1 + ports: + - 7000:7000 + - 7001:7001 + options: >- + --health-cmd "redis-cli -p 7000 ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Setup MySQL 8.4 + if: matrix.db-type == 'mysql' && matrix.dependencies == 'highest' && matrix.php-version != '8.3' + run: docker run --rm --name=mysqld -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=cakephp -p 3306:3306 -d mysql:8.4 + + - name: Setup MySQL 8.0 + if: matrix.db-type == 'mysql' && matrix.dependencies == 'highest' && matrix.php-version == '8.3' + run: | + sudo service mysql start + mysql -h 127.0.0.1 -u root -proot -e 'CREATE DATABASE cakephp;' + + - name: Setup MySQL 5.7 + if: matrix.db-type == 'mysql' && matrix.dependencies == 'lowest' + run: docker run --rm --name=mysqld -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=cakephp -p 3306:3306 -d mysql:5.7 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + + - name: Setup PostgreSQL with PostGIS + if: matrix.db-type == 'pgsql' + run: docker run --rm --name=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=cakephp -p 5432:5432 -d postgis/postgis:18-3.6 + + - name: Setup MariaDB 11.8 + if: matrix.db-type == 'mariadb' + run: | + docker run -d --name=mariadb \ + -e MARIADB_ROOT_PASSWORD=root \ + -e MARIADB_DATABASE=cakephp \ + -p 3306:3306 \ + --health-cmd="mariadb-admin ping -h 127.0.0.1 -proot || exit 1" \ + --health-interval=10s \ + --health-timeout=5s \ + --health-retries=10 \ + mariadb:11.8 + + echo "Waiting for MariaDB to be ready..." + for i in {1..60}; do + if docker exec mariadb mariadb-admin ping -h 127.0.0.1 -proot >/dev/null 2>&1; then + echo "MariaDB is responding." + break + fi + + sleep 2 + done + + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, intl, apcu, memcached, redis, pdo_${{ matrix.db-type }} + ini-values: apc.enable_cli = 1, zend.assertions = 1 + coverage: pcov + + - name: Install packages + run: | + sudo locale-gen da_DK.UTF-8 + sudo locale-gen de_DE.UTF-8 + + - name: Composer install + uses: ramsey/composer-install@v4 + with: + dependency-versions: ${{ matrix.dependencies }} + composer-options: "${{ matrix.composer-options }}" + + - name: Setup problem matchers for PHPUnit + if: matrix.php-version == '8.2' && matrix.db-type == 'mysql' + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Run PHPUnit + env: + REDIS_PORT: ${{ job.services.redis.ports['6379'] }} + MEMCACHED_PORT: ${{ job.services.memcached.ports['11211'] }} + REDIS_CLUSTER_NODES: "127.0.0.1:7000,127.0.0.1:7001" + run: | + if [[ ${{ matrix.db-type }} == 'sqlite' ]]; then export DB_URL='sqlite:///:memory:'; fi + if [[ ${{ matrix.db-type }} == 'mysql' ]]; then export DB_URL='mysql://root:root@127.0.0.1/cakephp'; fi + if [[ ${{ matrix.db-type }} == 'mariadb' ]]; then export DB_URL='mysql://root:root@127.0.0.1/cakephp'; fi + if [[ ${{ matrix.db-type }} == 'pgsql' ]]; then export DB_URL='postgres://postgres:postgres@127.0.0.1/postgres'; fi + + if [[ ${{ matrix.php-version }} == '8.2' && ${{ matrix.dependencies }} == 'highest' ]]; then + export CODECOVERAGE=1 + vendor/bin/phpunit --display-all-issues --fail-on-all-issues --do-not-fail-on-skipped --do-not-fail-on-incomplete --testsuite=cakephp --coverage-clover=coverage.xml + vendor/bin/phpunit --display-all-issues --fail-on-all-issues --do-not-fail-on-skipped --do-not-fail-on-incomplete --testsuite=database --coverage-clover=coverage-database.xml + CAKE_TEST_AUTOQUOTE=1 vendor/bin/phpunit --display-all-issues --fail-on-all-issues --do-not-fail-on-skipped --do-not-fail-on-incomplete --testsuite=database + vendor/bin/phpunit --display-all-issues --fail-on-all-issues --do-not-fail-on-skipped --do-not-fail-on-incomplete --do-not-fail-on-warning --testsuite=globalfunctions --coverage-clover=coverage-functions.xml + elif [[ ${{ matrix.php-version }} == '8.2' && ${{ matrix.dependencies }} == 'lowest' ]]; then + vendor/bin/phpunit + CAKE_TEST_AUTOQUOTE=1 vendor/bin/phpunit --testsuite=database + else + vendor/bin/phpunit --display-phpunit-notices --display-phpunit-deprecations --display-deprecations --display-warnings + CAKE_TEST_AUTOQUOTE=1 vendor/bin/phpunit --display-phpunit-notices --display-phpunit-deprecations --display-deprecations --display-warnings --testsuite=database + fi + + - name: Submit code coverage + if: matrix.php-version == '8.2' + uses: codecov/codecov-action@v7 + with: + files: coverage.xml,coverage-database.xml,coverage-functions.xml + token: ${{ secrets.CODECOV_TOKEN }} + + testsuite-windows: + runs-on: windows-2022 + name: Windows - PHP 8.2 & SQL Server + + env: + EXTENSIONS: mbstring, intl, apcu, redis, pdo_sqlsrv + PHP_VERSION: '8.2' + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Get date part for cache key + id: key-date + run: echo "date=$(date +'%Y-%m')" >> $env:GITHUB_OUTPUT + + - name: Setup PHP extensions cache + id: php-ext-cache + uses: shivammathur/cache-extensions@v1 + with: + php-version: ${{ env.PHP_VERSION }} + extensions: ${{ env.EXTENSIONS }} + key: ${{ steps.key-date.outputs.date }} + + - name: Cache PHP extensions + uses: actions/cache@v6.1.0 + with: + path: ${{ steps.php-ext-cache.outputs.dir }} + key: ${{ runner.os }}-php-ext-${{ steps.php-ext-cache.outputs.key }} + restore-keys: ${{ runner.os }}-php-ext-${{ steps.php-ext-cache.outputs.key }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + extensions: ${{ env.EXTENSIONS }} + ini-values: apc.enable_cli = 1, zend.assertions = 1, extension = php_fileinfo.dll + coverage: none + + - name: Setup SQLServer + run: | + # MSSQLLocalDB is the default SQL LocalDB instance + SqlLocalDB start MSSQLLocalDB + SqlLocalDB info MSSQLLocalDB + sqlcmd -S "(localdb)\MSSQLLocalDB" -Q "create database cakephp;" + + - name: Composer install + uses: ramsey/composer-install@v4 + + - name: Run PHPUnit + env: + DB_URL: 'sqlserver://@(localdb)\MSSQLLocalDB/cakephp' + run: | + set CAKE_DISABLE_GLOBAL_FUNCS=1 + vendor/bin/phpunit --display-incomplete + + - name: Run PHPUnit (autoquote enabled) + env: + DB_URL: 'sqlserver://@(localdb)\MSSQLLocalDB/cakephp' + run: | + set CAKE_TEST_AUTOQUOTE=1 + vendor/bin/phpunit --display-incomplete --testsuite=database + + cs-stan: + name: Coding Standard & Static Analysis + runs-on: ubuntu-24.04 + + env: + PHIVE_KEYS: 'CF1A108D0E7AE720,51C67305FFC2E5C0,12CE0F1D262429A5,99BF4D9A33D65E1E' + PHPSTAN_TESTS: 1 + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: mbstring, intl, pcntl + coverage: none + tools: phive, cs2pr + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Composer install + uses: ramsey/composer-install@v4 + + - name: Cache phive tools + uses: actions/cache@v6.1.0 + with: + path: | + ~/.phive + tools + key: ${{ runner.os }}-phive-${{ hashFiles('.phive/phars.xml') }} + restore-keys: ${{ runner.os }}-phive- + + - name: Install PHP tools with phive. + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: 'phive install --trust-gpg-keys "$PHIVE_KEYS"' + + - name: Run phpstan + if: always() + run: tools/phpstan analyse --error-format=github + + - name: Run Psalm + if: always() + run: tools/psalm --output-format=github + + - name: Setup phpcs cache + if: always() + uses: actions/cache@v6.1.0 + with: + path: ${{ runner.temp }}/phpcs.cache + key: ${{ runner.os }}-phpcs-${{ hashFiles('phpcs.xml', 'composer.lock') }} + restore-keys: ${{ runner.os }}-phpcs- + + - name: Run phpcs + if: always() + run: vendor/bin/phpcs --parallel=8 --cache=${{ runner.temp }}/phpcs.cache --report=checkstyle | cs2pr + + - name: Run phpstan for tests + if: env.PHPSTAN_TESTS + run: tools/phpstan analyse -c tests/phpstan.neon --error-format=github + + - name: Run class deprecation aliasing validation script + if: always() + run: php contrib/validate-deprecation-aliases.php + + - name: Run composer.json validation for split packages + if: always() + run: php contrib/validate-split-packages.php + + - name: Prefer lowest check + if: matrix.prefer-lowest == 'prefer-lowest' + run: composer require --dev dereuromark/composer-prefer-lowest && vendor/bin/validate-prefer-lowest -m + + - name: Setup rector cache + if: always() + uses: actions/cache@v6.1.0 + with: + path: ${{ runner.temp }}/rector + key: ${{ runner.os }}-rector-${{ hashFiles('rector.php', 'composer.lock') }} + restore-keys: ${{ runner.os }}-rector- + + - name: Create rector cache dir + if: always() + run: mkdir -p ${{ runner.temp }}/rector + + - name: Run rector + if: always() + env: + RECTOR_CACHE_DIR: ${{ runner.temp }}/rector + run: composer rector-setup && composer rector-check + + - name: Run StructArmed + if: always() + run: vendor/bin/structarmed analyze src + + split-packages-stan: + name: Static Analysis for Split Packages + runs-on: ubuntu-24.04 + + env: + PHIVE_KEYS: 'CF1A108D0E7AE720,51C67305FFC2E5C0,12CE0F1D262429A5,99BF4D9A33D65E1E' + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: mbstring, intl + coverage: none + tools: phive + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Composer install + uses: ramsey/composer-install@v4 + + - name: Cache phive tools + uses: actions/cache@v6.1.0 + with: + path: | + ~/.phive + tools + key: ${{ runner.os }}-phive-${{ hashFiles('.phive/phars.xml') }} + restore-keys: ${{ runner.os }}-phive- + + - name: Install PHP tools with phive. + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: 'phive install --trust-gpg-keys "$PHIVE_KEYS"' + + - name: Run phpstan for split packages + run: php contrib/validate-split-packages-phpstan.php diff --git a/.github/workflows/split-packages.yml b/.github/workflows/split-packages.yml new file mode 100644 index 00000000000..927da3474d3 --- /dev/null +++ b/.github/workflows/split-packages.yml @@ -0,0 +1,35 @@ +name: Split Packages + +on: + push: + branches: + - '5.x' + - '5.next' + - '6.x' + +permissions: + contents: read # to fetch code (actions/checkout) + +jobs: + split-packages: + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Configure git to use token over HTTPS + env: + GITHUB_TOKEN: ${{ secrets.GH_SPLIT_PACKAGES_WRITE_TOKEN }} + run: | + git config --global url."https://x-access-token:${GITHUB_TOKEN}@github.com/".insteadOf "git@github.com:" + + - name: Push split packages + env: + CURRENT_BRANCH: ${{ github.ref_name }} + GITHUB_TOKEN: ${{ secrets.GH_SPLIT_PACKAGES_WRITE_TOKEN }} + run: | + make CURRENT_BRANCH="${CURRENT_BRANCH}" components + make clean-components-branches diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000000..721204651ee --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,29 @@ +name: Mark stale issues and pull requests + +on: + schedule: + - cron: "0 0 * * *" + +permissions: + contents: read + +jobs: + stale: + + permissions: + issues: write # for actions/stale to close stale issues + pull-requests: write # for actions/stale to close stale PRs + runs-on: ubuntu-latest + + steps: + - uses: actions/stale@v11.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'This issue is stale because it has been open for 120 days with no activity. Remove the `stale` label or comment or this will be closed in 15 days' + stale-pr-message: 'This pull request is stale because it has been open 30 days with no activity. Remove the `stale` label or comment on this issue, or it will be closed in 15 days' + stale-issue-label: 'stale' + stale-pr-label: 'stale' + days-before-stale: 120 + days-before-close: 15 + exempt-issue-labels: 'pinned' + exempt-pr-labels: 'pinned' diff --git a/.gitignore b/.gitignore index b3882d34adf..4135ceccf21 100644 --- a/.gitignore +++ b/.gitignore @@ -5,11 +5,16 @@ /tags /composer.lock /phpunit.xml -/phpcs.xml +/phpstan.neon +/tools /vendor +/composer.phar *.mo debug.log error.log +.phpunit.result.cache +.phpunit.cache +.phpcs.cache # OS generated files # ###################### @@ -35,6 +40,8 @@ Thumbs.db *.tmPreferences.cache # Eclipse .settings/* +/.project +/.buildpath # JetBrains, aka PHPStorm, IntelliJ IDEA .idea/* # NetBeans diff --git a/.mailmap b/.mailmap index 2466bc69c71..8d2c15681dc 100644 --- a/.mailmap +++ b/.mailmap @@ -15,6 +15,7 @@ Walther Lalk Walther Lalk Walther Lalk Mark Scherer +Mark Scherer Mark Scherer Mark Scherer phpnut @@ -111,3 +112,23 @@ antograssiot antograssiot Patrick Conroy Patrick Conroy +saeideng +saeideng +Albert Cansado Solà +Albert Cansado Solà +Albert Cansado Solà +Albert Cansado Solà +Alejandro Ibarra +Andreas Kristiansen +Bob Fanger +Cees-Jan Kiewiet +Cees-Jan Kiewiet +Dmitriy Romanov +Dmitrii Romanov +Dmitrii Romanov +Dmitrii Romanov +Edgaras Janušauskas +Eric Büttner +Eric Büttner +Eric Büttner +Hideki Kinjyo diff --git a/.phive/phars.xml b/.phive/phars.xml new file mode 100644 index 00000000000..a1053000514 --- /dev/null +++ b/.phive/phars.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index ef53e8a462b..00000000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,5 +0,0 @@ -checks: - php: true - -filter: - paths: ['src/*'] diff --git a/.stickler.yml b/.stickler.yml deleted file mode 100644 index 8e82f42e122..00000000000 --- a/.stickler.yml +++ /dev/null @@ -1,13 +0,0 @@ ---- -linters: - phpcs: - standard: CakePHP - extensions: 'php,ctp' - fixer: true - -branches: - ignore: ['2.x', '2.next'] - -fixers: - enable: true - workflow: commit diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 90482439230..00000000000 --- a/.travis.yml +++ /dev/null @@ -1,80 +0,0 @@ -language: php - -php: - - 7.0 - - 5.6 - - 7.1 - - 7.2 - -dist: trusty - -env: - matrix: - - DB=mysql db_dsn='mysql://root@127.0.0.1/cakephp_test' - - DB=pgsql db_dsn='postgres://postgres@127.0.0.1/cakephp_test' - - DB=sqlite db_dsn='sqlite:///:memory:' - global: - - DEFAULT=1 - -services: - - memcached - - redis-server - - postgresql - - mysql - -addons: - postgresql: "9.4" - -cache: - directories: - - vendor - - $HOME/.composer/cache - -matrix: - fast_finish: true - - include: - - php: 7.0 - env: PHPCS=1 DEFAULT=0 - - - php: 7.1 - env: PHPSTAN=1 DEFAULT=0 - -before_install: - - phpenv config-rm xdebug.ini - - - if [ $DB = 'mysql' ]; then mysql -u root -e 'CREATE DATABASE cakephp_test;'; fi - - if [ $DB = 'mysql' ]; then mysql -u root -e 'CREATE DATABASE cakephp_test2;'; fi - - if [ $DB = 'mysql' ]; then mysql -u root -e 'CREATE DATABASE cakephp_test3;'; fi - - - if [ $DB = 'pgsql' ]; then psql -c 'CREATE DATABASE cakephp_test;' -U postgres; fi - - if [ $DB = 'pgsql' ]; then psql -c 'CREATE SCHEMA test2;' -U postgres -d cakephp_test; fi - - if [ $DB = 'pgsql' ]; then psql -c 'CREATE SCHEMA test3;' -U postgres -d cakephp_test; fi - - - if [[ $DEFAULT = 1 || $PHPSTAN = 1 ]] ; then pecl channel-update pecl.php.net; fi; - - if [[ $DEFAULT = 1 || $PHPSTAN = 1 ]] ; then echo 'extension = memcached.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini; fi - - if [[ $DEFAULT = 1 || $PHPSTAN = 1 ]] ; then echo 'extension = redis.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini; fi - - if [[ $DEFAULT = 1 || $PHPSTAN = 1 ]] ; then echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini; fi - - if [[ $DEFAULT = 1 || $PHPSTAN = 1 ]] ; then echo 'apc.enable_cli = 1' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini; fi - - - if [[ ${TRAVIS_PHP_VERSION:0:1} == "7" ]] ; then echo "yes" | pecl install channel://pecl.php.net/apcu-5.1.5 || true; fi - - if [[ ${TRAVIS_PHP_VERSION:0:1} == "5" ]] ; then echo "yes" | pecl install apcu-4.0.11 || true; fi - - if [[ ${TRAVIS_PHP_VERSION:0:1} == "5" && $DB = 'mysql' ]] ; then wget http://xcache.lighttpd.net/pub/Releases/3.2.0/xcache-3.2.0.tar.gz; tar xf xcache-3.2.0.tar.gz; pushd xcache-3.2.0; phpize; ./configure; make; NO_INTERACTION=1 make test; make install; popd;printf "extension=xcache.so\nxcache.size=64M\nxcache.var_size=16M\nxcache.test=On" > ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini; fi - - - sudo locale-gen da_DK - -before_script: - - composer install --prefer-dist --no-interaction - -script: - - if [[ $DEFAULT = 1 && $TRAVIS_PHP_VERSION = 7.0 ]]; then export CODECOVERAGE=1; phpdbg -qrr vendor/bin/phpunit --coverage-clover=clover.xml; fi - - if [[ $DEFAULT = 1 && $TRAVIS_PHP_VERSION != 7.0 ]]; then vendor/bin/phpunit; fi - - - if [[ $PHPCS = 1 ]]; then composer cs-check; fi - - if [[ $PHPSTAN = 1 ]]; then composer require --dev phpstan/phpstan:^0.9 && vendor/bin/phpstan analyse -c phpstan.neon -l 2 src; fi - -after_success: - - if [[ $DEFAULT = 1 && $TRAVIS_PHP_VERSION = 7.0 ]]; then bash <(curl -s https://codecov.io/bash); fi - -notifications: - email: false diff --git a/.varci.yml b/.varci.yml deleted file mode 100644 index a9fd806f077..00000000000 --- a/.varci.yml +++ /dev/null @@ -1,44 +0,0 @@ -ruleset: - label_defects: - name: "Label defects" - events: [ issues, pull_request ] - label: Defect - when: - - action = "opened" - - body matches "/\[\s?x\s?\] bug/" - - label_enhancements: - name: "Label enhancements" - events: [ issues, pull_request ] - label: Enhancement - when: - - action = "opened" - - body matches "/\[\s?x\s?\] enhancement/" - - label_rfcs: - name: "Label RFCs" - events: [ issues ] - label: RFC - when: - - action = "opened" - - body matches "/\[\s?x\s?\] feature\-discussion/" - - remove_invalid: - name: "Remove invalid tag when issue re-opened" - events: [ issues, pull_request ] - label: -Invalid - when: - - action = "reopened" - - filter(labels, "name") has "Invalid" - - request_missing_version: - name: "Request missing version" - events: [ issues ] - label: "On hold" - when: - - action = "opened" or action = "re-opened" - - body matches "/\[x\] bug/" - - 'not(body matches "/CakePHP Version: v?(\d+\.)?(\d+\.)?(\*|\d+)/")' - - 'not(body matches "/CakePHP Version: [0-9a-f]{5,40}/")' - comment: '{{ user.login }}, please include the CakePHP version number you are using in your description. It helps us debug your issue.' - diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000000..291d80e1bad --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2005-present, Cake Software Foundation, Inc. (https://cakefoundation.org) + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index 624193e46c5..00000000000 --- a/LICENSE.txt +++ /dev/null @@ -1,22 +0,0 @@ -The MIT License (MIT) - -CakePHP(tm) : The Rapid Development PHP Framework (https://cakephp.org) -Copyright (c) 2005-2018, Cake Software Foundation, Inc. (https://cakefoundation.org) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/Makefile b/Makefile index 7e38689d8df..0e358ca566d 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,11 @@ # The following env variables need to be set: # - VERSION -# - GITHUB_USER -# - GITHUB_TOKEN (optional if you have two factor authentication in github) +# - GITHUB_TOKEN personal access API token for github. # Use the version number to figure out if the release # is a pre-release PRERELEASE=$(shell echo $(VERSION) | grep -E 'dev|rc|alpha|beta' --quiet && echo 'true' || echo 'false') -COMPONENTS= filesystem log utility cache datasource core collection event validation database i18n ORM form +COMPONENTS=cache console core collection container database datasource event filesystem form http i18n log ORM utility validation CURRENT_BRANCH=$(shell git branch | grep '*' | tr -d '* ') # Github settings @@ -17,8 +16,6 @@ REMOTE=origin ifdef GITHUB_TOKEN AUTH=-H 'Authorization: token $(GITHUB_TOKEN)' -else - AUTH=-u $(GITHUB_USER) -p$(GITHUB_PASS) endif DASH_VERSION=$(shell echo $(VERSION) | sed -e s/\\./-/g) @@ -28,27 +25,31 @@ DASH_VERSION=$(shell echo $(VERSION) | sed -e s/\\./-/g) # correct tag in that repo. # For 3.1.x use 3.1.2 # For 3.0.x use 3.0.5 -APP_VERSION:=master +APP_VERSION:=5.x + +# The branch name of the 'next' branch that will also have package +# splits updated during a release. +NEXT_BRANCH=5.next ALL: help -.PHONY: help install test need-version bump-version tag-version help: @echo "CakePHP Makefile" @echo "================" @echo "" @echo "release VERSION=x.y.z" - @echo " Create a new release of CakePHP. Requires the VERSION and GITHUB_USER, or GITHUB_TOKEN parameter." + @echo " Create a new release of CakePHP. Requires the VERSION and GITHUB_TOKEN parameter." @echo " Packages up a new app skeleton tarball and uploads it to github." @echo "" @echo "package" @echo " Build the app package with all its dependencies." @echo "" @echo "publish" - @echo " Publish the dist/cakephp-VERSION.zip to github." + @echo " Publish the dist/cakephp-VERSION.zip to GitHub." @echo "" @echo "components" - @echo " Split each of the public namespaces into separate repos and push the to Github." + @echo " Split each of the public namespaces into separate repos and push the to GitHub." + @echo " Can be run with CURRENT_BRANCH=xx to split a specific branch." @echo "" @echo "clean-components CURRENT_BRANCH=xx" @echo " Delete branch xx from each subsplit. Useful when cleaning up after a security release." @@ -56,11 +57,20 @@ help: @echo "test" @echo " Run the tests for CakePHP." @echo "" + @echo "lint-src" + @echo " Run php -l against every PHP file in src/." + @echo "" @echo "All other tasks are not intended to be run directly." +.PHONY: help test: install vendor/bin/phpunit +.PHONY: test + +lint-src: + @contrib/lint-php-src src +.PHONY: lint-src # Utility target for checking required parameters @@ -78,6 +88,7 @@ composer.phar: # Install dependencies install: composer.phar php composer.phar install +.PHONY: install @@ -91,6 +102,7 @@ bump-version: guard-VERSION rm VERSION.old git add VERSION.txt git commit -m "Update version number to $(VERSION)" +.PHONY: bump-version # Tag a release tag-release: guard-VERSION bump-version @@ -103,11 +115,9 @@ tag-release: guard-VERSION bump-version # Tasks for tagging the app skeleton and # creating a zipball of a fully built app skeleton. -.PHONY: clean package - clean: rm -rf build - rm -rf dist +.PHONY: clean build: mkdir -p build @@ -125,7 +135,7 @@ dist/cakephp-$(DASH_VERSION).zip: build/app build/cakephp composer.phar mkdir -p dist @echo "Installing app dependencies with composer" # Install deps with composer - cd build/app && php ../../composer.phar install + cd build/app && php ../../composer.phar install && ../../composer.phar run-script post-install-cmd --no-interaction # Copy the current cakephp libs up so we don't have to wait # for packagist to refresh. rm -rf build/app/vendor/cakephp/cakephp @@ -138,24 +148,14 @@ dist/cakephp-$(DASH_VERSION).zip: build/app build/cakephp composer.phar # Easier to type alias for zip balls package: clean dist/cakephp-$(DASH_VERSION).zip +.PHONY: package - - -# Tasks to publish zipballs to Github. -.PHONY: publish release - -publish: guard-VERSION guard-GITHUB_USER dist/cakephp-$(DASH_VERSION).zip +# Publish app skeleton with dependencies zipballs to Github. +publish: guard-VERSION dist/cakephp-$(DASH_VERSION).zip @echo "Creating draft release for $(VERSION). prerelease=$(PRERELEASE)" - curl $(AUTH) -XPOST $(API_HOST)/repos/$(OWNER)/cakephp/releases -d '{ \ - "tag_name": "$(VERSION)", \ - "name": "CakePHP $(VERSION) released", \ - "draft": true, \ - "prerelease": $(PRERELEASE) \ - }' > release.json + curl $(AUTH) -XPOST $(API_HOST)/repos/$(OWNER)/cakephp/releases -d '{"tag_name": "$(VERSION)", "name": "CakePHP $(VERSION)", "draft": true, "prerelease": $(PRERELEASE)}' > release.json # Extract id out of response json. - php -r '$$f = file_get_contents("./release.json"); \ - $$d = json_decode($$f, true); \ - file_put_contents("./id.txt", $$d["id"]);' + php -r '$$f = file_get_contents("./release.json"); $$d = json_decode($$f, true); file_put_contents("./id.txt", $$d["id"]);' @echo "Uploading zip file to github." curl $(AUTH) -XPOST \ $(UPLOAD_HOST)/repos/$(OWNER)/cakephp/releases/`cat ./id.txt`/assets?name=cakephp-$(DASH_VERSION).zip \ @@ -165,38 +165,46 @@ publish: guard-VERSION guard-GITHUB_USER dist/cakephp-$(DASH_VERSION).zip # Cleanup files. rm release.json rm id.txt +.PHONY: publish # Tasks for publishing separate repositories out of each CakePHP namespace - components: $(foreach component, $(COMPONENTS), component-$(component)) +.PHONY: components + components-tag: $(foreach component, $(COMPONENTS), tag-component-$(component)) +.PHONY: components-tag component-%: git checkout $(CURRENT_BRANCH) > /dev/null - - (git remote add $* git@github.com:$(OWNER)/$*.git -f 2> /dev/null) + - (git remote add pkg-$* git@github.com:$(OWNER)/$*.git -f 2> /dev/null) - (git branch -D $* 2> /dev/null) - git checkout -b $* - git filter-branch --prune-empty --subdirectory-filter src/$(shell php -r "echo ucfirst('$*');") -f $* - git push -f $* $*:$(CURRENT_BRANCH) - git checkout $(CURRENT_BRANCH) > /dev/null + git branch $* $(CURRENT_BRANCH) + python3 contrib/git-filter-repo --subdirectory-filter src/$(shell php -r "echo ucfirst('$*');") --refs refs/heads/$* --force + git push -f pkg-$* $*:$(CURRENT_BRANCH) -tag-component-%: component-% guard-VERSION guard-GITHUB_USER +tag-component-%: component-% guard-VERSION guard-GITHUB_TOKEN @echo "Creating tag for the $* component" git checkout $* - curl $(AUTH) -XPOST $(API_HOST)/repos/$(OWNER)/$*/git/refs -d '{ \ - "ref": "refs\/tags\/$(VERSION)", \ - "sha": "$(shell git rev-parse $*)" \ - }' + curl $(AUTH) -XPOST $(API_HOST)/repos/$(OWNER)/$*/git/refs -d '{"ref": "refs\/tags\/$(VERSION)", "sha": "$(shell git rev-parse $*)"}' git checkout $(CURRENT_BRANCH) > /dev/null + make clean-component-branch-$* + +# Task for cleaning up branches and remotes after updating split packages +clean-components-branches: $(foreach component, $(COMPONENTS), clean-component-branch-$(component)) +.PHONY: clean-components-branches + +clean-component-branch-%: git branch -D $* - git remote rm $* + git remote rm pkg-$* # Tasks for cleaning up branches created by security fixes to old branches. components-clean: $(foreach component, $(COMPONENTS), clean-component-$(component)) clean-component-%: - - (git remote add $* git@github.com:$(OWNER)/$*.git -f 2> /dev/null) + - (git remote add pkg-$* git@github.com:$(OWNER)/$*.git -f 2> /dev/null) - (git branch -D $* 2> /dev/null) - - git push -f $* :$(CURRENT_BRANCH) + - git push -f pkg-$* :$(CURRENT_BRANCH) +.PHONY: components-clean # Top level alias for doing a release. -release: guard-VERSION guard-GITHUB_USER tag-release components-tag package publish +release: guard-VERSION lint-src tag-release components-tag package publish +.PHONY: release diff --git a/README.md b/README.md index 5dc3663d57d..90514947435 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@

- + Software License - - Build Status + + Coverage Status - - Coverage Status + + PHPStan Code Consistency @@ -38,27 +38,26 @@ recommend using the [app skeleton](https://github.com/cakephp/app) as a starting point. For existing applications you can run the following: ``` bash -$ composer require cakephp/cakephp:"~3.5" +composer require cakephp/cakephp ``` +For details on the (minimum/maximum) PHP version see [version map](https://github.com/cakephp/cakephp/wiki#version-map). + ## Running Tests -Assuming you have PHPUnit installed system wide using one of the methods stated -[here](https://phpunit.de/manual/current/en/installation.html), you can run the -tests for CakePHP by doing the following: +Assuming you have PHPUnit installed (`composer install`), you can run the tests for CakePHP by doing the following: 1. Copy `phpunit.xml.dist` to `phpunit.xml`. 2. Add the relevant database credentials to your `phpunit.xml` if you want to run tests against a non-SQLite datasource. -3. Run `phpunit`. +3. Run `vendor/bin/phpunit`. -## Some Handy Links +## Learn More -* [CakePHP](https://cakephp.org) - The rapid development PHP framework. -* [CookBook](https://book.cakephp.org) - The CakePHP user documentation; start learning here! -* [API](https://api.cakephp.org) - A reference to CakePHP's classes. -* [Awesome CakePHP](https://github.com/FriendsOfCake/awesome-cakephp) - A list of featured resources around the framework. -* [Plugins](https://plugins.cakephp.org) - A repository of extensions to the framework. +* [CakePHP](https://cakephp.org) - The home of the CakePHP project. +* [Book](https://book.cakephp.org) - The CakePHP documentation; start learning here! +* [API](https://api.cakephp.org) - A reference to CakePHP's classes and API documentation. +* [Awesome CakePHP](https://github.com/FriendsOfCake/awesome-cakephp) - A curated list of featured resources around the framework. * [The Bakery](https://bakery.cakephp.org) - Tips, tutorials and articles. * [Community Center](https://community.cakephp.org) - A source for everything community related. * [Training](https://training.cakephp.org) - Join a live session and get skilled with the framework. @@ -67,25 +66,19 @@ tests for CakePHP by doing the following: ## Get Support! -* [Slack](https://cakesf.herokuapp.com/) - Join us on Slack. +* [Slack](https://slack-invite.cakephp.org/) - Join us on Slack. +* [Discord](https://discord.gg/k4trEMPebj) - Join us on Discord. * [#cakephp](https://webchat.freenode.net/?channels=#cakephp) on irc.freenode.net - Come chat with us, we have cake. -* [Forum](http://discourse.cakephp.org/) - Official CakePHP forum. +* [Forum](https://discourse.cakephp.org/) - Official CakePHP forum. * [GitHub Issues](https://github.com/cakephp/cakephp/issues) - Got issues? Please tell us! * [Roadmaps](https://github.com/cakephp/cakephp/wiki#roadmaps) - Want to contribute? Get involved! ## Contributing * [CONTRIBUTING.md](.github/CONTRIBUTING.md) - Quick pointers for contributing to the CakePHP project. -* [CookBook "Contributing" Section](https://book.cakephp.org/3.0/en/contributing.html) - Details about contributing to the project. +* [CookBook "Contributing" Section](https://book.cakephp.org/5/en/contributing.html) - Details about contributing to the project. # Security -If you’ve found a security issue in CakePHP, please use the following procedure instead of the normal bug reporting system. Instead of using the bug tracker, mailing list or IRC please send an email to security [at] cakephp.org. Emails sent to this address go to the CakePHP core team on a private mailing list. - -For each report, we try to first confirm the vulnerability. Once confirmed, the CakePHP team will take the following actions: - -- Acknowledge to the reporter that we’ve received the issue, and are working on a fix. We ask that the reporter keep the issue confidential until we announce it. -- Get a fix/patch prepared. -- Prepare a post describing the vulnerability, and the possible exploits. -- Release new versions of all affected versions. -- Prominently feature the problem in the release announcement. +If you’ve found a security issue in CakePHP, please use the procedure +described in [SECURITY.md](.github/SECURITY.md). diff --git a/VERSION.txt b/VERSION.txt index 1d966df9537..5dd9f697c62 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -16,4 +16,4 @@ // @license https://opensource.org/licenses/mit-license.php MIT License // +--------------------------------------------------------------------------------------------+ // //////////////////////////////////////////////////////////////////////////////////////////////////// -3.5.11 +5.4.1 diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 35db3289c21..00000000000 --- a/appveyor.yml +++ /dev/null @@ -1,91 +0,0 @@ -build: false -shallow_clone: false -platform: 'x86' -clone_folder: c:\projects\cakephp - -cache: - - '%LOCALAPPDATA%\Composer' - - '%APPDATA%\Composer' - -branches: - only: - - master - - 3.next -environment: - global: - PHP: "C:/PHP" - - matrix: - - db: 2012 - db_dsn: 'sqlserver://sa:Password12!@.\SQL2012SP1/cakephp?MultipleActiveResultSets=false' - -services: - - mssql2012sp1 - -init: - - SET PATH=C:\php\;%PATH% - -install: - - cd c:\ - - appveyor DownloadFile http://windows.php.net/downloads/releases/php-5.6.32-nts-Win32-VC11-x86.zip -FileName php.zip - - 7z x php.zip -oc:\php > nul - - appveyor DownloadFile https://dl.dropboxusercontent.com/s/euip490d9183jkr/SQLSRV32.cab -FileName sqlsrv.cab - - 7z x sqlsrv.cab -oc:\php\ext php*_56_nts.dll > nul - - cd c:\php - - copy php.ini-production php.ini - - echo date.timezone="UTC" >> php.ini - - echo extension_dir=ext >> php.ini - - echo extension=php_openssl.dll >> php.ini - - echo extension=php_sqlsrv_56_nts.dll >> php.ini - - echo extension=php_pdo_sqlsrv_56_nts.dll >> php.ini - - echo extension=php_intl.dll >> php.ini - - echo extension=php_mbstring.dll >> php.ini - - echo extension=php_fileinfo.dll >> php.ini - - appveyor DownloadFile http://windows.php.net/downloads/pecl/releases/wincache/1.3.7.12/php_wincache-1.3.7.12-5.6-nts-vc11-x86.zip -FileName wincache.zip - - 7z x wincache.zip -oc:\php\ext php_wincache.dll > nul - - echo extension=php_wincache.dll >> php.ini - - echo wincache.enablecli = 1 >> php.ini - - cd C:\projects\cakephp - - appveyor DownloadFile https://getcomposer.org/composer.phar - - php composer.phar install --prefer-dist --no-interaction --ansi --no-progress - - php -i | grep "ICU version" - -before_test: -# This script solves the "Database 'model' is being recovered. Waiting until recovery is finished." -# This solution comes from https://gist.github.com/jonathanhickford/1cb0d6665adab8b9c664 -# and is follow by http://help.appveyor.com/discussions/suggestions/264-database-mssqlsystemresource-is-being-recovered-waiting-for-sql-server-to-start -- ps: >- - $tries = 5; - - $pause = 10; # Seconds to wait between tries - - While ($tries -gt 0) { - try { - $ServerConnectionString = "Data Source=(local)\SQL2012SP1;Initial Catalog=master;User Id=sa;PWD=Password12!"; - $ServerConnection = new-object system.data.SqlClient.SqlConnection($ServerConnectionString); - $query = "exec sp_configure 'clr enabled', 1;`n" - $query = $query + "RECONFIGURE;`n" - $cmd = new-object system.data.sqlclient.sqlcommand($query, $ServerConnection); - $ServerConnection.Open(); - "Running:" - $query - if ($cmd.ExecuteNonQuery() -ne -1) { - "SQL Error"; - } else { - "Success" - } - $ServerConnection.Close(); - $tries = 0; - } catch { - "Error:" - $_.Exception.Message - "Retry in $pause seconds. Attempts left: $tries"; - Start-Sleep -s $pause; - } - $tries = $tries -1; - } - -test_script: - - sqlcmd -S ".\SQL2012SP1" -U sa -P Password12! -Q "create database cakephp;" - - cd C:\projects\cakephp - - vendor\bin\phpunit.bat diff --git a/composer.json b/composer.json index 10c3811c189..970cd92ee3b 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "cakephp/cakephp", - "description": "The CakePHP framework", "type": "library", + "description": "The CakePHP framework", "keywords": [ "framework", "mvc", @@ -21,28 +21,75 @@ "homepage": "https://github.com/cakephp/cakephp/graphs/contributors" } ], - "support": { - "issues": "https://github.com/cakephp/cakephp/issues", - "forum": "https://stackoverflow.com/tags/cakephp", - "irc": "irc://irc.freenode.org/cakephp", - "source": "https://github.com/cakephp/cakephp" - }, "require": { - "php": ">=5.6.0", + "php": ">=8.2", "ext-intl": "*", + "ext-json": "*", "ext-mbstring": "*", - "cakephp/chronos": "^1.0.0", - "aura/intl": "^3.0.0", - "psr/log": "^1.0.0", - "zendframework/zend-diactoros": "^1.4.0" + "cakephp/chronos": "^3.3", + "composer/ca-bundle": "^1.5", + "laminas/laminas-diactoros": "^3.8", + "laminas/laminas-httphandlerrunner": "^2.6", + "league/container": "^5.1", + "psr/container": "^1.1 || ^2.0", + "psr/http-client": "^1.0.2", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0.2", + "psr/http-server-middleware": "^1.0.2", + "psr/link": "^2.0", + "psr/log": "^3.0", + "psr/simple-cache": "^2.0 || ^3.0" + }, + "replace": { + "cakephp/cache": "self.version", + "cakephp/collection": "self.version", + "cakephp/console": "self.version", + "cakephp/container": "self.version", + "cakephp/core": "self.version", + "cakephp/database": "self.version", + "cakephp/datasource": "self.version", + "cakephp/event": "self.version", + "cakephp/form": "self.version", + "cakephp/http": "self.version", + "cakephp/i18n": "self.version", + "cakephp/log": "self.version", + "cakephp/orm": "self.version", + "cakephp/utility": "self.version", + "cakephp/validation": "self.version" + }, + "require-dev": { + "boundwize/structarmed": "^0.14", + "cakephp/cakephp-codesniffer": "^5.3", + "http-interop/http-factory-tests": "^2.0", + "mikey179/vfsstream": "^1.6.12", + "mockery/mockery": "^1.6", + "paragonie/csp-builder": "^2.3 || ^3.0", + "phpunit/phpunit": "^11.5.3 || ^12.1.3 || ^13.0" }, "suggest": { + "ext-curl": "To enable more efficient network calls in Http\\Client.", "ext-openssl": "To use Security::encrypt() or have secure CSRF token generation.", - "lib-ICU": "The intl PHP library, to use Text::transliterate() or Text::slug()" + "paragonie/csp-builder": "CSP builder, to use the CSP Middleware" }, - "require-dev": { - "phpunit/phpunit": "^5.7.14|^6.0", - "cakephp/cakephp-codesniffer": "^3.0" + "provide": { + "psr/container-implementation": "^2.0", + "psr/http-client-implementation": "^1.0", + "psr/http-factory-implementation": "^1.0", + "psr/http-server-handler-implementation": "^1.0", + "psr/http-server-middleware-implementation": "^1.0", + "psr/link-implementation": "^2.0", + "psr/log-implementation": "^3.0", + "psr/simple-cache-implementation": "^3.0" + }, + "config": { + "lock": false, + "process-timeout": 900, + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "phpstan/extension-installer": true + } }, "autoload": { "psr-4": { @@ -50,8 +97,11 @@ }, "files": [ "src/Core/functions.php", + "src/Error/functions.php", "src/Collection/functions.php", "src/I18n/functions.php", + "src/ORM/bootstrap.php", + "src/Routing/functions.php", "src/Utility/bootstrap.php" ] }, @@ -60,40 +110,53 @@ "Cake\\PHPStan\\": "tests/PHPStan/", "Cake\\Test\\": "tests/", "TestApp\\": "tests/test_app/TestApp/", + "TestApp\\Test\\": "tests/test_app/TestApp/tests/", "TestPlugin\\": "tests/test_app/Plugin/TestPlugin/src/", "TestPlugin\\Test\\": "tests/test_app/Plugin/TestPlugin/tests/", "TestPluginTwo\\": "tests/test_app/Plugin/TestPluginTwo/src/", "Company\\TestPluginThree\\": "tests/test_app/Plugin/Company/TestPluginThree/src/", "Company\\TestPluginThree\\Test\\": "tests/test_app/Plugin/Company/TestPluginThree/tests/", + "Named\\": "tests/test_app/Plugin/Named/src/", + "TestTheme\\": "tests/test_app/Plugin/TestTheme/src/", "PluginJs\\": "tests/test_app/Plugin/PluginJs/src/" - } - }, - "replace": { - "cakephp/cache": "self.version", - "cakephp/collection": "self.version", - "cakephp/core": "self.version", - "cakephp/datasource": "self.version", - "cakephp/database": "self.version", - "cakephp/event": "self.version", - "cakephp/filesystem": "self.version", - "cakephp/form": "self.version", - "cakephp/i18n": "self.version", - "cakephp/log": "self.version", - "cakephp/orm": "self.version", - "cakephp/utility": "self.version", - "cakephp/validation": "self.version" - }, - "conflict": { - "phpunit/phpunit": "<5.7" + }, + "files": [ + "tests/TestCase/Container/Asset/function.php" + ] }, "scripts": { "check": [ "@cs-check", "@test" ], - "cs-check": "phpcs --colors -p ./src ./tests", - "cs-fix": "phpcbf --colors ./src ./tests", + "cs-check": "phpcs", + "cs-fix": "phpcbf", + "stan": [ + "tools/phpstan analyse", + "tools/psalm", + "@structarmed" + ], + "structarmed": "vendor/bin/structarmed analyze src", + "stan-tests": "tools/phpstan analyze -c tests/phpstan.neon", + "stan-baseline": "tools/phpstan --generate-baseline", + "stan-setup": "phive install", + "psalm-baseline": "tools/psalm --set-baseline=psalm-baseline.xml", + "lowest": "validate-prefer-lowest", + "lowest-setup": "composer update --prefer-lowest --prefer-stable --prefer-dist --no-interaction && cp composer.json composer.backup && composer require --dev dereuromark/composer-prefer-lowest && mv composer.backup composer.json", + "rector-setup": "cp composer.json composer.backup && composer require --dev rector/rector:\"~2.5.0\" && mv composer.backup composer.json", + "rector-check": "vendor/bin/rector process --dry-run", + "rector-fix": "vendor/bin/rector process", "test": "phpunit", "test-coverage": "phpunit --coverage-clover=clover.xml" + }, + "support": { + "issues": "https://github.com/cakephp/cakephp/issues", + "forum": "https://discourse.cakephp.org/", + "source": "https://github.com/cakephp/cakephp" + }, + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" + } } } diff --git a/config/bootstrap.php b/config/bootstrap.php index 1e40cbfde47..4e95849bc2c 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -17,7 +17,5 @@ define('TIME_START', microtime(true)); -require CAKE . 'basics.php'; - // Sets the initial router state so future reloads work. Router::reload(); diff --git a/config/cacert.pem b/config/cacert.pem deleted file mode 100644 index 6e13c65fed1..00000000000 --- a/config/cacert.pem +++ /dev/null @@ -1,3955 +0,0 @@ -## -## Bundle of CA Root Certificates -## -## Certificate data from Mozilla as of: Wed Jun 7 03:12:05 2017 GMT -## -## This is a bundle of X.509 certificates of public Certificate Authorities -## (CA). These were automatically extracted from Mozilla's root certificates -## file (certdata.txt). This file can be found in the mozilla source tree: -## https://hg.mozilla.org/releases/mozilla-release/raw-file/default/security/nss/lib/ckfw/builtins/certdata.txt -## -## It contains the certificates in PEM format and therefore -## can be directly used with curl / libcurl / php_curl, or with -## an Apache+mod_ssl webserver for SSL client authentication. -## Just configure this file as the SSLCACertificateFile. -## -## Conversion done with mk-ca-bundle.pl version 1.27. -## SHA256: 93753268e1c596aee21893fb1c6975338389132f15c942ed65fc394a904371d7 -## - - -GlobalSign Root CA -================== ------BEGIN CERTIFICATE----- -MIIDdTCCAl2gAwIBAgILBAAAAAABFUtaw5QwDQYJKoZIhvcNAQEFBQAwVzELMAkGA1UEBhMCQkUx -GTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExEDAOBgNVBAsTB1Jvb3QgQ0ExGzAZBgNVBAMTEkds -b2JhbFNpZ24gUm9vdCBDQTAeFw05ODA5MDExMjAwMDBaFw0yODAxMjgxMjAwMDBaMFcxCzAJBgNV -BAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRAwDgYDVQQLEwdSb290IENBMRswGQYD -VQQDExJHbG9iYWxTaWduIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDa -DuaZjc6j40+Kfvvxi4Mla+pIH/EqsLmVEQS98GPR4mdmzxzdzxtIK+6NiY6arymAZavpxy0Sy6sc -THAHoT0KMM0VjU/43dSMUBUc71DuxC73/OlS8pF94G3VNTCOXkNz8kHp1Wrjsok6Vjk4bwY8iGlb -Kk3Fp1S4bInMm/k8yuX9ifUSPJJ4ltbcdG6TRGHRjcdGsnUOhugZitVtbNV4FpWi6cgKOOvyJBNP -c1STE4U6G7weNLWLBYy5d4ux2x8gkasJU26Qzns3dLlwR5EiUWMWea6xrkEmCMgZK9FGqkjWZCrX -gzT/LCrBbBlDSgeF59N89iFo7+ryUp9/k5DPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV -HRMBAf8EBTADAQH/MB0GA1UdDgQWBBRge2YaRQ2XyolQL30EzTSo//z9SzANBgkqhkiG9w0BAQUF -AAOCAQEA1nPnfE920I2/7LqivjTFKDK1fPxsnCwrvQmeU79rXqoRSLblCKOzyj1hTdNGCbM+w6Dj -Y1Ub8rrvrTnhQ7k4o+YviiY776BQVvnGCv04zcQLcFGUl5gE38NflNUVyRRBnMRddWQVDf9VMOyG -j/8N7yy5Y0b2qvzfvGn9LhJIZJrglfCm7ymPAbEVtQwdpf5pLGkkeB6zpxxxYu7KyJesF12KwvhH -hm4qxFYxldBniYUr+WymXUadDKqC5JlR3XC321Y9YeRq4VzW9v493kHMB65jUr9TU/Qr6cf9tveC -X4XSQRjbgbMEHMUfpIBvFSDJ3gyICh3WZlXi/EjJKSZp4A== ------END CERTIFICATE----- - -GlobalSign Root CA - R2 -======================= ------BEGIN CERTIFICATE----- -MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4GA1UECxMXR2xv -YmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2Jh -bFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxT -aWduIFJvb3QgQ0EgLSBSMjETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2ln -bjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6 -ErPLv4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8eoLrvozp -s6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklqtTleiDTsvHgMCJiEbKjN -S7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzdC9XZzPnqJworc5HGnRusyMvo4KD0L5CL -TfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pazq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6C -ygPCm48CAwEAAaOBnDCBmTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E -FgQUm+IHV2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5nbG9i -YWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG3lm0mi3f3BmGLjAN -BgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4GsJ0/WwbgcQ3izDJr86iw8bmEbTUsp -9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu -01yiPqFbQfXf5WRDLenVOavSot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG7 -9G+dwfCMNYxdAfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7 -TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg== ------END CERTIFICATE----- - -Verisign Class 3 Public Primary Certification Authority - G3 -============================================================ ------BEGIN CERTIFICATE----- -MIIEGjCCAwICEQCbfgZJoz5iudXukEhxKe9XMA0GCSqGSIb3DQEBBQUAMIHKMQswCQYDVQQGEwJV -UzEXMBUGA1UEChMOVmVyaVNpZ24sIEluYy4xHzAdBgNVBAsTFlZlcmlTaWduIFRydXN0IE5ldHdv -cmsxOjA4BgNVBAsTMShjKSAxOTk5IFZlcmlTaWduLCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNl -IG9ubHkxRTBDBgNVBAMTPFZlcmlTaWduIENsYXNzIDMgUHVibGljIFByaW1hcnkgQ2VydGlmaWNh -dGlvbiBBdXRob3JpdHkgLSBHMzAeFw05OTEwMDEwMDAwMDBaFw0zNjA3MTYyMzU5NTlaMIHKMQsw -CQYDVQQGEwJVUzEXMBUGA1UEChMOVmVyaVNpZ24sIEluYy4xHzAdBgNVBAsTFlZlcmlTaWduIFRy -dXN0IE5ldHdvcmsxOjA4BgNVBAsTMShjKSAxOTk5IFZlcmlTaWduLCBJbmMuIC0gRm9yIGF1dGhv -cml6ZWQgdXNlIG9ubHkxRTBDBgNVBAMTPFZlcmlTaWduIENsYXNzIDMgUHVibGljIFByaW1hcnkg -Q2VydGlmaWNhdGlvbiBBdXRob3JpdHkgLSBHMzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC -ggEBAMu6nFL8eB8aHm8bN3O9+MlrlBIwT/A2R/XQkQr1F8ilYcEWQE37imGQ5XYgwREGfassbqb1 -EUGO+i2tKmFZpGcmTNDovFJbcCAEWNF6yaRpvIMXZK0Fi7zQWM6NjPXr8EJJC52XJ2cybuGukxUc -cLwgTS8Y3pKI6GyFVxEa6X7jJhFUokWWVYPKMIno3Nij7SqAP395ZVc+FSBmCC+Vk7+qRy+oRpfw -EuL+wgorUeZ25rdGt+INpsyow0xZVYnm6FNcHOqd8GIWC6fJXwzw3sJ2zq/3avL6QaaiMxTJ5Xpj -055iN9WFZZ4O5lMkdBteHRJTW8cs54NJOxWuimi5V5cCAwEAATANBgkqhkiG9w0BAQUFAAOCAQEA -ERSWwauSCPc/L8my/uRan2Te2yFPhpk0djZX3dAVL8WtfxUfN2JzPtTnX84XA9s1+ivbrmAJXx5f -j267Cz3qWhMeDGBvtcC1IyIuBwvLqXTLR7sdwdela8wv0kL9Sd2nic9TutoAWii/gt/4uhMdUIaC -/Y4wjylGsB49Ndo4YhYYSq3mtlFs3q9i6wHQHiT+eo8SGhJouPtmmRQURVyu565pF4ErWjfJXir0 -xuKhXFSbplQAz/DxwceYMBo7Nhbbo27q/a2ywtrvAkcTisDxszGtTxzhT5yvDwyd93gN2PQ1VoDa -t20Xj50egWTh/sVFuq1ruQp6Tk9LhO5L8X3dEQ== ------END CERTIFICATE----- - -Entrust.net Premium 2048 Secure Server CA -========================================= ------BEGIN CERTIFICATE----- -MIIEKjCCAxKgAwIBAgIEOGPe+DANBgkqhkiG9w0BAQUFADCBtDEUMBIGA1UEChMLRW50cnVzdC5u -ZXQxQDA+BgNVBAsUN3d3dy5lbnRydXN0Lm5ldC9DUFNfMjA0OCBpbmNvcnAuIGJ5IHJlZi4gKGxp -bWl0cyBsaWFiLikxJTAjBgNVBAsTHChjKSAxOTk5IEVudHJ1c3QubmV0IExpbWl0ZWQxMzAxBgNV -BAMTKkVudHJ1c3QubmV0IENlcnRpZmljYXRpb24gQXV0aG9yaXR5ICgyMDQ4KTAeFw05OTEyMjQx -NzUwNTFaFw0yOTA3MjQxNDE1MTJaMIG0MRQwEgYDVQQKEwtFbnRydXN0Lm5ldDFAMD4GA1UECxQ3 -d3d3LmVudHJ1c3QubmV0L0NQU18yMDQ4IGluY29ycC4gYnkgcmVmLiAobGltaXRzIGxpYWIuKTEl -MCMGA1UECxMcKGMpIDE5OTkgRW50cnVzdC5uZXQgTGltaXRlZDEzMDEGA1UEAxMqRW50cnVzdC5u -ZXQgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgKDIwNDgpMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEArU1LqRKGsuqjIAcVFmQqK0vRvwtKTY7tgHalZ7d4QMBzQshowNtTK91euHaYNZOL -Gp18EzoOH1u3Hs/lJBQesYGpjX24zGtLA/ECDNyrpUAkAH90lKGdCCmziAv1h3edVc3kw37XamSr -hRSGlVuXMlBvPci6Zgzj/L24ScF2iUkZ/cCovYmjZy/Gn7xxGWC4LeksyZB2ZnuU4q941mVTXTzW -nLLPKQP5L6RQstRIzgUyVYr9smRMDuSYB3Xbf9+5CFVghTAp+XtIpGmG4zU/HoZdenoVve8AjhUi -VBcAkCaTvA5JaJG/+EfTnZVCwQ5N328mz8MYIWJmQ3DW1cAH4QIDAQABo0IwQDAOBgNVHQ8BAf8E -BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUVeSB0RGAvtiJuQijMfmhJAkWuXAwDQYJ -KoZIhvcNAQEFBQADggEBADubj1abMOdTmXx6eadNl9cZlZD7Bh/KM3xGY4+WZiT6QBshJ8rmcnPy -T/4xmf3IDExoU8aAghOY+rat2l098c5u9hURlIIM7j+VrxGrD9cv3h8Dj1csHsm7mhpElesYT6Yf -zX1XEC+bBAlahLVu2B064dae0Wx5XnkcFMXj0EyTO2U87d89vqbllRrDtRnDvV5bu/8j72gZyxKT -J1wDLW8w0B62GqzeWvfRqqgnpv55gcR5mTNXuhKwqeBCbJPKVt7+bYQLCIt+jerXmCHG8+c8eS9e -nNFMFY3h7CI3zJpDC5fcgJCNs2ebb0gIFVbPv/ErfF6adulZkMV8gzURZVE= ------END CERTIFICATE----- - -Baltimore CyberTrust Root -========================= ------BEGIN CERTIFICATE----- -MIIDdzCCAl+gAwIBAgIEAgAAuTANBgkqhkiG9w0BAQUFADBaMQswCQYDVQQGEwJJRTESMBAGA1UE -ChMJQmFsdGltb3JlMRMwEQYDVQQLEwpDeWJlclRydXN0MSIwIAYDVQQDExlCYWx0aW1vcmUgQ3li -ZXJUcnVzdCBSb290MB4XDTAwMDUxMjE4NDYwMFoXDTI1MDUxMjIzNTkwMFowWjELMAkGA1UEBhMC -SUUxEjAQBgNVBAoTCUJhbHRpbW9yZTETMBEGA1UECxMKQ3liZXJUcnVzdDEiMCAGA1UEAxMZQmFs -dGltb3JlIEN5YmVyVHJ1c3QgUm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKME -uyKrmD1X6CZymrV51Cni4eiVgLGw41uOKymaZN+hXe2wCQVt2yguzmKiYv60iNoS6zjrIZ3AQSsB -UnuId9Mcj8e6uYi1agnnc+gRQKfRzMpijS3ljwumUNKoUMMo6vWrJYeKmpYcqWe4PwzV9/lSEy/C -G9VwcPCPwBLKBsua4dnKM3p31vjsufFoREJIE9LAwqSuXmD+tqYF/LTdB1kC1FkYmGP1pWPgkAx9 -XbIGevOF6uvUA65ehD5f/xXtabz5OTZydc93Uk3zyZAsuT3lySNTPx8kmCFcB5kpvcY67Oduhjpr -l3RjM71oGDHweI12v/yejl0qhqdNkNwnGjkCAwEAAaNFMEMwHQYDVR0OBBYEFOWdWTCCR1jMrPoI -VDaGezq1BE3wMBIGA1UdEwEB/wQIMAYBAf8CAQMwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEB -BQUAA4IBAQCFDF2O5G9RaEIFoN27TyclhAO992T9Ldcw46QQF+vaKSm2eT929hkTI7gQCvlYpNRh -cL0EYWoSihfVCr3FvDB81ukMJY2GQE/szKN+OMY3EU/t3WgxjkzSswF07r51XgdIGn9w/xZchMB5 -hbgF/X++ZRGjD8ACtPhSNzkE1akxehi/oCr0Epn3o0WC4zxe9Z2etciefC7IpJ5OCBRLbf1wbWsa -Y71k5h+3zvDyny67G7fyUIhzksLi4xaNmjICq44Y3ekQEe5+NauQrz4wlHrQMz2nZQ/1/I6eYs9H -RCwBXbsdtTLSR9I4LtD+gdwyah617jzV/OeBHRnDJELqYzmp ------END CERTIFICATE----- - -AddTrust Low-Value Services Root -================================ ------BEGIN CERTIFICATE----- -MIIEGDCCAwCgAwIBAgIBATANBgkqhkiG9w0BAQUFADBlMQswCQYDVQQGEwJTRTEUMBIGA1UEChML -QWRkVHJ1c3QgQUIxHTAbBgNVBAsTFEFkZFRydXN0IFRUUCBOZXR3b3JrMSEwHwYDVQQDExhBZGRU -cnVzdCBDbGFzcyAxIENBIFJvb3QwHhcNMDAwNTMwMTAzODMxWhcNMjAwNTMwMTAzODMxWjBlMQsw -CQYDVQQGEwJTRTEUMBIGA1UEChMLQWRkVHJ1c3QgQUIxHTAbBgNVBAsTFEFkZFRydXN0IFRUUCBO -ZXR3b3JrMSEwHwYDVQQDExhBZGRUcnVzdCBDbGFzcyAxIENBIFJvb3QwggEiMA0GCSqGSIb3DQEB -AQUAA4IBDwAwggEKAoIBAQCWltQhSWDia+hBBwzexODcEyPNwTXH+9ZOEQpnXvUGW2ulCDtbKRY6 -54eyNAbFvAWlA3yCyykQruGIgb3WntP+LVbBFc7jJp0VLhD7Bo8wBN6ntGO0/7Gcrjyvd7ZWxbWr -oulpOj0OM3kyP3CCkplhbY0wCI9xP6ZIVxn4JdxLZlyldI+Yrsj5wAYi56xz36Uu+1LcsRVlIPo1 -Zmne3yzxbrww2ywkEtvrNTVokMsAsJchPXQhI2U0K7t4WaPW4XY5mqRJjox0r26kmqPZm9I4XJui -GMx1I4S+6+JNM3GOGvDC+Mcdoq0Dlyz4zyXG9rgkMbFjXZJ/Y/AlyVMuH79NAgMBAAGjgdIwgc8w -HQYDVR0OBBYEFJWxtPCUtr3H2tERCSG+wa9J/RB7MAsGA1UdDwQEAwIBBjAPBgNVHRMBAf8EBTAD -AQH/MIGPBgNVHSMEgYcwgYSAFJWxtPCUtr3H2tERCSG+wa9J/RB7oWmkZzBlMQswCQYDVQQGEwJT -RTEUMBIGA1UEChMLQWRkVHJ1c3QgQUIxHTAbBgNVBAsTFEFkZFRydXN0IFRUUCBOZXR3b3JrMSEw -HwYDVQQDExhBZGRUcnVzdCBDbGFzcyAxIENBIFJvb3SCAQEwDQYJKoZIhvcNAQEFBQADggEBACxt -ZBsfzQ3duQH6lmM0MkhHma6X7f1yFqZzR1r0693p9db7RcwpiURdv0Y5PejuvE1Uhh4dbOMXJ0Ph -iVYrqW9yTkkz43J8KiOavD7/KCrto/8cI7pDVwlnTUtiBi34/2ydYB7YHEt9tTEv2dB8Xfjea4MY -eDdXL+gzB2ffHsdrKpV2ro9Xo/D0UrSpUwjP4E/TelOL/bscVjby/rK25Xa71SJlpz/+0WatC7xr -mYbvP33zGDLKe8bjq2RGlfgmadlVg3sslgf/WSxEo8bl6ancoWOAWiFeIc9TVPC6b4nbqKqVz4vj -ccweGyBECMB6tkD9xOQ14R0WHNC8K47Wcdk= ------END CERTIFICATE----- - -AddTrust External Root -====================== ------BEGIN CERTIFICATE----- -MIIENjCCAx6gAwIBAgIBATANBgkqhkiG9w0BAQUFADBvMQswCQYDVQQGEwJTRTEUMBIGA1UEChML -QWRkVHJ1c3QgQUIxJjAkBgNVBAsTHUFkZFRydXN0IEV4dGVybmFsIFRUUCBOZXR3b3JrMSIwIAYD -VQQDExlBZGRUcnVzdCBFeHRlcm5hbCBDQSBSb290MB4XDTAwMDUzMDEwNDgzOFoXDTIwMDUzMDEw -NDgzOFowbzELMAkGA1UEBhMCU0UxFDASBgNVBAoTC0FkZFRydXN0IEFCMSYwJAYDVQQLEx1BZGRU -cnVzdCBFeHRlcm5hbCBUVFAgTmV0d29yazEiMCAGA1UEAxMZQWRkVHJ1c3QgRXh0ZXJuYWwgQ0Eg -Um9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALf3GjPm8gAELTngTlvtH7xsD821 -+iO2zt6bETOXpClMfZOfvUq8k+0DGuOPz+VtUFrWlymUWoCwSXrbLpX9uMq/NzgtHj6RQa1wVsfw -Tz/oMp50ysiQVOnGXw94nZpAPA6sYapeFI+eh6FqUNzXmk6vBbOmcZSccbNQYArHE504B4YCqOmo -aSYYkKtMsE8jqzpPhNjfzp/haW+710LXa0Tkx63ubUFfclpxCDezeWWkWaCUN/cALw3CknLa0Dhy -2xSoRcRdKn23tNbE7qzNE0S3ySvdQwAl+mG5aWpYIxG3pzOPVnVZ9c0p10a3CitlttNCbxWyuHv7 -7+ldU9U0WicCAwEAAaOB3DCB2TAdBgNVHQ4EFgQUrb2YejS0Jvf6xCZU7wO94CTLVBowCwYDVR0P -BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wgZkGA1UdIwSBkTCBjoAUrb2YejS0Jvf6xCZU7wO94CTL -VBqhc6RxMG8xCzAJBgNVBAYTAlNFMRQwEgYDVQQKEwtBZGRUcnVzdCBBQjEmMCQGA1UECxMdQWRk -VHJ1c3QgRXh0ZXJuYWwgVFRQIE5ldHdvcmsxIjAgBgNVBAMTGUFkZFRydXN0IEV4dGVybmFsIENB -IFJvb3SCAQEwDQYJKoZIhvcNAQEFBQADggEBALCb4IUlwtYj4g+WBpKdQZic2YR5gdkeWxQHIzZl -j7DYd7usQWxHYINRsPkyPef89iYTx4AWpb9a/IfPeHmJIZriTAcKhjW88t5RxNKWt9x+Tu5w/Rw5 -6wwCURQtjr0W4MHfRnXnJK3s9EK0hZNwEGe6nQY1ShjTK3rMUUKhemPR5ruhxSvCNr4TDea9Y355 -e6cJDUCrat2PisP29owaQgVR1EX1n6diIWgVIEM8med8vSTYqZEXc4g/VhsxOBi0cQ+azcgOno4u -G+GMmIPLHzHxREzGBHNJdmAPx/i9F4BrLunMTA5amnkPIAou1Z5jJh5VkpTYghdae9C8x49OhgQ= ------END CERTIFICATE----- - -AddTrust Public Services Root -============================= ------BEGIN CERTIFICATE----- -MIIEFTCCAv2gAwIBAgIBATANBgkqhkiG9w0BAQUFADBkMQswCQYDVQQGEwJTRTEUMBIGA1UEChML -QWRkVHJ1c3QgQUIxHTAbBgNVBAsTFEFkZFRydXN0IFRUUCBOZXR3b3JrMSAwHgYDVQQDExdBZGRU -cnVzdCBQdWJsaWMgQ0EgUm9vdDAeFw0wMDA1MzAxMDQxNTBaFw0yMDA1MzAxMDQxNTBaMGQxCzAJ -BgNVBAYTAlNFMRQwEgYDVQQKEwtBZGRUcnVzdCBBQjEdMBsGA1UECxMUQWRkVHJ1c3QgVFRQIE5l -dHdvcmsxIDAeBgNVBAMTF0FkZFRydXN0IFB1YmxpYyBDQSBSb290MIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEA6Rowj4OIFMEg2Dybjxt+A3S72mnTRqX4jsIMEZBRpS9mVEBV6tsfSlbu -nyNu9DnLoblv8n75XYcmYZ4c+OLspoH4IcUkzBEMP9smcnrHAZcHF/nXGCwwfQ56HmIexkvA/X1i -d9NEHif2P0tEs7c42TkfYNVRknMDtABp4/MUTu7R3AnPdzRGULD4EfL+OHn3Bzn+UZKXC1sIXzSG -Aa2Il+tmzV7R/9x98oTaunet3IAIx6eH1lWfl2royBFkuucZKT8Rs3iQhCBSWxHveNCD9tVIkNAw -HM+A+WD+eeSI8t0A65RF62WUaUC6wNW0uLp9BBGo6zEFlpROWCGOn9Bg/QIDAQABo4HRMIHOMB0G -A1UdDgQWBBSBPjfYkrAfd59ctKtzquf2NGAv+jALBgNVHQ8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB -/zCBjgYDVR0jBIGGMIGDgBSBPjfYkrAfd59ctKtzquf2NGAv+qFopGYwZDELMAkGA1UEBhMCU0Ux -FDASBgNVBAoTC0FkZFRydXN0IEFCMR0wGwYDVQQLExRBZGRUcnVzdCBUVFAgTmV0d29yazEgMB4G -A1UEAxMXQWRkVHJ1c3QgUHVibGljIENBIFJvb3SCAQEwDQYJKoZIhvcNAQEFBQADggEBAAP3FUr4 -JNojVhaTdt02KLmuG7jD8WS6IBh4lSknVwW8fCr0uVFV2ocC3g8WFzH4qnkuCRO7r7IgGRLlk/lL -+YPoRNWyQSW/iHVv/xD8SlTQX/D67zZzfRs2RcYhbbQVuE7PnFylPVoAjgbjPGsye/Kf8Lb93/Ao -GEjwxrzQvzSAlsJKsW2Ox5BF3i9nrEUEo3rcVZLJR2bYGozH7ZxOmuASu7VqTITh4SINhwBk/ox9 -Yjllpu9CtoAlEmEBqCQTcAARJl/6NVDFSMwGR+gn2HCNX2TmoUQmXiLsks3/QppEIW1cxeMiHV9H -EufOX1362KqxMy3ZdvJOOjMMK7MtkAY= ------END CERTIFICATE----- - -AddTrust Qualified Certificates Root -==================================== ------BEGIN CERTIFICATE----- -MIIEHjCCAwagAwIBAgIBATANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJTRTEUMBIGA1UEChML -QWRkVHJ1c3QgQUIxHTAbBgNVBAsTFEFkZFRydXN0IFRUUCBOZXR3b3JrMSMwIQYDVQQDExpBZGRU -cnVzdCBRdWFsaWZpZWQgQ0EgUm9vdDAeFw0wMDA1MzAxMDQ0NTBaFw0yMDA1MzAxMDQ0NTBaMGcx -CzAJBgNVBAYTAlNFMRQwEgYDVQQKEwtBZGRUcnVzdCBBQjEdMBsGA1UECxMUQWRkVHJ1c3QgVFRQ -IE5ldHdvcmsxIzAhBgNVBAMTGkFkZFRydXN0IFF1YWxpZmllZCBDQSBSb290MIIBIjANBgkqhkiG -9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5B6a/twJWoekn0e+EV+vhDTbYjx5eLfpMLXsDBwqxBb/4Oxx -64r1EW7tTw2R0hIYLUkVAcKkIhPHEWT/IhKauY5cLwjPcWqzZwFZ8V1G87B4pfYOQnrjfxvM0PC3 -KP0q6p6zsLkEqv32x7SxuCqg+1jxGaBvcCV+PmlKfw8i2O+tCBGaKZnhqkRFmhJePp1tUvznoD1o -L/BLcHwTOK28FSXx1s6rosAx1i+f4P8UWfyEk9mHfExUE+uf0S0R+Bg6Ot4l2ffTQO2kBhLEO+GR -wVY18BTcZTYJbqukB8c10cIDMzZbdSZtQvESa0NvS3GU+jQd7RNuyoB/mC9suWXY6QIDAQABo4HU -MIHRMB0GA1UdDgQWBBQ5lYtii1zJ1IC6WA+XPxUIQ8yYpzALBgNVHQ8EBAMCAQYwDwYDVR0TAQH/ -BAUwAwEB/zCBkQYDVR0jBIGJMIGGgBQ5lYtii1zJ1IC6WA+XPxUIQ8yYp6FrpGkwZzELMAkGA1UE -BhMCU0UxFDASBgNVBAoTC0FkZFRydXN0IEFCMR0wGwYDVQQLExRBZGRUcnVzdCBUVFAgTmV0d29y -azEjMCEGA1UEAxMaQWRkVHJ1c3QgUXVhbGlmaWVkIENBIFJvb3SCAQEwDQYJKoZIhvcNAQEFBQAD -ggEBABmrder4i2VhlRO6aQTvhsoToMeqT2QbPxj2qC0sVY8FtzDqQmodwCVRLae/DLPt7wh/bDxG -GuoYQ992zPlmhpwsaPXpF/gxsxjE1kh9I0xowX67ARRvxdlu3rsEQmr49lx95dr6h+sNNVJn0J6X -dgWTP5XHAeZpVTh/EGGZyeNfpso+gmNIquIISD6q8rKFYqa0p9m9N5xotS1WfbC3P6CxB9bpT9ze -RXEwMn8bLgn5v1Kh7sKAPgZcLlVAwRv1cEWw3F369nJad9Jjzc9YiQBCYz95OdBEsIJuQRno3eDB -iFrRHnGTHyQwdOUeqN48Jzd/g66ed8/wMLH/S5noxqE= ------END CERTIFICATE----- - -Entrust Root Certification Authority -==================================== ------BEGIN CERTIFICATE----- -MIIEkTCCA3mgAwIBAgIERWtQVDANBgkqhkiG9w0BAQUFADCBsDELMAkGA1UEBhMCVVMxFjAUBgNV -BAoTDUVudHJ1c3QsIEluYy4xOTA3BgNVBAsTMHd3dy5lbnRydXN0Lm5ldC9DUFMgaXMgaW5jb3Jw -b3JhdGVkIGJ5IHJlZmVyZW5jZTEfMB0GA1UECxMWKGMpIDIwMDYgRW50cnVzdCwgSW5jLjEtMCsG -A1UEAxMkRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTA2MTEyNzIwMjM0 -MloXDTI2MTEyNzIwNTM0MlowgbAxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMu -MTkwNwYDVQQLEzB3d3cuZW50cnVzdC5uZXQvQ1BTIGlzIGluY29ycG9yYXRlZCBieSByZWZlcmVu -Y2UxHzAdBgNVBAsTFihjKSAyMDA2IEVudHJ1c3QsIEluYy4xLTArBgNVBAMTJEVudHJ1c3QgUm9v -dCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB -ALaVtkNC+sZtKm9I35RMOVcF7sN5EUFoNu3s/poBj6E4KPz3EEZmLk0eGrEaTsbRwJWIsMn/MYsz -A9u3g3s+IIRe7bJWKKf44LlAcTfFy0cOlypowCKVYhXbR9n10Cv/gkvJrT7eTNuQgFA/CYqEAOww -Cj0Yzfv9KlmaI5UXLEWeH25DeW0MXJj+SKfFI0dcXv1u5x609mhF0YaDW6KKjbHjKYD+JXGIrb68 -j6xSlkuqUY3kEzEZ6E5Nn9uss2rVvDlUccp6en+Q3X0dgNmBu1kmwhH+5pPi94DkZfs0Nw4pgHBN -rziGLp5/V6+eF67rHMsoIV+2HNjnogQi+dPa2MsCAwEAAaOBsDCBrTAOBgNVHQ8BAf8EBAMCAQYw -DwYDVR0TAQH/BAUwAwEB/zArBgNVHRAEJDAigA8yMDA2MTEyNzIwMjM0MlqBDzIwMjYxMTI3MjA1 -MzQyWjAfBgNVHSMEGDAWgBRokORnpKZTgMeGZqTx90tD+4S9bTAdBgNVHQ4EFgQUaJDkZ6SmU4DH -hmak8fdLQ/uEvW0wHQYJKoZIhvZ9B0EABBAwDhsIVjcuMTo0LjADAgSQMA0GCSqGSIb3DQEBBQUA -A4IBAQCT1DCw1wMgKtD5Y+iRDAUgqV8ZyntyTtSx29CW+1RaGSwMCPeyvIWonX9tO1KzKtvn1ISM -Y/YPyyYBkVBs9F8U4pN0wBOeMDpQ47RgxRzwIkSNcUesyBrJ6ZuaAGAT/3B+XxFNSRuzFVJ7yVTa -v52Vr2ua2J7p8eRDjeIRRDq/r72DQnNSi6q7pynP9WQcCk3RvKqsnyrQ/39/2n3qse0wJcGE2jTS -W3iDVuycNsMm4hH2Z0kdkquM++v/eu6FSqdQgPCnXEqULl8FmTxSQeDNtGPPAUO6nIPcj2A781q0 -tHuu2guQOHXvgR1m0vdXcDazv/wor3ElhVsT/h5/WrQ8 ------END CERTIFICATE----- - -GeoTrust Global CA -================== ------BEGIN CERTIFICATE----- -MIIDVDCCAjygAwIBAgIDAjRWMA0GCSqGSIb3DQEBBQUAMEIxCzAJBgNVBAYTAlVTMRYwFAYDVQQK -Ew1HZW9UcnVzdCBJbmMuMRswGQYDVQQDExJHZW9UcnVzdCBHbG9iYWwgQ0EwHhcNMDIwNTIxMDQw -MDAwWhcNMjIwNTIxMDQwMDAwWjBCMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNR2VvVHJ1c3QgSW5j -LjEbMBkGA1UEAxMSR2VvVHJ1c3QgR2xvYmFsIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB -CgKCAQEA2swYYzD99BcjGlZ+W988bDjkcbd4kdS8odhM+KhDtgPpTSEHCIjaWC9mOSm9BXiLnTjo -BbdqfnGk5sRgprDvgOSJKA+eJdbtg/OtppHHmMlCGDUUna2YRpIuT8rxh0PBFpVXLVDviS2Aelet -8u5fa9IAjbkU+BQVNdnARqN7csiRv8lVK83Qlz6cJmTM386DGXHKTubU1XupGc1V3sjs0l44U+Vc -T4wt/lAjNvxm5suOpDkZALeVAjmRCw7+OC7RHQWa9k0+bw8HHa8sHo9gOeL6NlMTOdReJivbPagU -vTLrGAMoUgRx5aszPeE4uwc2hGKceeoWMPRfwCvocWvk+QIDAQABo1MwUTAPBgNVHRMBAf8EBTAD -AQH/MB0GA1UdDgQWBBTAephojYn7qwVkDBF9qn1luMrMTjAfBgNVHSMEGDAWgBTAephojYn7qwVk -DBF9qn1luMrMTjANBgkqhkiG9w0BAQUFAAOCAQEANeMpauUvXVSOKVCUn5kaFOSPeCpilKInZ57Q -zxpeR+nBsqTP3UEaBU6bS+5Kb1VSsyShNwrrZHYqLizz/Tt1kL/6cdjHPTfStQWVYrmm3ok9Nns4 -d0iXrKYgjy6myQzCsplFAMfOEVEiIuCl6rYVSAlk6l5PdPcFPseKUgzbFbS9bZvlxrFUaKnjaZC2 -mqUPuLk/IH2uSrW4nOQdtqvmlKXBx4Ot2/Unhw4EbNX/3aBd7YdStysVAq45pmp06drE57xNNB6p -XE0zX5IJL4hmXXeXxx12E6nV5fEWCRE11azbJHFwLJhWC9kXtNHjUStedejV0NxPNO3CBWaAocvm -Mw== ------END CERTIFICATE----- - -GeoTrust Global CA 2 -==================== ------BEGIN CERTIFICATE----- -MIIDZjCCAk6gAwIBAgIBATANBgkqhkiG9w0BAQUFADBEMQswCQYDVQQGEwJVUzEWMBQGA1UEChMN -R2VvVHJ1c3QgSW5jLjEdMBsGA1UEAxMUR2VvVHJ1c3QgR2xvYmFsIENBIDIwHhcNMDQwMzA0MDUw -MDAwWhcNMTkwMzA0MDUwMDAwWjBEMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNR2VvVHJ1c3QgSW5j -LjEdMBsGA1UEAxMUR2VvVHJ1c3QgR2xvYmFsIENBIDIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw -ggEKAoIBAQDvPE1APRDfO1MA4Wf+lGAVPoWI8YkNkMgoI5kF6CsgncbzYEbYwbLVjDHZ3CB5JIG/ -NTL8Y2nbsSpr7iFY8gjpeMtvy/wWUsiRxP89c96xPqfCfWbB9X5SJBri1WeR0IIQ13hLTytCOb1k -LUCgsBDTOEhGiKEMuzozKmKY+wCdE1l/bztyqu6mD4b5BWHqZ38MN5aL5mkWRxHCJ1kDs6ZgwiFA -Vvqgx306E+PsV8ez1q6diYD3Aecs9pYrEw15LNnA5IZ7S4wMcoKK+xfNAGw6EzywhIdLFnopsk/b -HdQL82Y3vdj2V7teJHq4PIu5+pIaGoSe2HSPqht/XvT+RSIhAgMBAAGjYzBhMA8GA1UdEwEB/wQF -MAMBAf8wHQYDVR0OBBYEFHE4NvICMVNHK266ZUapEBVYIAUJMB8GA1UdIwQYMBaAFHE4NvICMVNH -K266ZUapEBVYIAUJMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQUFAAOCAQEAA/e1K6tdEPx7 -srJerJsOflN4WT5CBP51o62sgU7XAotexC3IUnbHLB/8gTKY0UvGkpMzNTEv/NgdRN3ggX+d6Yvh -ZJFiCzkIjKx0nVnZellSlxG5FntvRdOW2TF9AjYPnDtuzywNA0ZF66D0f0hExghAzN4bcLUprbqL -OzRldRtxIR0sFAqwlpW41uryZfspuk/qkZN0abby/+Ea0AzRdoXLiiW9l14sbxWZJue2Kf8i7MkC -x1YAzUm5s2x7UwQa4qjJqhIFI8LO57sEAszAR6LkxCkvW0VXiVHuPOtSCP8HNR6fNWpHSlaY0VqF -H4z1Ir+rzoPz4iIprn2DQKi6bA== ------END CERTIFICATE----- - -GeoTrust Universal CA -===================== ------BEGIN CERTIFICATE----- -MIIFaDCCA1CgAwIBAgIBATANBgkqhkiG9w0BAQUFADBFMQswCQYDVQQGEwJVUzEWMBQGA1UEChMN -R2VvVHJ1c3QgSW5jLjEeMBwGA1UEAxMVR2VvVHJ1c3QgVW5pdmVyc2FsIENBMB4XDTA0MDMwNDA1 -MDAwMFoXDTI5MDMwNDA1MDAwMFowRTELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUdlb1RydXN0IElu -Yy4xHjAcBgNVBAMTFUdlb1RydXN0IFVuaXZlcnNhbCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIP -ADCCAgoCggIBAKYVVaCjxuAfjJ0hUNfBvitbtaSeodlyWL0AG0y/YckUHUWCq8YdgNY96xCcOq9t -JPi8cQGeBvV8Xx7BDlXKg5pZMK4ZyzBIle0iN430SppyZj6tlcDgFgDgEB8rMQ7XlFTTQjOgNB0e -RXbdT8oYN+yFFXoZCPzVx5zw8qkuEKmS5j1YPakWaDwvdSEYfyh3peFhF7em6fgemdtzbvQKoiFs -7tqqhZJmr/Z6a4LauiIINQ/PQvE1+mrufislzDoR5G2vc7J2Ha3QsnhnGqQ5HFELZ1aD/ThdDc7d -8Lsrlh/eezJS/R27tQahsiFepdaVaH/wmZ7cRQg+59IJDTWU3YBOU5fXtQlEIGQWFwMCTFMNaN7V -qnJNk22CDtucvc+081xdVHppCZbW2xHBjXWotM85yM48vCR85mLK4b19p71XZQvk/iXttmkQ3Cga -Rr0BHdCXteGYO8A3ZNY9lO4L4fUorgtWv3GLIylBjobFS1J72HGrH4oVpjuDWtdYAVHGTEHZf9hB -Z3KiKN9gg6meyHv8U3NyWfWTehd2Ds735VzZC1U0oqpbtWpU5xPKV+yXbfReBi9Fi1jUIxaS5BZu -KGNZMN9QAZxjiRqf2xeUgnA3wySemkfWWspOqGmJch+RbNt+nhutxx9z3SxPGWX9f5NAEC7S8O08 -ni4oPmkmM8V7AgMBAAGjYzBhMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFNq7LqqwDLiIJlF0 -XG0D08DYj3rWMB8GA1UdIwQYMBaAFNq7LqqwDLiIJlF0XG0D08DYj3rWMA4GA1UdDwEB/wQEAwIB -hjANBgkqhkiG9w0BAQUFAAOCAgEAMXjmx7XfuJRAyXHEqDXsRh3ChfMoWIawC/yOsjmPRFWrZIRc -aanQmjg8+uUfNeVE44B5lGiku8SfPeE0zTBGi1QrlaXv9z+ZhP015s8xxtxqv6fXIwjhmF7DWgh2 -qaavdy+3YL1ERmrvl/9zlcGO6JP7/TG37FcREUWbMPEaiDnBTzynANXH/KttgCJwpQzgXQQpAvvL -oJHRfNbDflDVnVi+QTjruXU8FdmbyUqDWcDaU/0zuzYYm4UPFd3uLax2k7nZAY1IEKj79TiG8dsK -xr2EoyNB3tZ3b4XUhRxQ4K5RirqNPnbiucon8l+f725ZDQbYKxek0nxru18UGkiPGkzns0ccjkxF -KyDuSN/n3QmOGKjaQI2SJhFTYXNd673nxE0pN2HrrDktZy4W1vUAg4WhzH92xH3kt0tm7wNFYGm2 -DFKWkoRepqO1pD4r2czYG0eq8kTaT/kD6PAUyz/zg97QwVTjt+gKN02LIFkDMBmhLMi9ER/frslK -xfMnZmaGrGiR/9nmUxwPi1xpZQomyB40w11Re9epnAahNt3ViZS82eQtDF4JbAiXfKM9fJP/P6EU -p8+1Xevb2xzEdt+Iub1FBZUbrvxGakyvSOPOrg/SfuvmbJxPgWp6ZKy7PtXny3YuxadIwVyQD8vI -P/rmMuGNG2+k5o7Y+SlIis5z/iw= ------END CERTIFICATE----- - -GeoTrust Universal CA 2 -======================= ------BEGIN CERTIFICATE----- -MIIFbDCCA1SgAwIBAgIBATANBgkqhkiG9w0BAQUFADBHMQswCQYDVQQGEwJVUzEWMBQGA1UEChMN -R2VvVHJ1c3QgSW5jLjEgMB4GA1UEAxMXR2VvVHJ1c3QgVW5pdmVyc2FsIENBIDIwHhcNMDQwMzA0 -MDUwMDAwWhcNMjkwMzA0MDUwMDAwWjBHMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNR2VvVHJ1c3Qg -SW5jLjEgMB4GA1UEAxMXR2VvVHJ1c3QgVW5pdmVyc2FsIENBIDIwggIiMA0GCSqGSIb3DQEBAQUA -A4ICDwAwggIKAoICAQCzVFLByT7y2dyxUxpZKeexw0Uo5dfR7cXFS6GqdHtXr0om/Nj1XqduGdt0 -DE81WzILAePb63p3NeqqWuDW6KFXlPCQo3RWlEQwAx5cTiuFJnSCegx2oG9NzkEtoBUGFF+3Qs17 -j1hhNNwqCPkuwwGmIkQcTAeC5lvO0Ep8BNMZcyfwqph/Lq9O64ceJHdqXbboW0W63MOhBW9Wjo8Q -JqVJwy7XQYci4E+GymC16qFjwAGXEHm9ADwSbSsVsaxLse4YuU6W3Nx2/zu+z18DwPw76L5GG//a -QMJS9/7jOvdqdzXQ2o3rXhhqMcceujwbKNZrVMaqW9eiLBsZzKIC9ptZvTdrhrVtgrrY6slWvKk2 -WP0+GfPtDCapkzj4T8FdIgbQl+rhrcZV4IErKIM6+vR7IVEAvlI4zs1meaj0gVbi0IMJR1FbUGrP -20gaXT73y/Zl92zxlfgCOzJWgjl6W70viRu/obTo/3+NjN8D8WBOWBFM66M/ECuDmgFz2ZRthAAn -ZqzwcEAJQpKtT5MNYQlRJNiS1QuUYbKHsu3/mjX/hVTK7URDrBs8FmtISgocQIgfksILAAX/8sgC -SqSqqcyZlpwvWOB94b67B9xfBHJcMTTD7F8t4D1kkCLm0ey4Lt1ZrtmhN79UNdxzMk+MBB4zsslG -8dhcyFVQyWi9qLo2CQIDAQABo2MwYTAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR281Xh+qQ2 -+/CfXGJx7Tz0RzgQKzAfBgNVHSMEGDAWgBR281Xh+qQ2+/CfXGJx7Tz0RzgQKzAOBgNVHQ8BAf8E -BAMCAYYwDQYJKoZIhvcNAQEFBQADggIBAGbBxiPz2eAubl/oz66wsCVNK/g7WJtAJDday6sWSf+z -dXkzoS9tcBc0kf5nfo/sm+VegqlVHy/c1FEHEv6sFj4sNcZj/NwQ6w2jqtB8zNHQL1EuxBRa3ugZ -4T7GzKQp5y6EqgYweHZUcyiYWTjgAA1i00J9IZ+uPTqM1fp3DRgrFg5fNuH8KrUwJM/gYwx7WBr+ -mbpCErGR9Hxo4sjoryzqyX6uuyo9DRXcNJW2GHSoag/HtPQTxORb7QrSpJdMKu0vbBKJPfEncKpq -A1Ihn0CoZ1Dy81of398j9tx4TuaYT1U6U+Pv8vSfx3zYWK8pIpe44L2RLrB27FcRz+8pRPPphXpg -Y+RdM4kX2TGq2tbzGDVyz4crL2MjhF2EjD9XoIj8mZEoJmmZ1I+XRL6O1UixpCgp8RW04eWe3fiP -pm8m1wk8OhwRDqZsN/etRIcsKMfYdIKz0G9KV7s1KSegi+ghp4dkNl3M2Basx7InQJJVOCiNUW7d -FGdTbHFcJoRNdVq2fmBWqU2t+5sel/MN2dKXVHfaPRK34B7vCAas+YWH6aLcr34YEoP9VhdBLtUp -gn2Z9DH2canPLAEnpQW5qrJITirvn5NSUZU8UnOOVkwXQMAJKOSLakhT2+zNVVXxxvjpoixMptEm -X36vWkzaH6byHCx+rgIW0lbQL1dTR+iS ------END CERTIFICATE----- - -Visa eCommerce Root -=================== ------BEGIN CERTIFICATE----- -MIIDojCCAoqgAwIBAgIQE4Y1TR0/BvLB+WUF1ZAcYjANBgkqhkiG9w0BAQUFADBrMQswCQYDVQQG -EwJVUzENMAsGA1UEChMEVklTQTEvMC0GA1UECxMmVmlzYSBJbnRlcm5hdGlvbmFsIFNlcnZpY2Ug -QXNzb2NpYXRpb24xHDAaBgNVBAMTE1Zpc2EgZUNvbW1lcmNlIFJvb3QwHhcNMDIwNjI2MDIxODM2 -WhcNMjIwNjI0MDAxNjEyWjBrMQswCQYDVQQGEwJVUzENMAsGA1UEChMEVklTQTEvMC0GA1UECxMm -VmlzYSBJbnRlcm5hdGlvbmFsIFNlcnZpY2UgQXNzb2NpYXRpb24xHDAaBgNVBAMTE1Zpc2EgZUNv -bW1lcmNlIFJvb3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvV95WHm6h2mCxlCfL -F9sHP4CFT8icttD0b0/Pmdjh28JIXDqsOTPHH2qLJj0rNfVIsZHBAk4ElpF7sDPwsRROEW+1QK8b -RaVK7362rPKgH1g/EkZgPI2h4H3PVz4zHvtH8aoVlwdVZqW1LS7YgFmypw23RuwhY/81q6UCzyr0 -TP579ZRdhE2o8mCP2w4lPJ9zcc+U30rq299yOIzzlr3xF7zSujtFWsan9sYXiwGd/BmoKoMWuDpI -/k4+oKsGGelT84ATB+0tvz8KPFUgOSwsAGl0lUq8ILKpeeUYiZGo3BxN77t+Nwtd/jmliFKMAGzs -GHxBvfaLdXe6YJ2E5/4tAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEG -MB0GA1UdDgQWBBQVOIMPPyw/cDMezUb+B4wg4NfDtzANBgkqhkiG9w0BAQUFAAOCAQEAX/FBfXxc -CLkr4NWSR/pnXKUTwwMhmytMiUbPWU3J/qVAtmPN3XEolWcRzCSs00Rsca4BIGsDoo8Ytyk6feUW -YFN4PMCvFYP3j1IzJL1kk5fui/fbGKhtcbP3LBfQdCVp9/5rPJS+TUtBjE7ic9DjkCJzQ83z7+pz -zkWKsKZJ/0x9nXGIxHYdkFsd7v3M9+79YKWxehZx0RbQfBI8bGmX265fOZpwLwU8GUYEmSA20GBu -YQa7FkKMcPcw++DbZqMAAb3mLNqRX6BGi01qnD093QVG/na/oAo85ADmJ7f/hC3euiInlhBx6yLt -398znM/jra6O1I7mT1GvFpLgXPYHDw== ------END CERTIFICATE----- - -Certum Root CA -============== ------BEGIN CERTIFICATE----- -MIIDDDCCAfSgAwIBAgIDAQAgMA0GCSqGSIb3DQEBBQUAMD4xCzAJBgNVBAYTAlBMMRswGQYDVQQK -ExJVbml6ZXRvIFNwLiB6IG8uby4xEjAQBgNVBAMTCUNlcnR1bSBDQTAeFw0wMjA2MTExMDQ2Mzla -Fw0yNzA2MTExMDQ2MzlaMD4xCzAJBgNVBAYTAlBMMRswGQYDVQQKExJVbml6ZXRvIFNwLiB6IG8u -by4xEjAQBgNVBAMTCUNlcnR1bSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM6x -wS7TT3zNJc4YPk/EjG+AanPIW1H4m9LcuwBcsaD8dQPugfCI7iNS6eYVM42sLQnFdvkrOYCJ5JdL -kKWoePhzQ3ukYbDYWMzhbGZ+nPMJXlVjhNWo7/OxLjBos8Q82KxujZlakE403Daaj4GIULdtlkIJ -89eVgw1BS7Bqa/j8D35in2fE7SZfECYPCE/wpFcozo+47UX2bu4lXapuOb7kky/ZR6By6/qmW6/K -Uz/iDsaWVhFu9+lmqSbYf5VT7QqFiLpPKaVCjF62/IUgAKpoC6EahQGcxEZjgoi2IrHu/qpGWX7P -NSzVttpd90gzFFS269lvzs2I1qsb2pY7HVkCAwEAAaMTMBEwDwYDVR0TAQH/BAUwAwEB/zANBgkq -hkiG9w0BAQUFAAOCAQEAuI3O7+cUus/usESSbLQ5PqKEbq24IXfS1HeCh+YgQYHu4vgRt2PRFze+ -GXYkHAQaTOs9qmdvLdTN/mUxcMUbpgIKumB7bVjCmkn+YzILa+M6wKyrO7Do0wlRjBCDxjTgxSvg -GrZgFCdsMneMvLJymM/NzD+5yCRCFNZX/OYmQ6kd5YCQzgNUKD73P9P4Te1qCjqTE5s7FCMTY5w/ -0YcneeVMUeMBrYVdGjux1XMQpNPyvG5k9VpWkKjHDkx0Dy5xO/fIR/RpbxXyEV6DHpx8Uq79AtoS -qFlnGNu8cN2bsWntgM6JQEhqDjXKKWYVIZQs6GAqm4VKQPNriiTsBhYscw== ------END CERTIFICATE----- - -Comodo AAA Services root -======================== ------BEGIN CERTIFICATE----- -MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEbMBkGA1UECAwS -R3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0Eg -TGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAw -MFoXDTI4MTIzMTIzNTk1OVowezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hl -c3RlcjEQMA4GA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV -BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC -ggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQuaBtDFcCLNSS1UY8y2bmhG -C1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe3M/vg4aijJRPn2jymJBGhCfHdr/jzDUs -i14HZGWCwEiwqJH5YZ92IFCokcdmtet4YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszW -Y19zjNoFmag4qMsXeDZRrOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjH -Ypy+g8cmez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQUoBEK -Iz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wewYDVR0f -BHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNl -cy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29tb2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2Vz -LmNybDANBgkqhkiG9w0BAQUFAAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm -7l3sAg9g1o1QGE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz -Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2G9w84FoVxp7Z -8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsil2D4kF501KKaU73yqWjgom7C -12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg== ------END CERTIFICATE----- - -Comodo Secure Services root -=========================== ------BEGIN CERTIFICATE----- -MIIEPzCCAyegAwIBAgIBATANBgkqhkiG9w0BAQUFADB+MQswCQYDVQQGEwJHQjEbMBkGA1UECAwS -R3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0Eg -TGltaXRlZDEkMCIGA1UEAwwbU2VjdXJlIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAw -MDAwMFoXDTI4MTIzMTIzNTk1OVowfjELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFu -Y2hlc3RlcjEQMA4GA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxJDAi -BgNVBAMMG1NlY3VyZSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP -ADCCAQoCggEBAMBxM4KK0HDrc4eCQNUd5MvJDkKQ+d40uaG6EfQlhfPMcm3ye5drswfxdySRXyWP -9nQ95IDC+DwN879A6vfIUtFyb+/Iq0G4bi4XKpVpDM3SHpR7LZQdqnXXs5jLrLxkU0C8j6ysNstc -rbvd4JQX7NFc0L/vpZXJkMWwrPsbQ996CF23uPJAGysnnlDOXmWCiIxe004MeuoIkbY2qitC++rC -oznl2yY4rYsK7hljxxwk3wN42ubqwUcaCwtGCd0C/N7Lh1/XMGNooa7cMqG6vv5Eq2i2pRcV/b3V -p6ea5EQz6YiO/O1R65NxTq0B50SOqy3LqP4BSUjwwN3HaNiS/j0CAwEAAaOBxzCBxDAdBgNVHQ4E -FgQUPNiTiMLAggnMAZkGkyDpnnAJY08wDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8w -gYEGA1UdHwR6MHgwO6A5oDeGNWh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL1NlY3VyZUNlcnRpZmlj -YXRlU2VydmljZXMuY3JsMDmgN6A1hjNodHRwOi8vY3JsLmNvbW9kby5uZXQvU2VjdXJlQ2VydGlm -aWNhdGVTZXJ2aWNlcy5jcmwwDQYJKoZIhvcNAQEFBQADggEBAIcBbSMdflsXfcFhMs+P5/OKlFlm -4J4oqF7Tt/Q05qo5spcWxYJvMqTpjOev/e/C6LlLqqP05tqNZSH7uoDrJiiFGv45jN5bBAS0VPmj -Z55B+glSzAVIqMk/IQQezkhr/IXownuvf7fM+F86/TXGDe+X3EyrEeFryzHRbPtIgKvcnDe4IRRL -DXE97IMzbtFuMhbsmMcWi1mmNKsFVy2T96oTy9IT4rcuO81rUBcJaD61JlfutuC23bkpgHl9j6Pw -pCikFcSF9CfUa7/lXORlAnZUtOM3ZiTTGWHIUhDlizeauan5Hb/qmZJhlv8BzaFfDbxxvA6sCx1H -RR3B7Hzs/Sk= ------END CERTIFICATE----- - -Comodo Trusted Services root -============================ ------BEGIN CERTIFICATE----- -MIIEQzCCAyugAwIBAgIBATANBgkqhkiG9w0BAQUFADB/MQswCQYDVQQGEwJHQjEbMBkGA1UECAwS -R3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0Eg -TGltaXRlZDElMCMGA1UEAwwcVHJ1c3RlZCBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0wNDAxMDEw -MDAwMDBaFw0yODEyMzEyMzU5NTlaMH8xCzAJBgNVBAYTAkdCMRswGQYDVQQIDBJHcmVhdGVyIE1h -bmNoZXN0ZXIxEDAOBgNVBAcMB1NhbGZvcmQxGjAYBgNVBAoMEUNvbW9kbyBDQSBMaW1pdGVkMSUw -IwYDVQQDDBxUcnVzdGVkIENlcnRpZmljYXRlIFNlcnZpY2VzMIIBIjANBgkqhkiG9w0BAQEFAAOC -AQ8AMIIBCgKCAQEA33FvNlhTWvI2VFeAxHQIIO0Yfyod5jWaHiWsnOWWfnJSoBVC21ndZHoa0Lh7 -3TkVvFVIxO06AOoxEbrycXQaZ7jPM8yoMa+j49d/vzMtTGo87IvDktJTdyR0nAducPy9C1t2ul/y -/9c3S0pgePfw+spwtOpZqqPOSC+pw7ILfhdyFgymBwwbOM/JYrc/oJOlh0Hyt3BAd9i+FHzjqMB6 -juljatEPmsbS9Is6FARW1O24zG71++IsWL1/T2sr92AkWCTOJu80kTrV44HQsvAEAtdbtz6SrGsS -ivnkBbA7kUlcsutT6vifR4buv5XAwAaf0lteERv0xwQ1KdJVXOTt6wIDAQABo4HJMIHGMB0GA1Ud -DgQWBBTFe1i97doladL3WRaoszLAeydb9DAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB -/zCBgwYDVR0fBHwwejA8oDqgOIY2aHR0cDovL2NybC5jb21vZG9jYS5jb20vVHJ1c3RlZENlcnRp -ZmljYXRlU2VydmljZXMuY3JsMDqgOKA2hjRodHRwOi8vY3JsLmNvbW9kby5uZXQvVHJ1c3RlZENl -cnRpZmljYXRlU2VydmljZXMuY3JsMA0GCSqGSIb3DQEBBQUAA4IBAQDIk4E7ibSvuIQSTI3S8Ntw -uleGFTQQuS9/HrCoiWChisJ3DFBKmwCL2Iv0QeLQg4pKHBQGsKNoBXAxMKdTmw7pSqBYaWcOrp32 -pSxBvzwGa+RZzG0Q8ZZvH9/0BAKkn0U+yNj6NkZEUD+Cl5EfKNsYEYwq5GWDVxISjBc/lDb+XbDA -BHcTuPQV1T84zJQ6VdCsmPW6AF/ghhmBeC8owH7TzEIK9a5QoNE+xqFx7D+gIIxmOom0jtTYsU0l -R+4viMi14QVFwL4Ucd56/Y57fU0IlqUSc/AtyjcndBInTMu2l+nZrghtWjlA3QVHdWpaIbOjGM9O -9y5Xt5hwXsjEeLBi ------END CERTIFICATE----- - -QuoVadis Root CA -================ ------BEGIN CERTIFICATE----- -MIIF0DCCBLigAwIBAgIEOrZQizANBgkqhkiG9w0BAQUFADB/MQswCQYDVQQGEwJCTTEZMBcGA1UE -ChMQUXVvVmFkaXMgTGltaXRlZDElMCMGA1UECxMcUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0 -eTEuMCwGA1UEAxMlUXVvVmFkaXMgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0wMTAz -MTkxODMzMzNaFw0yMTAzMTcxODMzMzNaMH8xCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRp -cyBMaW1pdGVkMSUwIwYDVQQLExxSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MS4wLAYDVQQD -EyVRdW9WYWRpcyBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEAv2G1lVO6V/z68mcLOhrfEYBklbTRvM16z/Ypli4kVEAkOPcahdxYTMuk -J0KX0J+DisPkBgNbAKVRHnAEdOLB1Dqr1607BxgFjv2DrOpm2RgbaIr1VxqYuvXtdj182d6UajtL -F8HVj71lODqV0D1VNk7feVcxKh7YWWVJWCCYfqtffp/p1k3sg3Spx2zY7ilKhSoGFPlU5tPaZQeL -YzcS19Dsw3sgQUSj7cugF+FxZc4dZjH3dgEZyH0DWLaVSR2mEiboxgx24ONmy+pdpibu5cxfvWen -AScOospUxbF6lR1xHkopigPcakXBpBlebzbNw6Kwt/5cOOJSvPhEQ+aQuwIDAQABo4ICUjCCAk4w -PQYIKwYBBQUHAQEEMTAvMC0GCCsGAQUFBzABhiFodHRwczovL29jc3AucXVvdmFkaXNvZmZzaG9y -ZS5jb20wDwYDVR0TAQH/BAUwAwEB/zCCARoGA1UdIASCAREwggENMIIBCQYJKwYBBAG+WAABMIH7 -MIHUBggrBgEFBQcCAjCBxxqBxFJlbGlhbmNlIG9uIHRoZSBRdW9WYWRpcyBSb290IENlcnRpZmlj -YXRlIGJ5IGFueSBwYXJ0eSBhc3N1bWVzIGFjY2VwdGFuY2Ugb2YgdGhlIHRoZW4gYXBwbGljYWJs -ZSBzdGFuZGFyZCB0ZXJtcyBhbmQgY29uZGl0aW9ucyBvZiB1c2UsIGNlcnRpZmljYXRpb24gcHJh -Y3RpY2VzLCBhbmQgdGhlIFF1b1ZhZGlzIENlcnRpZmljYXRlIFBvbGljeS4wIgYIKwYBBQUHAgEW -Fmh0dHA6Ly93d3cucXVvdmFkaXMuYm0wHQYDVR0OBBYEFItLbe3TKbkGGew5Oanwl4Rqy+/fMIGu -BgNVHSMEgaYwgaOAFItLbe3TKbkGGew5Oanwl4Rqy+/foYGEpIGBMH8xCzAJBgNVBAYTAkJNMRkw -FwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMSUwIwYDVQQLExxSb290IENlcnRpZmljYXRpb24gQXV0 -aG9yaXR5MS4wLAYDVQQDEyVRdW9WYWRpcyBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5ggQ6 -tlCLMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQUFAAOCAQEAitQUtf70mpKnGdSkfnIYj9lo -fFIk3WdvOXrEql494liwTXCYhGHoG+NpGA7O+0dQoE7/8CQfvbLO9Sf87C9TqnN7Az10buYWnuul -LsS/VidQK2K6vkscPFVcQR0kvoIgR13VRH56FmjffU1RcHhXHTMe/QKZnAzNCgVPx7uOpHX6Sm2x -gI4JVrmcGmD+XcHXetwReNDWXcG31a0ymQM6isxUJTkxgXsTIlG6Rmyhu576BGxJJnSP0nPrzDCi -5upZIof4l/UO/erMkqQWxFIY6iHOsfHmhIHluqmGKPJDWl0Snawe2ajlCmqnf6CHKc/yiU3U7MXi -5nrQNiOKSnQ2+Q== ------END CERTIFICATE----- - -QuoVadis Root CA 2 -================== ------BEGIN CERTIFICATE----- -MIIFtzCCA5+gAwIBAgICBQkwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoT -EFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJvb3QgQ0EgMjAeFw0wNjExMjQx -ODI3MDBaFw0zMTExMjQxODIzMzNaMEUxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM -aW1pdGVkMRswGQYDVQQDExJRdW9WYWRpcyBSb290IENBIDIwggIiMA0GCSqGSIb3DQEBAQUAA4IC -DwAwggIKAoICAQCaGMpLlA0ALa8DKYrwD4HIrkwZhR0In6spRIXzL4GtMh6QRr+jhiYaHv5+HBg6 -XJxgFyo6dIMzMH1hVBHL7avg5tKifvVrbxi3Cgst/ek+7wrGsxDp3MJGF/hd/aTa/55JWpzmM+Yk -lvc/ulsrHHo1wtZn/qtmUIttKGAr79dgw8eTvI02kfN/+NsRE8Scd3bBrrcCaoF6qUWD4gXmuVbB -lDePSHFjIuwXZQeVikvfj8ZaCuWw419eaxGrDPmF60Tp+ARz8un+XJiM9XOva7R+zdRcAitMOeGy -lZUtQofX1bOQQ7dsE/He3fbE+Ik/0XX1ksOR1YqI0JDs3G3eicJlcZaLDQP9nL9bFqyS2+r+eXyt -66/3FsvbzSUr5R/7mp/iUcw6UwxI5g69ybR2BlLmEROFcmMDBOAENisgGQLodKcftslWZvB1Jdxn -wQ5hYIizPtGo/KPaHbDRsSNU30R2be1B2MGyIrZTHN81Hdyhdyox5C315eXbyOD/5YDXC2Og/zOh -D7osFRXql7PSorW+8oyWHhqPHWykYTe5hnMz15eWniN9gqRMgeKh0bpnX5UHoycR7hYQe7xFSkyy -BNKr79X9DFHOUGoIMfmR2gyPZFwDwzqLID9ujWc9Otb+fVuIyV77zGHcizN300QyNQliBJIWENie -J0f7OyHj+OsdWwIDAQABo4GwMIGtMA8GA1UdEwEB/wQFMAMBAf8wCwYDVR0PBAQDAgEGMB0GA1Ud -DgQWBBQahGK8SEwzJQTU7tD2A8QZRtGUazBuBgNVHSMEZzBlgBQahGK8SEwzJQTU7tD2A8QZRtGU -a6FJpEcwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMT -ElF1b1ZhZGlzIFJvb3QgQ0EgMoICBQkwDQYJKoZIhvcNAQEFBQADggIBAD4KFk2fBluornFdLwUv -Z+YTRYPENvbzwCYMDbVHZF34tHLJRqUDGCdViXh9duqWNIAXINzng/iN/Ae42l9NLmeyhP3ZRPx3 -UIHmfLTJDQtyU/h2BwdBR5YM++CCJpNVjP4iH2BlfF/nJrP3MpCYUNQ3cVX2kiF495V5+vgtJodm -VjB3pjd4M1IQWK4/YY7yarHvGH5KWWPKjaJW1acvvFYfzznB4vsKqBUsfU16Y8Zsl0Q80m/DShcK -+JDSV6IZUaUtl0HaB0+pUNqQjZRG4T7wlP0QADj1O+hA4bRuVhogzG9Yje0uRY/W6ZM/57Es3zrW -IozchLsib9D45MY56QSIPMO661V6bYCZJPVsAfv4l7CUW+v90m/xd2gNNWQjrLhVoQPRTUIZ3Ph1 -WVaj+ahJefivDrkRoHy3au000LYmYjgahwz46P0u05B/B5EqHdZ+XIWDmbA4CD/pXvk1B+TJYm5X -f6dQlfe6yJvmjqIBxdZmv3lh8zwc4bmCXF2gw+nYSL0ZohEUGW6yhhtoPkg3Goi3XZZenMfvJ2II -4pEZXNLxId26F0KCl3GBUzGpn/Z9Yr9y4aOTHcyKJloJONDO1w2AFrR4pTqHTI2KpdVGl/IsELm8 -VCLAAVBpQ570su9t+Oza8eOx79+Rj1QqCyXBJhnEUhAFZdWCEOrCMc0u ------END CERTIFICATE----- - -QuoVadis Root CA 3 -================== ------BEGIN CERTIFICATE----- -MIIGnTCCBIWgAwIBAgICBcYwDQYJKoZIhvcNAQEFBQAwRTELMAkGA1UEBhMCQk0xGTAXBgNVBAoT -EFF1b1ZhZGlzIExpbWl0ZWQxGzAZBgNVBAMTElF1b1ZhZGlzIFJvb3QgQ0EgMzAeFw0wNjExMjQx -OTExMjNaFw0zMTExMjQxOTA2NDRaMEUxCzAJBgNVBAYTAkJNMRkwFwYDVQQKExBRdW9WYWRpcyBM -aW1pdGVkMRswGQYDVQQDExJRdW9WYWRpcyBSb290IENBIDMwggIiMA0GCSqGSIb3DQEBAQUAA4IC -DwAwggIKAoICAQDMV0IWVJzmmNPTTe7+7cefQzlKZbPoFog02w1ZkXTPkrgEQK0CSzGrvI2RaNgg -DhoB4hp7Thdd4oq3P5kazethq8Jlph+3t723j/z9cI8LoGe+AaJZz3HmDyl2/7FWeUUrH556VOij -KTVopAFPD6QuN+8bv+OPEKhyq1hX51SGyMnzW9os2l2ObjyjPtr7guXd8lyyBTNvijbO0BNO/79K -DDRMpsMhvVAEVeuxu537RR5kFd5VAYwCdrXLoT9CabwvvWhDFlaJKjdhkf2mrk7AyxRllDdLkgbv -BNDInIjbC3uBr7E9KsRlOni27tyAsdLTmZw67mtaa7ONt9XOnMK+pUsvFrGeaDsGb659n/je7Mwp -p5ijJUMv7/FfJuGITfhebtfZFG4ZM2mnO4SJk8RTVROhUXhA+LjJou57ulJCg54U7QVSWllWp5f8 -nT8KKdjcT5EOE7zelaTfi5m+rJsziO+1ga8bxiJTyPbH7pcUsMV8eFLI8M5ud2CEpukqdiDtWAEX -MJPpGovgc2PZapKUSU60rUqFxKMiMPwJ7Wgic6aIDFUhWMXhOp8q3crhkODZc6tsgLjoC2SToJyM -Gf+z0gzskSaHirOi4XCPLArlzW1oUevaPwV/izLmE1xr/l9A4iLItLRkT9a6fUg+qGkM17uGcclz -uD87nSVL2v9A6wIDAQABo4IBlTCCAZEwDwYDVR0TAQH/BAUwAwEB/zCB4QYDVR0gBIHZMIHWMIHT -BgkrBgEEAb5YAAMwgcUwgZMGCCsGAQUFBwICMIGGGoGDQW55IHVzZSBvZiB0aGlzIENlcnRpZmlj -YXRlIGNvbnN0aXR1dGVzIGFjY2VwdGFuY2Ugb2YgdGhlIFF1b1ZhZGlzIFJvb3QgQ0EgMyBDZXJ0 -aWZpY2F0ZSBQb2xpY3kgLyBDZXJ0aWZpY2F0aW9uIFByYWN0aWNlIFN0YXRlbWVudC4wLQYIKwYB -BQUHAgEWIWh0dHA6Ly93d3cucXVvdmFkaXNnbG9iYWwuY29tL2NwczALBgNVHQ8EBAMCAQYwHQYD -VR0OBBYEFPLAE+CCQz777i9nMpY1XNu4ywLQMG4GA1UdIwRnMGWAFPLAE+CCQz777i9nMpY1XNu4 -ywLQoUmkRzBFMQswCQYDVQQGEwJCTTEZMBcGA1UEChMQUXVvVmFkaXMgTGltaXRlZDEbMBkGA1UE -AxMSUXVvVmFkaXMgUm9vdCBDQSAzggIFxjANBgkqhkiG9w0BAQUFAAOCAgEAT62gLEz6wPJv92ZV -qyM07ucp2sNbtrCD2dDQ4iH782CnO11gUyeim/YIIirnv6By5ZwkajGxkHon24QRiSemd1o417+s -hvzuXYO8BsbRd2sPbSQvS3pspweWyuOEn62Iix2rFo1bZhfZFvSLgNLd+LJ2w/w4E6oM3kJpK27z -POuAJ9v1pkQNn1pVWQvVDVJIxa6f8i+AxeoyUDUSly7B4f/xI4hROJ/yZlZ25w9Rl6VSDE1JUZU2 -Pb+iSwwQHYaZTKrzchGT5Or2m9qoXadNt54CrnMAyNojA+j56hl0YgCUyyIgvpSnWbWCar6ZeXqp -8kokUvd0/bpO5qgdAm6xDYBEwa7TIzdfu4V8K5Iu6H6li92Z4b8nby1dqnuH/grdS/yO9SbkbnBC -bjPsMZ57k8HkyWkaPcBrTiJt7qtYTcbQQcEr6k8Sh17rRdhs9ZgC06DYVYoGmRmioHfRMJ6szHXu -g/WwYjnPbFfiTNKRCw51KBuav/0aQ/HKd/s7j2G4aSgWQgRecCocIdiP4b0jWy10QJLZYxkNc91p -vGJHvOB0K7Lrfb5BG7XARsWhIstfTsEokt4YutUqKLsRixeTmJlglFwjz1onl14LBQaTNx47aTbr -qZ5hHY8y2o4M1nQ+ewkk2gF3R8Q7zTSMmfXK4SVhM7JZG+Ju1zdXtg2pEto= ------END CERTIFICATE----- - -Security Communication Root CA -============================== ------BEGIN CERTIFICATE----- -MIIDWjCCAkKgAwIBAgIBADANBgkqhkiG9w0BAQUFADBQMQswCQYDVQQGEwJKUDEYMBYGA1UEChMP -U0VDT00gVHJ1c3QubmV0MScwJQYDVQQLEx5TZWN1cml0eSBDb21tdW5pY2F0aW9uIFJvb3RDQTEw -HhcNMDMwOTMwMDQyMDQ5WhcNMjMwOTMwMDQyMDQ5WjBQMQswCQYDVQQGEwJKUDEYMBYGA1UEChMP -U0VDT00gVHJ1c3QubmV0MScwJQYDVQQLEx5TZWN1cml0eSBDb21tdW5pY2F0aW9uIFJvb3RDQTEw -ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCzs/5/022x7xZ8V6UMbXaKL0u/ZPtM7orw -8yl89f/uKuDp6bpbZCKamm8sOiZpUQWZJtzVHGpxxpp9Hp3dfGzGjGdnSj74cbAZJ6kJDKaVv0uM -DPpVmDvY6CKhS3E4eayXkmmziX7qIWgGmBSWh9JhNrxtJ1aeV+7AwFb9Ms+k2Y7CI9eNqPPYJayX -5HA49LY6tJ07lyZDo6G8SVlyTCMwhwFY9k6+HGhWZq/NQV3Is00qVUarH9oe4kA92819uZKAnDfd -DJZkndwi92SL32HeFZRSFaB9UslLqCHJxrHty8OVYNEP8Ktw+N/LTX7s1vqr2b1/VPKl6Xn62dZ2 -JChzAgMBAAGjPzA9MB0GA1UdDgQWBBSgc0mZaNyFW2XjmygvV5+9M7wHSDALBgNVHQ8EBAMCAQYw -DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAaECpqLvkT115swW1F7NgE+vGkl3g -0dNq/vu+m22/xwVtWSDEHPC32oRYAmP6SBbvT6UL90qY8j+eG61Ha2POCEfrUj94nK9NrvjVT8+a -mCoQQTlSxN3Zmw7vkwGusi7KaEIkQmywszo+zenaSMQVy+n5Bw+SUEmK3TGXX8npN6o7WWWXlDLJ -s58+OmJYxUmtYg5xpTKqL8aJdkNAExNnPaJUJRDL8Try2frbSVa7pv6nQTXD4IhhyYjH3zYQIphZ -6rBK+1YWc26sTfcioU+tHXotRSflMMFe8toTyyVCUZVHA4xsIcx0Qu1T/zOLjw9XARYvz6buyXAi -FL39vmwLAw== ------END CERTIFICATE----- - -Sonera Class 2 Root CA -====================== ------BEGIN CERTIFICATE----- -MIIDIDCCAgigAwIBAgIBHTANBgkqhkiG9w0BAQUFADA5MQswCQYDVQQGEwJGSTEPMA0GA1UEChMG -U29uZXJhMRkwFwYDVQQDExBTb25lcmEgQ2xhc3MyIENBMB4XDTAxMDQwNjA3Mjk0MFoXDTIxMDQw -NjA3Mjk0MFowOTELMAkGA1UEBhMCRkkxDzANBgNVBAoTBlNvbmVyYTEZMBcGA1UEAxMQU29uZXJh -IENsYXNzMiBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJAXSjWdyvANlsdE+hY3 -/Ei9vX+ALTU74W+oZ6m/AxxNjG8yR9VBaKQTBME1DJqEQ/xcHf+Js+gXGM2RX/uJ4+q/Tl18GybT -dXnt5oTjV+WtKcT0OijnpXuENmmz/V52vaMtmdOQTiMofRhj8VQ7Jp12W5dCsv+u8E7s3TmVToMG -f+dJQMjFAbJUWmYdPfz56TwKnoG4cPABi+QjVHzIrviQHgCWctRUz2EjvOr7nQKV0ba5cTppCD8P -tOFCx4j1P5iop7oc4HFx71hXgVB6XGt0Rg6DA5jDjqhu8nYybieDwnPz3BjotJPqdURrBGAgcVeH -nfO+oJAjPYok4doh28MCAwEAAaMzMDEwDwYDVR0TAQH/BAUwAwEB/zARBgNVHQ4ECgQISqCqWITT -XjwwCwYDVR0PBAQDAgEGMA0GCSqGSIb3DQEBBQUAA4IBAQBazof5FnIVV0sd2ZvnoiYw7JNn39Yt -0jSv9zilzqsWuasvfDXLrNAPtEwr/IDva4yRXzZ299uzGxnq9LIR/WFxRL8oszodv7ND6J+/3DEI -cbCdjdY0RzKQxmUk96BKfARzjzlvF4xytb1LyHr4e4PDKE6cCepnP7JnBBvDFNr450kkkdAdavph -Oe9r5yF1BgfYErQhIHBCcYHaPJo2vqZbDWpsmh+Re/n570K6Tk6ezAyNlNzZRZxe7EJQY670XcSx -EtzKO6gunRRaBXW37Ndj4ro1tgQIkejanZz2ZrUYrAqmVCY0M9IbwdR/GjqOC6oybtv8TyWf2TLH -llpwrN9M ------END CERTIFICATE----- - -UTN USERFirst Hardware Root CA -============================== ------BEGIN CERTIFICATE----- -MIIEdDCCA1ygAwIBAgIQRL4Mi1AAJLQR0zYq/mUK/TANBgkqhkiG9w0BAQUFADCBlzELMAkGA1UE -BhMCVVMxCzAJBgNVBAgTAlVUMRcwFQYDVQQHEw5TYWx0IExha2UgQ2l0eTEeMBwGA1UEChMVVGhl -IFVTRVJUUlVTVCBOZXR3b3JrMSEwHwYDVQQLExhodHRwOi8vd3d3LnVzZXJ0cnVzdC5jb20xHzAd -BgNVBAMTFlVUTi1VU0VSRmlyc3QtSGFyZHdhcmUwHhcNOTkwNzA5MTgxMDQyWhcNMTkwNzA5MTgx -OTIyWjCBlzELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAlVUMRcwFQYDVQQHEw5TYWx0IExha2UgQ2l0 -eTEeMBwGA1UEChMVVGhlIFVTRVJUUlVTVCBOZXR3b3JrMSEwHwYDVQQLExhodHRwOi8vd3d3LnVz -ZXJ0cnVzdC5jb20xHzAdBgNVBAMTFlVUTi1VU0VSRmlyc3QtSGFyZHdhcmUwggEiMA0GCSqGSIb3 -DQEBAQUAA4IBDwAwggEKAoIBAQCx98M4P7Sof885glFn0G2f0v9Y8+efK+wNiVSZuTiZFvfgIXlI -wrthdBKWHTxqctU8EGc6Oe0rE81m65UJM6Rsl7HoxuzBdXmcRl6Nq9Bq/bkqVRcQVLMZ8Jr28bFd -tqdt++BxF2uiiPsA3/4aMXcMmgF6sTLjKwEHOG7DpV4jvEWbe1DByTCP2+UretNb+zNAHqDVmBe8 -i4fDidNdoI6yqqr2jmmIBsX6iSHzCJ1pLgkzmykNRg+MzEk0sGlRvfkGzWitZky8PqxhvQqIDsjf -Pe58BEydCl5rkdbux+0ojatNh4lz0G6k0B4WixThdkQDf2Os5M1JnMWS9KsyoUhbAgMBAAGjgbkw -gbYwCwYDVR0PBAQDAgHGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFKFyXyYbKJhDlV0HN9WF -lp1L0sNFMEQGA1UdHwQ9MDswOaA3oDWGM2h0dHA6Ly9jcmwudXNlcnRydXN0LmNvbS9VVE4tVVNF -UkZpcnN0LUhhcmR3YXJlLmNybDAxBgNVHSUEKjAoBggrBgEFBQcDAQYIKwYBBQUHAwUGCCsGAQUF -BwMGBggrBgEFBQcDBzANBgkqhkiG9w0BAQUFAAOCAQEARxkP3nTGmZev/K0oXnWO6y1n7k57K9cM -//bey1WiCuFMVGWTYGufEpytXoMs61quwOQt9ABjHbjAbPLPSbtNk28GpgoiskliCE7/yMgUsogW -XecB5BKV5UU0s4tpvc+0hY91UZ59Ojg6FEgSxvunOxqNDYJAB+gECJChicsZUN/KHAG8HQQZexB2 -lzvukJDKxA4fFm517zP4029bHpbj4HR3dHuKom4t3XbWOTCC8KucUvIqx69JXn7HaOWCgchqJ/kn -iCrVWFCVH/A7HFe7fRQ5YiuayZSSKqMiDP+JJn1fIytH1xUdqWqeUQ0qUZ6B+dQ7XnASfxAynB67 -nfhmqA== ------END CERTIFICATE----- - -Camerfirma Chambers of Commerce Root -==================================== ------BEGIN CERTIFICATE----- -MIIEvTCCA6WgAwIBAgIBADANBgkqhkiG9w0BAQUFADB/MQswCQYDVQQGEwJFVTEnMCUGA1UEChMe -QUMgQ2FtZXJmaXJtYSBTQSBDSUYgQTgyNzQzMjg3MSMwIQYDVQQLExpodHRwOi8vd3d3LmNoYW1i -ZXJzaWduLm9yZzEiMCAGA1UEAxMZQ2hhbWJlcnMgb2YgQ29tbWVyY2UgUm9vdDAeFw0wMzA5MzAx -NjEzNDNaFw0zNzA5MzAxNjEzNDRaMH8xCzAJBgNVBAYTAkVVMScwJQYDVQQKEx5BQyBDYW1lcmZp -cm1hIFNBIENJRiBBODI3NDMyODcxIzAhBgNVBAsTGmh0dHA6Ly93d3cuY2hhbWJlcnNpZ24ub3Jn -MSIwIAYDVQQDExlDaGFtYmVycyBvZiBDb21tZXJjZSBSb290MIIBIDANBgkqhkiG9w0BAQEFAAOC -AQ0AMIIBCAKCAQEAtzZV5aVdGDDg2olUkfzIx1L4L1DZ77F1c2VHfRtbunXF/KGIJPov7coISjlU -xFF6tdpg6jg8gbLL8bvZkSM/SAFwdakFKq0fcfPJVD0dBmpAPrMMhe5cG3nCYsS4No41XQEMIwRH -NaqbYE6gZj3LJgqcQKH0XZi/caulAGgq7YN6D6IUtdQis4CwPAxaUWktWBiP7Zme8a7ileb2R6jW -DA+wWFjbw2Y3npuRVDM30pQcakjJyfKl2qUMI/cjDpwyVV5xnIQFUZot/eZOKjRa3spAN2cMVCFV -d9oKDMyXroDclDZK9D7ONhMeU+SsTjoF7Nuucpw4i9A5O4kKPnf+dQIBA6OCAUQwggFAMBIGA1Ud -EwEB/wQIMAYBAf8CAQwwPAYDVR0fBDUwMzAxoC+gLYYraHR0cDovL2NybC5jaGFtYmVyc2lnbi5v -cmcvY2hhbWJlcnNyb290LmNybDAdBgNVHQ4EFgQU45T1sU3p26EpW1eLTXYGduHRooowDgYDVR0P -AQH/BAQDAgEGMBEGCWCGSAGG+EIBAQQEAwIABzAnBgNVHREEIDAegRxjaGFtYmVyc3Jvb3RAY2hh -bWJlcnNpZ24ub3JnMCcGA1UdEgQgMB6BHGNoYW1iZXJzcm9vdEBjaGFtYmVyc2lnbi5vcmcwWAYD -VR0gBFEwTzBNBgsrBgEEAYGHLgoDATA+MDwGCCsGAQUFBwIBFjBodHRwOi8vY3BzLmNoYW1iZXJz -aWduLm9yZy9jcHMvY2hhbWJlcnNyb290Lmh0bWwwDQYJKoZIhvcNAQEFBQADggEBAAxBl8IahsAi -fJ/7kPMa0QOx7xP5IV8EnNrJpY0nbJaHkb5BkAFyk+cefV/2icZdp0AJPaxJRUXcLo0waLIJuvvD -L8y6C98/d3tGfToSJI6WjzwFCm/SlCgdbQzALogi1djPHRPH8EjX1wWnz8dHnjs8NMiAT9QUu/wN -UPf6s+xCX6ndbcj0dc97wXImsQEcXCz9ek60AcUFV7nnPKoF2YjpB0ZBzu9Bga5Y34OirsrXdx/n -ADydb47kMgkdTXg0eDQ8lJsm7U9xxhl6vSAiSFr+S30Dt+dYvsYyTnQeaN2oaFuzPu5ifdmA6Ap1 -erfutGWaIZDgqtCYvDi1czyL+Nw= ------END CERTIFICATE----- - -Camerfirma Global Chambersign Root -================================== ------BEGIN CERTIFICATE----- -MIIExTCCA62gAwIBAgIBADANBgkqhkiG9w0BAQUFADB9MQswCQYDVQQGEwJFVTEnMCUGA1UEChMe -QUMgQ2FtZXJmaXJtYSBTQSBDSUYgQTgyNzQzMjg3MSMwIQYDVQQLExpodHRwOi8vd3d3LmNoYW1i -ZXJzaWduLm9yZzEgMB4GA1UEAxMXR2xvYmFsIENoYW1iZXJzaWduIFJvb3QwHhcNMDMwOTMwMTYx -NDE4WhcNMzcwOTMwMTYxNDE4WjB9MQswCQYDVQQGEwJFVTEnMCUGA1UEChMeQUMgQ2FtZXJmaXJt -YSBTQSBDSUYgQTgyNzQzMjg3MSMwIQYDVQQLExpodHRwOi8vd3d3LmNoYW1iZXJzaWduLm9yZzEg -MB4GA1UEAxMXR2xvYmFsIENoYW1iZXJzaWduIFJvb3QwggEgMA0GCSqGSIb3DQEBAQUAA4IBDQAw -ggEIAoIBAQCicKLQn0KuWxfH2H3PFIP8T8mhtxOviteePgQKkotgVvq0Mi+ITaFgCPS3CU6gSS9J -1tPfnZdan5QEcOw/Wdm3zGaLmFIoCQLfxS+EjXqXd7/sQJ0lcqu1PzKY+7e3/HKE5TWH+VX6ox8O -by4o3Wmg2UIQxvi1RMLQQ3/bvOSiPGpVeAp3qdjqGTK3L/5cPxvusZjsyq16aUXjlg9V9ubtdepl -6DJWk0aJqCWKZQbua795B9Dxt6/tLE2Su8CoX6dnfQTyFQhwrJLWfQTSM/tMtgsL+xrJxI0DqX5c -8lCrEqWhz0hQpe/SyBoT+rB/sYIcd2oPX9wLlY/vQ37mRQklAgEDo4IBUDCCAUwwEgYDVR0TAQH/ -BAgwBgEB/wIBDDA/BgNVHR8EODA2MDSgMqAwhi5odHRwOi8vY3JsLmNoYW1iZXJzaWduLm9yZy9j -aGFtYmVyc2lnbnJvb3QuY3JsMB0GA1UdDgQWBBRDnDafsJ4wTcbOX60Qq+UDpfqpFDAOBgNVHQ8B -Af8EBAMCAQYwEQYJYIZIAYb4QgEBBAQDAgAHMCoGA1UdEQQjMCGBH2NoYW1iZXJzaWducm9vdEBj -aGFtYmVyc2lnbi5vcmcwKgYDVR0SBCMwIYEfY2hhbWJlcnNpZ25yb290QGNoYW1iZXJzaWduLm9y -ZzBbBgNVHSAEVDBSMFAGCysGAQQBgYcuCgEBMEEwPwYIKwYBBQUHAgEWM2h0dHA6Ly9jcHMuY2hh -bWJlcnNpZ24ub3JnL2Nwcy9jaGFtYmVyc2lnbnJvb3QuaHRtbDANBgkqhkiG9w0BAQUFAAOCAQEA -PDtwkfkEVCeR4e3t/mh/YV3lQWVPMvEYBZRqHN4fcNs+ezICNLUMbKGKfKX0j//U2K0X1S0E0T9Y -gOKBWYi+wONGkyT+kL0mojAt6JcmVzWJdJYY9hXiryQZVgICsroPFOrGimbBhkVVi76SvpykBMdJ -PJ7oKXqJ1/6v/2j1pReQvayZzKWGVwlnRtvWFsJG8eSpUPWP0ZIV018+xgBJOm5YstHRJw0lyDL4 -IBHNfTIzSJRUTN3cecQwn+uOuFW114hcxWokPbLTBQNRxgfvzBRydD1ucs4YKIxKoHflCStFREes -t2d/AYoFWpO+ocH/+OcOZ6RHSXZddZAa9SaP8A== ------END CERTIFICATE----- - -XRamp Global CA Root -==================== ------BEGIN CERTIFICATE----- -MIIEMDCCAxigAwIBAgIQUJRs7Bjq1ZxN1ZfvdY+grTANBgkqhkiG9w0BAQUFADCBgjELMAkGA1UE -BhMCVVMxHjAcBgNVBAsTFXd3dy54cmFtcHNlY3VyaXR5LmNvbTEkMCIGA1UEChMbWFJhbXAgU2Vj -dXJpdHkgU2VydmljZXMgSW5jMS0wKwYDVQQDEyRYUmFtcCBHbG9iYWwgQ2VydGlmaWNhdGlvbiBB -dXRob3JpdHkwHhcNMDQxMTAxMTcxNDA0WhcNMzUwMTAxMDUzNzE5WjCBgjELMAkGA1UEBhMCVVMx -HjAcBgNVBAsTFXd3dy54cmFtcHNlY3VyaXR5LmNvbTEkMCIGA1UEChMbWFJhbXAgU2VjdXJpdHkg -U2VydmljZXMgSW5jMS0wKwYDVQQDEyRYUmFtcCBHbG9iYWwgQ2VydGlmaWNhdGlvbiBBdXRob3Jp -dHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCYJB69FbS638eMpSe2OAtp87ZOqCwu -IR1cRN8hXX4jdP5efrRKt6atH67gBhbim1vZZ3RrXYCPKZ2GG9mcDZhtdhAoWORlsH9KmHmf4MMx -foArtYzAQDsRhtDLooY2YKTVMIJt2W7QDxIEM5dfT2Fa8OT5kavnHTu86M/0ay00fOJIYRyO82FE -zG+gSqmUsE3a56k0enI4qEHMPJQRfevIpoy3hsvKMzvZPTeL+3o+hiznc9cKV6xkmxnr9A8ECIqs -AxcZZPRaJSKNNCyy9mgdEm3Tih4U2sSPpuIjhdV6Db1q4Ons7Be7QhtnqiXtRYMh/MHJfNViPvry -xS3T/dRlAgMBAAGjgZ8wgZwwEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0PBAQDAgGGMA8GA1Ud -EwEB/wQFMAMBAf8wHQYDVR0OBBYEFMZPoj0GY4QJnM5i5ASsjVy16bYbMDYGA1UdHwQvMC0wK6Ap -oCeGJWh0dHA6Ly9jcmwueHJhbXBzZWN1cml0eS5jb20vWEdDQS5jcmwwEAYJKwYBBAGCNxUBBAMC -AQEwDQYJKoZIhvcNAQEFBQADggEBAJEVOQMBG2f7Shz5CmBbodpNl2L5JFMn14JkTpAuw0kbK5rc -/Kh4ZzXxHfARvbdI4xD2Dd8/0sm2qlWkSLoC295ZLhVbO50WfUfXN+pfTXYSNrsf16GBBEYgoyxt -qZ4Bfj8pzgCT3/3JknOJiWSe5yvkHJEs0rnOfc5vMZnT5r7SHpDwCRR5XCOrTdLaIR9NmXmd4c8n -nxCbHIgNsIpkQTG4DmyQJKSbXHGPurt+HBvbaoAPIbzp26a3QPSyi6mx5O+aGtA9aZnuqCij4Tyz -8LIRnM98QObd50N9otg6tamN8jSZxNQQ4Qb9CYQQO+7ETPTsJ3xCwnR8gooJybQDJbw= ------END CERTIFICATE----- - -Go Daddy Class 2 CA -=================== ------BEGIN CERTIFICATE----- -MIIEADCCAuigAwIBAgIBADANBgkqhkiG9w0BAQUFADBjMQswCQYDVQQGEwJVUzEhMB8GA1UEChMY -VGhlIEdvIERhZGR5IEdyb3VwLCBJbmMuMTEwLwYDVQQLEyhHbyBEYWRkeSBDbGFzcyAyIENlcnRp -ZmljYXRpb24gQXV0aG9yaXR5MB4XDTA0MDYyOTE3MDYyMFoXDTM0MDYyOTE3MDYyMFowYzELMAkG -A1UEBhMCVVMxITAfBgNVBAoTGFRoZSBHbyBEYWRkeSBHcm91cCwgSW5jLjExMC8GA1UECxMoR28g -RGFkZHkgQ2xhc3MgMiBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCASAwDQYJKoZIhvcNAQEBBQAD -ggENADCCAQgCggEBAN6d1+pXGEmhW+vXX0iG6r7d/+TvZxz0ZWizV3GgXne77ZtJ6XCAPVYYYwhv -2vLM0D9/AlQiVBDYsoHUwHU9S3/Hd8M+eKsaA7Ugay9qK7HFiH7Eux6wwdhFJ2+qN1j3hybX2C32 -qRe3H3I2TqYXP2WYktsqbl2i/ojgC95/5Y0V4evLOtXiEqITLdiOr18SPaAIBQi2XKVlOARFmR6j -YGB0xUGlcmIbYsUfb18aQr4CUWWoriMYavx4A6lNf4DD+qta/KFApMoZFv6yyO9ecw3ud72a9nmY -vLEHZ6IVDd2gWMZEewo+YihfukEHU1jPEX44dMX4/7VpkI+EdOqXG68CAQOjgcAwgb0wHQYDVR0O -BBYEFNLEsNKR1EwRcbNhyz2h/t2oatTjMIGNBgNVHSMEgYUwgYKAFNLEsNKR1EwRcbNhyz2h/t2o -atTjoWekZTBjMQswCQYDVQQGEwJVUzEhMB8GA1UEChMYVGhlIEdvIERhZGR5IEdyb3VwLCBJbmMu -MTEwLwYDVQQLEyhHbyBEYWRkeSBDbGFzcyAyIENlcnRpZmljYXRpb24gQXV0aG9yaXR5ggEAMAwG -A1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBADJL87LKPpH8EsahB4yOd6AzBhRckB4Y9wim -PQoZ+YeAEW5p5JYXMP80kWNyOO7MHAGjHZQopDH2esRU1/blMVgDoszOYtuURXO1v0XJJLXVggKt -I3lpjbi2Tc7PTMozI+gciKqdi0FuFskg5YmezTvacPd+mSYgFFQlq25zheabIZ0KbIIOqPjCDPoQ -HmyW74cNxA9hi63ugyuV+I6ShHI56yDqg+2DzZduCLzrTia2cyvk0/ZM/iZx4mERdEr/VxqHD3VI -Ls9RaRegAhJhldXRQLIQTO7ErBBDpqWeCtWVYpoNz4iCxTIM5CufReYNnyicsbkqWletNw+vHX/b -vZ8= ------END CERTIFICATE----- - -Starfield Class 2 CA -==================== ------BEGIN CERTIFICATE----- -MIIEDzCCAvegAwIBAgIBADANBgkqhkiG9w0BAQUFADBoMQswCQYDVQQGEwJVUzElMCMGA1UEChMc -U3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAGA1UECxMpU3RhcmZpZWxkIENsYXNzIDIg -Q2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDQwNjI5MTczOTE2WhcNMzQwNjI5MTczOTE2WjBo -MQswCQYDVQQGEwJVUzElMCMGA1UEChMcU3RhcmZpZWxkIFRlY2hub2xvZ2llcywgSW5jLjEyMDAG -A1UECxMpU3RhcmZpZWxkIENsYXNzIDIgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEgMA0GCSqG -SIb3DQEBAQUAA4IBDQAwggEIAoIBAQC3Msj+6XGmBIWtDBFk385N78gDGIc/oav7PKaf8MOh2tTY -bitTkPskpD6E8J7oX+zlJ0T1KKY/e97gKvDIr1MvnsoFAZMej2YcOadN+lq2cwQlZut3f+dZxkqZ -JRRU6ybH838Z1TBwj6+wRir/resp7defqgSHo9T5iaU0X9tDkYI22WY8sbi5gv2cOj4QyDvvBmVm -epsZGD3/cVE8MC5fvj13c7JdBmzDI1aaK4UmkhynArPkPw2vCHmCuDY96pzTNbO8acr1zJ3o/WSN -F4Azbl5KXZnJHoe0nRrA1W4TNSNe35tfPe/W93bC6j67eA0cQmdrBNj41tpvi/JEoAGrAgEDo4HF -MIHCMB0GA1UdDgQWBBS/X7fRzt0fhvRbVazc1xDCDqmI5zCBkgYDVR0jBIGKMIGHgBS/X7fRzt0f -hvRbVazc1xDCDqmI56FspGowaDELMAkGA1UEBhMCVVMxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNo -bm9sb2dpZXMsIEluYy4xMjAwBgNVBAsTKVN0YXJmaWVsZCBDbGFzcyAyIENlcnRpZmljYXRpb24g -QXV0aG9yaXR5ggEAMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAWdP4id0ckaVaGs -afPzWdqbAYcaT1epoXkJKtv3L7IezMdeatiDh6GX70k1PncGQVhiv45YuApnP+yz3SFmH8lU+nLM -PUxA2IGvd56Deruix/U0F47ZEUD0/CwqTRV/p2JdLiXTAAsgGh1o+Re49L2L7ShZ3U0WixeDyLJl -xy16paq8U4Zt3VekyvggQQto8PT7dL5WXXp59fkdheMtlb71cZBDzI0fmgAKhynpVSJYACPq4xJD -KVtHCN2MQWplBqjlIapBtJUhlbl90TSrE9atvNziPTnNvT51cKEYWQPJIrSPnNVeKtelttQKbfi3 -QBFGmh95DmK/D5fs4C8fF5Q= ------END CERTIFICATE----- - -StartCom Certification Authority -================================ ------BEGIN CERTIFICATE----- -MIIHyTCCBbGgAwIBAgIBATANBgkqhkiG9w0BAQUFADB9MQswCQYDVQQGEwJJTDEWMBQGA1UEChMN -U3RhcnRDb20gTHRkLjErMCkGA1UECxMiU2VjdXJlIERpZ2l0YWwgQ2VydGlmaWNhdGUgU2lnbmlu -ZzEpMCcGA1UEAxMgU3RhcnRDb20gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDYwOTE3MTk0 -NjM2WhcNMzYwOTE3MTk0NjM2WjB9MQswCQYDVQQGEwJJTDEWMBQGA1UEChMNU3RhcnRDb20gTHRk -LjErMCkGA1UECxMiU2VjdXJlIERpZ2l0YWwgQ2VydGlmaWNhdGUgU2lnbmluZzEpMCcGA1UEAxMg -U3RhcnRDb20gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw -ggIKAoICAQDBiNsJvGxGfHiflXu1M5DycmLWwTYgIiRezul38kMKogZkpMyONvg45iPwbm2xPN1y -o4UcodM9tDMr0y+v/uqwQVlntsQGfQqedIXWeUyAN3rfOQVSWff0G0ZDpNKFhdLDcfN1YjS6LIp/ -Ho/u7TTQEceWzVI9ujPW3U3eCztKS5/CJi/6tRYccjV3yjxd5srhJosaNnZcAdt0FCX+7bWgiA/d -eMotHweXMAEtcnn6RtYTKqi5pquDSR3l8u/d5AGOGAqPY1MWhWKpDhk6zLVmpsJrdAfkK+F2PrRt -2PZE4XNiHzvEvqBTViVsUQn3qqvKv3b9bZvzndu/PWa8DFaqr5hIlTpL36dYUNk4dalb6kMMAv+Z -6+hsTXBbKWWc3apdzK8BMewM69KN6Oqce+Zu9ydmDBpI125C4z/eIT574Q1w+2OqqGwaVLRcJXrJ -osmLFqa7LH4XXgVNWG4SHQHuEhANxjJ/GP/89PrNbpHoNkm+Gkhpi8KWTRoSsmkXwQqQ1vp5Iki/ -untp+HDH+no32NgN0nZPV/+Qt+OR0t3vwmC3Zzrd/qqc8NSLf3Iizsafl7b4r4qgEKjZ+xjGtrVc -UjyJthkqcwEKDwOzEmDyei+B26Nu/yYwl/WL3YlXtq09s68rxbd2AvCl1iuahhQqcvbjM4xdCUsT -37uMdBNSSwIDAQABo4ICUjCCAk4wDAYDVR0TBAUwAwEB/zALBgNVHQ8EBAMCAa4wHQYDVR0OBBYE -FE4L7xqkQFulF2mHMMo0aEPQQa7yMGQGA1UdHwRdMFswLKAqoCiGJmh0dHA6Ly9jZXJ0LnN0YXJ0 -Y29tLm9yZy9zZnNjYS1jcmwuY3JsMCugKaAnhiVodHRwOi8vY3JsLnN0YXJ0Y29tLm9yZy9zZnNj -YS1jcmwuY3JsMIIBXQYDVR0gBIIBVDCCAVAwggFMBgsrBgEEAYG1NwEBATCCATswLwYIKwYBBQUH -AgEWI2h0dHA6Ly9jZXJ0LnN0YXJ0Y29tLm9yZy9wb2xpY3kucGRmMDUGCCsGAQUFBwIBFilodHRw -Oi8vY2VydC5zdGFydGNvbS5vcmcvaW50ZXJtZWRpYXRlLnBkZjCB0AYIKwYBBQUHAgIwgcMwJxYg -U3RhcnQgQ29tbWVyY2lhbCAoU3RhcnRDb20pIEx0ZC4wAwIBARqBl0xpbWl0ZWQgTGlhYmlsaXR5 -LCByZWFkIHRoZSBzZWN0aW9uICpMZWdhbCBMaW1pdGF0aW9ucyogb2YgdGhlIFN0YXJ0Q29tIENl -cnRpZmljYXRpb24gQXV0aG9yaXR5IFBvbGljeSBhdmFpbGFibGUgYXQgaHR0cDovL2NlcnQuc3Rh -cnRjb20ub3JnL3BvbGljeS5wZGYwEQYJYIZIAYb4QgEBBAQDAgAHMDgGCWCGSAGG+EIBDQQrFilT -dGFydENvbSBGcmVlIFNTTCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTANBgkqhkiG9w0BAQUFAAOC -AgEAFmyZ9GYMNPXQhV59CuzaEE44HF7fpiUFS5Eyweg78T3dRAlbB0mKKctmArexmvclmAk8jhvh -3TaHK0u7aNM5Zj2gJsfyOZEdUauCe37Vzlrk4gNXcGmXCPleWKYK34wGmkUWFjgKXlf2Ysd6AgXm -vB618p70qSmD+LIU424oh0TDkBreOKk8rENNZEXO3SipXPJzewT4F+irsfMuXGRuczE6Eri8sxHk -fY+BUZo7jYn0TZNmezwD7dOaHZrzZVD1oNB1ny+v8OqCQ5j4aZyJecRDjkZy42Q2Eq/3JR44iZB3 -fsNrarnDy0RLrHiQi+fHLB5LEUTINFInzQpdn4XBidUaePKVEFMy3YCEZnXZtWgo+2EuvoSoOMCZ -EoalHmdkrQYuL6lwhceWD3yJZfWOQ1QOq92lgDmUYMA0yZZwLKMS9R9Ie70cfmu3nZD0Ijuu+Pwq -yvqCUqDvr0tVk+vBtfAii6w0TiYiBKGHLHVKt+V9E9e4DGTANtLJL4YSjCMJwRuCO3NJo2pXh5Tl -1njFmUNj403gdy3hZZlyaQQaRwnmDwFWJPsfvw55qVguucQJAX6Vum0ABj6y6koQOdjQK/W/7HW/ -lwLFCRsI3FU34oH7N4RDYiDK51ZLZer+bMEkkyShNOsF/5oirpt9P/FlUQqmMGqz9IgcgA38coro -g14= ------END CERTIFICATE----- - -Taiwan GRCA -=========== ------BEGIN CERTIFICATE----- -MIIFcjCCA1qgAwIBAgIQH51ZWtcvwgZEpYAIaeNe9jANBgkqhkiG9w0BAQUFADA/MQswCQYDVQQG -EwJUVzEwMC4GA1UECgwnR292ZXJubWVudCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4X -DTAyMTIwNTEzMjMzM1oXDTMyMTIwNTEzMjMzM1owPzELMAkGA1UEBhMCVFcxMDAuBgNVBAoMJ0dv -dmVybm1lbnQgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTCCAiIwDQYJKoZIhvcNAQEBBQAD -ggIPADCCAgoCggIBAJoluOzMonWoe/fOW1mKydGGEghU7Jzy50b2iPN86aXfTEc2pBsBHH8eV4qN -w8XRIePaJD9IK/ufLqGU5ywck9G/GwGHU5nOp/UKIXZ3/6m3xnOUT0b3EEk3+qhZSV1qgQdW8or5 -BtD3cCJNtLdBuTK4sfCxw5w/cP1T3YGq2GN49thTbqGsaoQkclSGxtKyyhwOeYHWtXBiCAEuTk8O -1RGvqa/lmr/czIdtJuTJV6L7lvnM4T9TjGxMfptTCAtsF/tnyMKtsc2AtJfcdgEWFelq16TheEfO -htX7MfP6Mb40qij7cEwdScevLJ1tZqa2jWR+tSBqnTuBto9AAGdLiYa4zGX+FVPpBMHWXx1E1wov -J5pGfaENda1UhhXcSTvxls4Pm6Dso3pdvtUqdULle96ltqqvKKyskKw4t9VoNSZ63Pc78/1Fm9G7 -Q3hub/FCVGqY8A2tl+lSXunVanLeavcbYBT0peS2cWeqH+riTcFCQP5nRhc4L0c/cZyu5SHKYS1t -B6iEfC3uUSXxY5Ce/eFXiGvviiNtsea9P63RPZYLhY3Naye7twWb7LuRqQoHEgKXTiCQ8P8NHuJB -O9NAOueNXdpm5AKwB1KYXA6OM5zCppX7VRluTI6uSw+9wThNXo+EHWbNxWCWtFJaBYmOlXqYwZE8 -lSOyDvR5tMl8wUohAgMBAAGjajBoMB0GA1UdDgQWBBTMzO/MKWCkO7GStjz6MmKPrCUVOzAMBgNV -HRMEBTADAQH/MDkGBGcqBwAEMTAvMC0CAQAwCQYFKw4DAhoFADAHBgVnKgMAAAQUA5vwIhP/lSg2 -09yewDL7MTqKUWUwDQYJKoZIhvcNAQEFBQADggIBAECASvomyc5eMN1PhnR2WPWus4MzeKR6dBcZ -TulStbngCnRiqmjKeKBMmo4sIy7VahIkv9Ro04rQ2JyftB8M3jh+Vzj8jeJPXgyfqzvS/3WXy6Tj -Zwj/5cAWtUgBfen5Cv8b5Wppv3ghqMKnI6mGq3ZW6A4M9hPdKmaKZEk9GhiHkASfQlK3T8v+R0F2 -Ne//AHY2RTKbxkaFXeIksB7jSJaYV0eUVXoPQbFEJPPB/hprv4j9wabak2BegUqZIJxIZhm1AHlU -D7gsL0u8qV1bYH+Mh6XgUmMqvtg7hUAV/h62ZT/FS9p+tXo1KaMuephgIqP0fSdOLeq0dDzpD6Qz -DxARvBMB1uUO07+1EqLhRSPAzAhuYbeJq4PjJB7mXQfnHyA+z2fI56wwbSdLaG5LKlwCCDTb+Hbk -Z6MmnD+iMsJKxYEYMRBWqoTvLQr/uB930r+lWKBi5NdLkXWNiYCYfm3LU05er/ayl4WXudpVBrkk -7tfGOB5jGxI7leFYrPLfhNVfmS8NVVvmONsuP3LpSIXLuykTjx44VbnzssQwmSNOXfJIoRIM3BKQ -CZBUkQM8R+XVyWXgt0t97EfTsws+rZ7QdAAO671RrcDeLMDDav7v3Aun+kbfYNucpllQdSNpc5Oy -+fwC00fmcc4QAu4njIT/rEUNE1yDMuAlpYYsfPQS ------END CERTIFICATE----- - -Swisscom Root CA 1 -================== ------BEGIN CERTIFICATE----- -MIIF2TCCA8GgAwIBAgIQXAuFXAvnWUHfV8w/f52oNjANBgkqhkiG9w0BAQUFADBkMQswCQYDVQQG -EwJjaDERMA8GA1UEChMIU3dpc3Njb20xJTAjBgNVBAsTHERpZ2l0YWwgQ2VydGlmaWNhdGUgU2Vy -dmljZXMxGzAZBgNVBAMTElN3aXNzY29tIFJvb3QgQ0EgMTAeFw0wNTA4MTgxMjA2MjBaFw0yNTA4 -MTgyMjA2MjBaMGQxCzAJBgNVBAYTAmNoMREwDwYDVQQKEwhTd2lzc2NvbTElMCMGA1UECxMcRGln -aXRhbCBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczEbMBkGA1UEAxMSU3dpc3Njb20gUm9vdCBDQSAxMIIC -IjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0LmwqAzZuz8h+BvVM5OAFmUgdbI9m2BtRsiM -MW8Xw/qabFbtPMWRV8PNq5ZJkCoZSx6jbVfd8StiKHVFXqrWW/oLJdihFvkcxC7mlSpnzNApbjyF -NDhhSbEAn9Y6cV9Nbc5fuankiX9qUvrKm/LcqfmdmUc/TilftKaNXXsLmREDA/7n29uj/x2lzZAe -AR81sH8A25Bvxn570e56eqeqDFdvpG3FEzuwpdntMhy0XmeLVNxzh+XTF3xmUHJd1BpYwdnP2IkC -b6dJtDZd0KTeByy2dbcokdaXvij1mB7qWybJvbCXc9qukSbraMH5ORXWZ0sKbU/Lz7DkQnGMU3nn -7uHbHaBuHYwadzVcFh4rUx80i9Fs/PJnB3r1re3WmquhsUvhzDdf/X/NTa64H5xD+SpYVUNFvJbN -cA78yeNmuk6NO4HLFWR7uZToXTNShXEuT46iBhFRyePLoW4xCGQMwtI89Tbo19AOeCMgkckkKmUp -WyL3Ic6DXqTz3kvTaI9GdVyDCW4pa8RwjPWd1yAv/0bSKzjCL3UcPX7ape8eYIVpQtPM+GP+HkM5 -haa2Y0EQs3MevNP6yn0WR+Kn1dCjigoIlmJWbjTb2QK5MHXjBNLnj8KwEUAKrNVxAmKLMb7dxiNY -MUJDLXT5xp6mig/p/r+D5kNXJLrvRjSq1xIBOO0CAwEAAaOBhjCBgzAOBgNVHQ8BAf8EBAMCAYYw -HQYDVR0hBBYwFDASBgdghXQBUwABBgdghXQBUwABMBIGA1UdEwEB/wQIMAYBAf8CAQcwHwYDVR0j -BBgwFoAUAyUv3m+CATpcLNwroWm1Z9SM0/0wHQYDVR0OBBYEFAMlL95vggE6XCzcK6FptWfUjNP9 -MA0GCSqGSIb3DQEBBQUAA4ICAQA1EMvspgQNDQ/NwNurqPKIlwzfky9NfEBWMXrrpA9gzXrzvsMn -jgM+pN0S734edAY8PzHyHHuRMSG08NBsl9Tpl7IkVh5WwzW9iAUPWxAaZOHHgjD5Mq2eUCzneAXQ -MbFamIp1TpBcahQq4FJHgmDmHtqBsfsUC1rxn9KVuj7QG9YVHaO+htXbD8BJZLsuUBlL0iT43R4H -VtA4oJVwIHaM190e3p9xxCPvgxNcoyQVTSlAPGrEqdi3pkSlDfTgnXceQHAm/NrZNuR55LU/vJtl -vrsRls/bxig5OgjOR1tTWsWZ/l2p3e9M1MalrQLmjAcSHm8D0W+go/MpvRLHUKKwf4ipmXeascCl -OS5cfGniLLDqN2qk4Vrh9VDlg++luyqI54zb/W1elxmofmZ1a3Hqv7HHb6D0jqTsNFFbjCYDcKF3 -1QESVwA12yPeDooomf2xEG9L/zgtYE4snOtnta1J7ksfrK/7DZBaZmBwXarNeNQk7shBoJMBkpxq -nvy5JMWzFYJ+vq6VK+uxwNrjAWALXmmshFZhvnEX/h0TD/7Gh0Xp/jKgGg0TpJRVcaUWi7rKibCy -x/yP2FS1k2Kdzs9Z+z0YzirLNRWCXf9UIltxUvu3yf5gmwBBZPCqKuy2QkPOiWaByIufOVQDJdMW -NY6E0F/6MBr1mmz0DlP5OlvRHA== ------END CERTIFICATE----- - -DigiCert Assured ID Root CA -=========================== ------BEGIN CERTIFICATE----- -MIIDtzCCAp+gAwIBAgIQDOfg5RfYRv6P5WD8G/AwOTANBgkqhkiG9w0BAQUFADBlMQswCQYDVQQG -EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQw -IgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwHhcNMDYxMTEwMDAwMDAwWhcNMzEx -MTEwMDAwMDAwWjBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQL -ExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0Ew -ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtDhXO5EOAXLGH87dg+XESpa7cJpSIqvTO -9SA5KFhgDPiA2qkVlTJhPLWxKISKityfCgyDF3qPkKyK53lTXDGEKvYPmDI2dsze3Tyoou9q+yHy -UmHfnyDXH+Kx2f4YZNISW1/5WBg1vEfNoTb5a3/UsDg+wRvDjDPZ2C8Y/igPs6eD1sNuRMBhNZYW -/lmci3Zt1/GiSw0r/wty2p5g0I6QNcZ4VYcgoc/lbQrISXwxmDNsIumH0DJaoroTghHtORedmTpy -oeb6pNnVFzF1roV9Iq4/AUaG9ih5yLHa5FcXxH4cDrC0kqZWs72yl+2qp/C3xag/lRbQ/6GW6whf -GHdPAgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRF -66Kv9JLLgjEtUYunpyGd823IDzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzANBgkq -hkiG9w0BAQUFAAOCAQEAog683+Lt8ONyc3pklL/3cmbYMuRCdWKuh+vy1dneVrOfzM4UKLkNl2Bc -EkxY5NM9g0lFWJc1aRqoR+pWxnmrEthngYTffwk8lOa4JiwgvT2zKIn3X/8i4peEH+ll74fg38Fn -SbNd67IJKusm7Xi+fT8r87cmNW1fiQG2SVufAQWbqz0lwcy2f8Lxb4bG+mRo64EtlOtCt/qMHt1i -8b5QZ7dsvfPxH2sMNgcWfzd8qVttevESRmCD1ycEvkvOl77DZypoEd+A5wwzZr8TDRRu838fYxAe -+o0bJW1sj6W3YQGx0qMmoRBxna3iw/nDmVG3KwcIzi7mULKn+gpFL6Lw8g== ------END CERTIFICATE----- - -DigiCert Global Root CA -======================= ------BEGIN CERTIFICATE----- -MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBhMQswCQYDVQQG -EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAw -HgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBDQTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAw -MDAwMDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3 -dy5kaWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkq -hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsBCSDMAZOn -TjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97nh6Vfe63SKMI2tavegw5 -BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt43C/dxC//AH2hdmoRBBYMql1GNXRor5H -4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7PT19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y -7vrTC0LUq7dBMtoM1O/4gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQAB -o2MwYTAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbRTLtm -8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUwDQYJKoZIhvcNAQEF -BQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/EsrhMAtudXH/vTBH1jLuG2cenTnmCmr -EbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIt -tep3Sp+dWOIrWcBAI+0tKIJFPnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886 -UAb3LujEV0lsYSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk -CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= ------END CERTIFICATE----- - -DigiCert High Assurance EV Root CA -================================== ------BEGIN CERTIFICATE----- -MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBsMQswCQYDVQQG -EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSsw -KQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5jZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAw -MFoXDTMxMTExMDAwMDAwMFowbDELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZ -MBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFu -Y2UgRVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm+9S75S0t -Mqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTWPNt0OKRKzE0lgvdKpVMS -OO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEMxChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3 -MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFBIk5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQ -NAQTXKFx01p8VdteZOE3hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUe -h10aUAsgEsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMB -Af8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaAFLE+w2kD+L9HAdSY -JhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3NecnzyIZgYIVyHbIUf4KmeqvxgydkAQ -V8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6zeM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFp -myPInngiK3BD41VHMWEZ71jFhS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkK -mNEVX58Svnw2Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe -vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep+OkuE6N36B9K ------END CERTIFICATE----- - -Certplus Class 2 Primary CA -=========================== ------BEGIN CERTIFICATE----- -MIIDkjCCAnqgAwIBAgIRAIW9S/PY2uNp9pTXX8OlRCMwDQYJKoZIhvcNAQEFBQAwPTELMAkGA1UE -BhMCRlIxETAPBgNVBAoTCENlcnRwbHVzMRswGQYDVQQDExJDbGFzcyAyIFByaW1hcnkgQ0EwHhcN -OTkwNzA3MTcwNTAwWhcNMTkwNzA2MjM1OTU5WjA9MQswCQYDVQQGEwJGUjERMA8GA1UEChMIQ2Vy -dHBsdXMxGzAZBgNVBAMTEkNsYXNzIDIgUHJpbWFyeSBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEP -ADCCAQoCggEBANxQltAS+DXSCHh6tlJw/W/uz7kRy1134ezpfgSN1sxvc0NXYKwzCkTsA18cgCSR -5aiRVhKC9+Ar9NuuYS6JEI1rbLqzAr3VNsVINyPi8Fo3UjMXEuLRYE2+L0ER4/YXJQyLkcAbmXuZ -Vg2v7tK8R1fjeUl7NIknJITesezpWE7+Tt9avkGtrAjFGA7v0lPubNCdEgETjdyAYveVqUSISnFO -YFWe2yMZeVYHDD9jC1yw4r5+FfyUM1hBOHTE4Y+L3yasH7WLO7dDWWuwJKZtkIvEcupdM5i3y95e -e++U8Rs+yskhwcWYAqqi9lt3m/V+llU0HGdpwPFC40es/CgcZlUCAwEAAaOBjDCBiTAPBgNVHRME -CDAGAQH/AgEKMAsGA1UdDwQEAwIBBjAdBgNVHQ4EFgQU43Mt38sOKAze3bOkynm4jrvoMIkwEQYJ -YIZIAYb4QgEBBAQDAgEGMDcGA1UdHwQwMC4wLKAqoCiGJmh0dHA6Ly93d3cuY2VydHBsdXMuY29t -L0NSTC9jbGFzczIuY3JsMA0GCSqGSIb3DQEBBQUAA4IBAQCnVM+IRBnL39R/AN9WM2K191EBkOvD -P9GIROkkXe/nFL0gt5o8AP5tn9uQ3Nf0YtaLcF3n5QRIqWh8yfFC82x/xXp8HVGIutIKPidd3i1R -TtMTZGnkLuPT55sJmabglZvOGtd/vjzOUrMRFcEPF80Du5wlFbqidon8BvEY0JNLDnyCt6X09l/+ -7UCmnYR0ObncHoUW2ikbhiMAybuJfm6AiB4vFLQDJKgybwOaRywwvlbGp0ICcBvqQNi6BQNwB6SW -//1IMwrh3KWBkJtN3X3n57LNXMhqlfil9o3EXXgIvnsG1knPGTZQIy4I5p4FTUcY1Rbpsda2ENW7 -l7+ijrRU ------END CERTIFICATE----- - -DST Root CA X3 -============== ------BEGIN CERTIFICATE----- -MIIDSjCCAjKgAwIBAgIQRK+wgNajJ7qJMDmGLvhAazANBgkqhkiG9w0BAQUFADA/MSQwIgYDVQQK -ExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMTDkRTVCBSb290IENBIFgzMB4X -DTAwMDkzMDIxMTIxOVoXDTIxMDkzMDE0MDExNVowPzEkMCIGA1UEChMbRGlnaXRhbCBTaWduYXR1 -cmUgVHJ1c3QgQ28uMRcwFQYDVQQDEw5EU1QgUm9vdCBDQSBYMzCCASIwDQYJKoZIhvcNAQEBBQAD -ggEPADCCAQoCggEBAN+v6ZdQCINXtMxiZfaQguzH0yxrMMpb7NnDfcdAwRgUi+DoM3ZJKuM/IUmT -rE4Orz5Iy2Xu/NMhD2XSKtkyj4zl93ewEnu1lcCJo6m67XMuegwGMoOifooUMM0RoOEqOLl5CjH9 -UL2AZd+3UWODyOKIYepLYYHsUmu5ouJLGiifSKOeDNoJjj4XLh7dIN9bxiqKqy69cK3FCxolkHRy -xXtqqzTWMIn/5WgTe1QLyNau7Fqckh49ZLOMxt+/yUFw7BZy1SbsOFU5Q9D8/RhcQPGX69Wam40d -utolucbY38EVAjqr2m7xPi71XAicPNaDaeQQmxkqtilX4+U9m5/wAl0CAwEAAaNCMEAwDwYDVR0T -AQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMSnsaR7LHH62+FLkHX/xBVghYkQ -MA0GCSqGSIb3DQEBBQUAA4IBAQCjGiybFwBcqR7uKGY3Or+Dxz9LwwmglSBd49lZRNI+DT69ikug -dB/OEIKcdBodfpga3csTS7MgROSR6cz8faXbauX+5v3gTt23ADq1cEmv8uXrAvHRAosZy5Q6XkjE -GB5YGV8eAlrwDPGxrancWYaLbumR9YbK+rlmM6pZW87ipxZzR8srzJmwN0jP41ZL9c8PDHIyh8bw -RLtTcm1D9SZImlJnt1ir/md2cXjbDaJWFBM5JDGFoqgCWjBH4d1QB7wCCZAA62RjYJsWvIjJEubS -fZGL+T0yjWW06XyxV3bqxbYoOb8VZRzI9neWagqNdwvYkQsEjgfbKbYK7p2CNTUQ ------END CERTIFICATE----- - -DST ACES CA X6 -============== ------BEGIN CERTIFICATE----- -MIIECTCCAvGgAwIBAgIQDV6ZCtadt3js2AdWO4YV2TANBgkqhkiG9w0BAQUFADBbMQswCQYDVQQG -EwJVUzEgMB4GA1UEChMXRGlnaXRhbCBTaWduYXR1cmUgVHJ1c3QxETAPBgNVBAsTCERTVCBBQ0VT -MRcwFQYDVQQDEw5EU1QgQUNFUyBDQSBYNjAeFw0wMzExMjAyMTE5NThaFw0xNzExMjAyMTE5NTha -MFsxCzAJBgNVBAYTAlVTMSAwHgYDVQQKExdEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdDERMA8GA1UE -CxMIRFNUIEFDRVMxFzAVBgNVBAMTDkRTVCBBQ0VTIENBIFg2MIIBIjANBgkqhkiG9w0BAQEFAAOC -AQ8AMIIBCgKCAQEAuT31LMmU3HWKlV1j6IR3dma5WZFcRt2SPp/5DgO0PWGSvSMmtWPuktKe1jzI -DZBfZIGxqAgNTNj50wUoUrQBJcWVHAx+PhCEdc/BGZFjz+iokYi5Q1K7gLFViYsx+tC3dr5BPTCa -pCIlF3PoHuLTrCq9Wzgh1SpL11V94zpVvddtawJXa+ZHfAjIgrrep4c9oW24MFbCswKBXy314pow -GCi4ZtPLAZZv6opFVdbgnf9nKxcCpk4aahELfrd755jWjHZvwTvbUJN+5dCOHze4vbrGn2zpfDPy -MjwmR/onJALJfh1biEITajV8fTXpLmaRcpPVMibEdPVTo7NdmvYJywIDAQABo4HIMIHFMA8GA1Ud -EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgHGMB8GA1UdEQQYMBaBFHBraS1vcHNAdHJ1c3Rkc3Qu -Y29tMGIGA1UdIARbMFkwVwYKYIZIAWUDAgEBATBJMEcGCCsGAQUFBwIBFjtodHRwOi8vd3d3LnRy -dXN0ZHN0LmNvbS9jZXJ0aWZpY2F0ZXMvcG9saWN5L0FDRVMtaW5kZXguaHRtbDAdBgNVHQ4EFgQU -CXIGThhDD+XWzMNqizF7eI+og7gwDQYJKoZIhvcNAQEFBQADggEBAKPYjtay284F5zLNAdMEA+V2 -5FYrnJmQ6AgwbN99Pe7lv7UkQIRJ4dEorsTCOlMwiPH1d25Ryvr/ma8kXxug/fKshMrfqfBfBC6t -Fr8hlxCBPeP/h40y3JTlR4peahPJlJU90u7INJXQgNStMgiAVDzgvVJT11J8smk/f3rPanTK+gQq -nExaBqXpIK1FZg9p8d2/6eMyi/rgwYZNcjwu2JN4Cir42NInPRmJX1p7ijvMDNpRrscL9yuwNwXs -vFcj4jjSm2jzVhKIT0J8uDHEtdvkyCE06UgRNe76x5JXxZ805Mf29w4LTJxoeHtxMcfrHuBnQfO3 -oKfN5XozNmr6mis= ------END CERTIFICATE----- - -SwissSign Gold CA - G2 -====================== ------BEGIN CERTIFICATE----- -MIIFujCCA6KgAwIBAgIJALtAHEP1Xk+wMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNVBAYTAkNIMRUw -EwYDVQQKEwxTd2lzc1NpZ24gQUcxHzAdBgNVBAMTFlN3aXNzU2lnbiBHb2xkIENBIC0gRzIwHhcN -MDYxMDI1MDgzMDM1WhcNMzYxMDI1MDgzMDM1WjBFMQswCQYDVQQGEwJDSDEVMBMGA1UEChMMU3dp -c3NTaWduIEFHMR8wHQYDVQQDExZTd2lzc1NpZ24gR29sZCBDQSAtIEcyMIICIjANBgkqhkiG9w0B -AQEFAAOCAg8AMIICCgKCAgEAr+TufoskDhJuqVAtFkQ7kpJcyrhdhJJCEyq8ZVeCQD5XJM1QiyUq -t2/876LQwB8CJEoTlo8jE+YoWACjR8cGp4QjK7u9lit/VcyLwVcfDmJlD909Vopz2q5+bbqBHH5C -jCA12UNNhPqE21Is8w4ndwtrvxEvcnifLtg+5hg3Wipy+dpikJKVyh+c6bM8K8vzARO/Ws/BtQpg -vd21mWRTuKCWs2/iJneRjOBiEAKfNA+k1ZIzUd6+jbqEemA8atufK+ze3gE/bk3lUIbLtK/tREDF -ylqM2tIrfKjuvqblCqoOpd8FUrdVxyJdMmqXl2MT28nbeTZ7hTpKxVKJ+STnnXepgv9VHKVxaSvR -AiTysybUa9oEVeXBCsdtMDeQKuSeFDNeFhdVxVu1yzSJkvGdJo+hB9TGsnhQ2wwMC3wLjEHXuend -jIj3o02yMszYF9rNt85mndT9Xv+9lz4pded+p2JYryU0pUHHPbwNUMoDAw8IWh+Vc3hiv69yFGkO -peUDDniOJihC8AcLYiAQZzlG+qkDzAQ4embvIIO1jEpWjpEA/I5cgt6IoMPiaG59je883WX0XaxR -7ySArqpWl2/5rX3aYT+YdzylkbYcjCbaZaIJbcHiVOO5ykxMgI93e2CaHt+28kgeDrpOVG2Y4OGi -GqJ3UM/EY5LsRxmd6+ZrzsECAwEAAaOBrDCBqTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUw -AwEB/zAdBgNVHQ4EFgQUWyV7lqRlUX64OfPAeGZe6Drn8O4wHwYDVR0jBBgwFoAUWyV7lqRlUX64 -OfPAeGZe6Drn8O4wRgYDVR0gBD8wPTA7BglghXQBWQECAQEwLjAsBggrBgEFBQcCARYgaHR0cDov -L3JlcG9zaXRvcnkuc3dpc3NzaWduLmNvbS8wDQYJKoZIhvcNAQEFBQADggIBACe645R88a7A3hfm -5djV9VSwg/S7zV4Fe0+fdWavPOhWfvxyeDgD2StiGwC5+OlgzczOUYrHUDFu4Up+GC9pWbY9ZIEr -44OE5iKHjn3g7gKZYbge9LgriBIWhMIxkziWMaa5O1M/wySTVltpkuzFwbs4AOPsF6m43Md8AYOf -Mke6UiI0HTJ6CVanfCU2qT1L2sCCbwq7EsiHSycR+R4tx5M/nttfJmtS2S6K8RTGRI0Vqbe/vd6m -Gu6uLftIdxf+u+yvGPUqUfA5hJeVbG4bwyvEdGB5JbAKJ9/fXtI5z0V9QkvfsywexcZdylU6oJxp -mo/a77KwPJ+HbBIrZXAVUjEaJM9vMSNQH4xPjyPDdEFjHFWoFN0+4FFQz/EbMFYOkrCChdiDyyJk -vC24JdVUorgG6q2SpCSgwYa1ShNqR88uC1aVVMvOmttqtKay20EIhid392qgQmwLOM7XdVAyksLf -KzAiSNDVQTglXaTpXZ/GlHXQRf0wl0OPkKsKx4ZzYEppLd6leNcG2mqeSz53OiATIgHQv2ieY2Br -NU0LbbqhPcCT4H8js1WtciVORvnSFu+wZMEBnunKoGqYDs/YYPIvSbjkQuE4NRb0yG5P94FW6Lqj -viOvrv1vA+ACOzB2+httQc8Bsem4yWb02ybzOqR08kkkW8mw0FfB+j564ZfJ ------END CERTIFICATE----- - -SwissSign Silver CA - G2 -======================== ------BEGIN CERTIFICATE----- -MIIFvTCCA6WgAwIBAgIITxvUL1S7L0swDQYJKoZIhvcNAQEFBQAwRzELMAkGA1UEBhMCQ0gxFTAT -BgNVBAoTDFN3aXNzU2lnbiBBRzEhMB8GA1UEAxMYU3dpc3NTaWduIFNpbHZlciBDQSAtIEcyMB4X -DTA2MTAyNTA4MzI0NloXDTM2MTAyNTA4MzI0NlowRzELMAkGA1UEBhMCQ0gxFTATBgNVBAoTDFN3 -aXNzU2lnbiBBRzEhMB8GA1UEAxMYU3dpc3NTaWduIFNpbHZlciBDQSAtIEcyMIICIjANBgkqhkiG -9w0BAQEFAAOCAg8AMIICCgKCAgEAxPGHf9N4Mfc4yfjDmUO8x/e8N+dOcbpLj6VzHVxumK4DV644 -N0MvFz0fyM5oEMF4rhkDKxD6LHmD9ui5aLlV8gREpzn5/ASLHvGiTSf5YXu6t+WiE7brYT7QbNHm -+/pe7R20nqA1W6GSy/BJkv6FCgU+5tkL4k+73JU3/JHpMjUi0R86TieFnbAVlDLaYQ1HTWBCrpJH -6INaUFjpiou5XaHc3ZlKHzZnu0jkg7Y360g6rw9njxcH6ATK72oxh9TAtvmUcXtnZLi2kUpCe2Uu -MGoM9ZDulebyzYLs2aFK7PayS+VFheZteJMELpyCbTapxDFkH4aDCyr0NQp4yVXPQbBH6TCfmb5h -qAaEuSh6XzjZG6k4sIN/c8HDO0gqgg8hm7jMqDXDhBuDsz6+pJVpATqJAHgE2cn0mRmrVn5bi4Y5 -FZGkECwJMoBgs5PAKrYYC51+jUnyEEp/+dVGLxmSo5mnJqy7jDzmDrxHB9xzUfFwZC8I+bRHHTBs -ROopN4WSaGa8gzj+ezku01DwH/teYLappvonQfGbGHLy9YR0SslnxFSuSGTfjNFusB3hB48IHpmc -celM2KX3RxIfdNFRnobzwqIjQAtz20um53MGjMGg6cFZrEb65i/4z3GcRm25xBWNOHkDRUjvxF3X -CO6HOSKGsg0PWEP3calILv3q1h8CAwEAAaOBrDCBqTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/ -BAUwAwEB/zAdBgNVHQ4EFgQUF6DNweRBtjpbO8tFnb0cwpj6hlgwHwYDVR0jBBgwFoAUF6DNweRB -tjpbO8tFnb0cwpj6hlgwRgYDVR0gBD8wPTA7BglghXQBWQEDAQEwLjAsBggrBgEFBQcCARYgaHR0 -cDovL3JlcG9zaXRvcnkuc3dpc3NzaWduLmNvbS8wDQYJKoZIhvcNAQEFBQADggIBAHPGgeAn0i0P -4JUw4ppBf1AsX19iYamGamkYDHRJ1l2E6kFSGG9YrVBWIGrGvShpWJHckRE1qTodvBqlYJ7YH39F -kWnZfrt4csEGDyrOj4VwYaygzQu4OSlWhDJOhrs9xCrZ1x9y7v5RoSJBsXECYxqCsGKrXlcSH9/L -3XWgwF15kIwb4FDm3jH+mHtwX6WQ2K34ArZv02DdQEsixT2tOnqfGhpHkXkzuoLcMmkDlm4fS/Bx -/uNncqCxv1yL5PqZIseEuRuNI5c/7SXgz2W79WEE790eslpBIlqhn10s6FvJbakMDHiqYMZWjwFa -DGi8aRl5xB9+lwW/xekkUV7U1UtT7dkjWjYDZaPBA61BMPNGG4WQr2W11bHkFlt4dR2Xem1ZqSqP -e97Dh4kQmUlzeMg9vVE1dCrV8X5pGyq7O70luJpaPXJhkGaH7gzWTdQRdAtq/gsD/KNVV4n+Ssuu -WxcFyPKNIzFTONItaj+CuY0IavdeQXRuwxF+B6wpYJE/OMpXEA29MC/HpeZBoNquBYeaoKRlbEwJ -DIm6uNO5wJOKMPqN5ZprFQFOZ6raYlY+hAhm0sQ2fac+EPyI4NSA5QC9qvNOBqN6avlicuMJT+ub -DgEj8Z+7fNzcbBGXJbLytGMU0gYqZ4yD9c7qB9iaah7s5Aq7KkzrCWA5zspi2C5u ------END CERTIFICATE----- - -GeoTrust Primary Certification Authority -======================================== ------BEGIN CERTIFICATE----- -MIIDfDCCAmSgAwIBAgIQGKy1av1pthU6Y2yv2vrEoTANBgkqhkiG9w0BAQUFADBYMQswCQYDVQQG -EwJVUzEWMBQGA1UEChMNR2VvVHJ1c3QgSW5jLjExMC8GA1UEAxMoR2VvVHJ1c3QgUHJpbWFyeSBD -ZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0wNjExMjcwMDAwMDBaFw0zNjA3MTYyMzU5NTlaMFgx -CzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMTEwLwYDVQQDEyhHZW9UcnVzdCBQ -cmltYXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB -CgKCAQEAvrgVe//UfH1nrYNke8hCUy3f9oQIIGHWAVlqnEQRr+92/ZV+zmEwu3qDXwK9AWbK7hWN -b6EwnL2hhZ6UOvNWiAAxz9juapYC2e0DjPt1befquFUWBRaa9OBesYjAZIVcFU2Ix7e64HXprQU9 -nceJSOC7KMgD4TCTZF5SwFlwIjVXiIrxlQqD17wxcwE07e9GceBrAqg1cmuXm2bgyxx5X9gaBGge -RwLmnWDiNpcB3841kt++Z8dtd1k7j53WkBWUvEI0EME5+bEnPn7WinXFsq+W06Lem+SYvn3h6YGt -tm/81w7a4DSwDRp35+MImO9Y+pyEtzavwt+s0vQQBnBxNQIDAQABo0IwQDAPBgNVHRMBAf8EBTAD -AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQULNVQQZcVi/CPNmFbSvtr2ZnJM5IwDQYJKoZI -hvcNAQEFBQADggEBAFpwfyzdtzRP9YZRqSa+S7iq8XEN3GHHoOo0Hnp3DwQ16CePbJC/kRYkRj5K -Ts4rFtULUh38H2eiAkUxT87z+gOneZ1TatnaYzr4gNfTmeGl4b7UVXGYNTq+k+qurUKykG/g/CFN -NWMziUnWm07Kx+dOCQD32sfvmWKZd7aVIl6KoKv0uHiYyjgZmclynnjNS6yvGaBzEi38wkG6gZHa -Floxt/m0cYASSJlyc1pZU8FjUjPtp8nSOQJw+uCxQmYpqptR7TBUIhRf2asdweSU8Pj1K/fqynhG -1riR/aYNKxoUAT6A8EKglQdebc3MS6RFjasS6LPeWuWgfOgPIh1a6Vk= ------END CERTIFICATE----- - -thawte Primary Root CA -====================== ------BEGIN CERTIFICATE----- -MIIEIDCCAwigAwIBAgIQNE7VVyDV7exJ9C/ON9srbTANBgkqhkiG9w0BAQUFADCBqTELMAkGA1UE -BhMCVVMxFTATBgNVBAoTDHRoYXd0ZSwgSW5jLjEoMCYGA1UECxMfQ2VydGlmaWNhdGlvbiBTZXJ2 -aWNlcyBEaXZpc2lvbjE4MDYGA1UECxMvKGMpIDIwMDYgdGhhd3RlLCBJbmMuIC0gRm9yIGF1dGhv -cml6ZWQgdXNlIG9ubHkxHzAdBgNVBAMTFnRoYXd0ZSBQcmltYXJ5IFJvb3QgQ0EwHhcNMDYxMTE3 -MDAwMDAwWhcNMzYwNzE2MjM1OTU5WjCBqTELMAkGA1UEBhMCVVMxFTATBgNVBAoTDHRoYXd0ZSwg -SW5jLjEoMCYGA1UECxMfQ2VydGlmaWNhdGlvbiBTZXJ2aWNlcyBEaXZpc2lvbjE4MDYGA1UECxMv -KGMpIDIwMDYgdGhhd3RlLCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxHzAdBgNVBAMT -FnRoYXd0ZSBQcmltYXJ5IFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCs -oPD7gFnUnMekz52hWXMJEEUMDSxuaPFsW0hoSVk3/AszGcJ3f8wQLZU0HObrTQmnHNK4yZc2AreJ -1CRfBsDMRJSUjQJib+ta3RGNKJpchJAQeg29dGYvajig4tVUROsdB58Hum/u6f1OCyn1PoSgAfGc -q/gcfomk6KHYcWUNo1F77rzSImANuVud37r8UVsLr5iy6S7pBOhih94ryNdOwUxkHt3Ph1i6Sk/K -aAcdHJ1KxtUvkcx8cXIcxcBn6zL9yZJclNqFwJu/U30rCfSMnZEfl2pSy94JNqR32HuHUETVPm4p -afs5SSYeCaWAe0At6+gnhcn+Yf1+5nyXHdWdAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYD -VR0PAQH/BAQDAgEGMB0GA1UdDgQWBBR7W0XPr87Lev0xkhpqtvNG61dIUDANBgkqhkiG9w0BAQUF -AAOCAQEAeRHAS7ORtvzw6WfUDW5FvlXok9LOAz/t2iWwHVfLHjp2oEzsUHboZHIMpKnxuIvW1oeE -uzLlQRHAd9mzYJ3rG9XRbkREqaYB7FViHXe4XI5ISXycO1cRrK1zN44veFyQaEfZYGDm/Ac9IiAX -xPcW6cTYcvnIc3zfFi8VqT79aie2oetaupgf1eNNZAqdE8hhuvU5HIe6uL17In/2/qxAeeWsEG89 -jxt5dovEN7MhGITlNgDrYyCZuen+MwS7QcjBAvlEYyCegc5C09Y/LHbTY5xZ3Y+m4Q6gLkH3LpVH -z7z9M/P2C2F+fpErgUfCJzDupxBdN49cOSvkBPB7jVaMaA== ------END CERTIFICATE----- - -VeriSign Class 3 Public Primary Certification Authority - G5 -============================================================ ------BEGIN CERTIFICATE----- -MIIE0zCCA7ugAwIBAgIQGNrRniZ96LtKIVjNzGs7SjANBgkqhkiG9w0BAQUFADCByjELMAkGA1UE -BhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQLExZWZXJpU2lnbiBUcnVzdCBO -ZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwNiBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRob3JpemVk -IHVzZSBvbmx5MUUwQwYDVQQDEzxWZXJpU2lnbiBDbGFzcyAzIFB1YmxpYyBQcmltYXJ5IENlcnRp -ZmljYXRpb24gQXV0aG9yaXR5IC0gRzUwHhcNMDYxMTA4MDAwMDAwWhcNMzYwNzE2MjM1OTU5WjCB -yjELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQLExZWZXJpU2ln -biBUcnVzdCBOZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwNiBWZXJpU2lnbiwgSW5jLiAtIEZvciBh -dXRob3JpemVkIHVzZSBvbmx5MUUwQwYDVQQDEzxWZXJpU2lnbiBDbGFzcyAzIFB1YmxpYyBQcmlt -YXJ5IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw -ggEKAoIBAQCvJAgIKXo1nmAMqudLO07cfLw8RRy7K+D+KQL5VwijZIUVJ/XxrcgxiV0i6CqqpkKz -j/i5Vbext0uz/o9+B1fs70PbZmIVYc9gDaTY3vjgw2IIPVQT60nKWVSFJuUrjxuf6/WhkcIzSdhD -Y2pSS9KP6HBRTdGJaXvHcPaz3BJ023tdS1bTlr8Vd6Gw9KIl8q8ckmcY5fQGBO+QueQA5N06tRn/ -Arr0PO7gi+s3i+z016zy9vA9r911kTMZHRxAy3QkGSGT2RT+rCpSx4/VBEnkjWNHiDxpg8v+R70r -fk/Fla4OndTRQ8Bnc+MUCH7lP59zuDMKz10/NIeWiu5T6CUVAgMBAAGjgbIwga8wDwYDVR0TAQH/ -BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwbQYIKwYBBQUHAQwEYTBfoV2gWzBZMFcwVRYJaW1hZ2Uv -Z2lmMCEwHzAHBgUrDgMCGgQUj+XTGoasjY5rw8+AatRIGCx7GS4wJRYjaHR0cDovL2xvZ28udmVy -aXNpZ24uY29tL3ZzbG9nby5naWYwHQYDVR0OBBYEFH/TZafC3ey78DAJ80M5+gKvMzEzMA0GCSqG -SIb3DQEBBQUAA4IBAQCTJEowX2LP2BqYLz3q3JktvXf2pXkiOOzEp6B4Eq1iDkVwZMXnl2YtmAl+ -X6/WzChl8gGqCBpH3vn5fJJaCGkgDdk+bW48DW7Y5gaRQBi5+MHt39tBquCWIMnNZBU4gcmU7qKE -KQsTb47bDN0lAtukixlE0kF6BWlKWE9gyn6CagsCqiUXObXbf+eEZSqVir2G3l6BFoMtEMze/aiC -Km0oHw0LxOXnGiYZ4fQRbxC1lfznQgUy286dUV4otp6F01vvpX1FQHKOtw5rDgb7MzVIcbidJ4vE -ZV8NhnacRHr2lVz2XTIIM6RUthg/aFzyQkqFOFSDX9HoLPKsEdao7WNq ------END CERTIFICATE----- - -SecureTrust CA -============== ------BEGIN CERTIFICATE----- -MIIDuDCCAqCgAwIBAgIQDPCOXAgWpa1Cf/DrJxhZ0DANBgkqhkiG9w0BAQUFADBIMQswCQYDVQQG -EwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xFzAVBgNVBAMTDlNlY3VyZVRy -dXN0IENBMB4XDTA2MTEwNzE5MzExOFoXDTI5MTIzMTE5NDA1NVowSDELMAkGA1UEBhMCVVMxIDAe -BgNVBAoTF1NlY3VyZVRydXN0IENvcnBvcmF0aW9uMRcwFQYDVQQDEw5TZWN1cmVUcnVzdCBDQTCC -ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKukgeWVzfX2FI7CT8rU4niVWJxB4Q2ZQCQX -OZEzZum+4YOvYlyJ0fwkW2Gz4BERQRwdbvC4u/jep4G6pkjGnx29vo6pQT64lO0pGtSO0gMdA+9t -DWccV9cGrcrI9f4Or2YlSASWC12juhbDCE/RRvgUXPLIXgGZbf2IzIaowW8xQmxSPmjL8xk037uH -GFaAJsTQ3MBv396gwpEWoGQRS0S8Hvbn+mPeZqx2pHGj7DaUaHp3pLHnDi+BeuK1cobvomuL8A/b -01k/unK8RCSc43Oz969XL0Imnal0ugBS8kvNU3xHCzaFDmapCJcWNFfBZveA4+1wVMeT4C4oFVmH -ursCAwEAAaOBnTCBmjATBgkrBgEEAYI3FAIEBh4EAEMAQTALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/ -BAUwAwEB/zAdBgNVHQ4EFgQUQjK2FvoE/f5dS3rD/fdMQB1aQ68wNAYDVR0fBC0wKzApoCegJYYj -aHR0cDovL2NybC5zZWN1cmV0cnVzdC5jb20vU1RDQS5jcmwwEAYJKwYBBAGCNxUBBAMCAQAwDQYJ -KoZIhvcNAQEFBQADggEBADDtT0rhWDpSclu1pqNlGKa7UTt36Z3q059c4EVlew3KW+JwULKUBRSu -SceNQQcSc5R+DCMh/bwQf2AQWnL1mA6s7Ll/3XpvXdMc9P+IBWlCqQVxyLesJugutIxq/3HcuLHf -mbx8IVQr5Fiiu1cprp6poxkmD5kuCLDv/WnPmRoJjeOnnyvJNjR7JLN4TJUXpAYmHrZkUjZfYGfZ -nMUFdAvnZyPSCPyI6a6Lf+Ew9Dd+/cYy2i2eRDAwbO4H3tI0/NL/QPZL9GZGBlSm8jIKYyYwa5vR -3ItHuuG51WLQoqD0ZwV4KWMabwTW+MZMo5qxN7SN5ShLHZ4swrhovO0C7jE= ------END CERTIFICATE----- - -Secure Global CA -================ ------BEGIN CERTIFICATE----- -MIIDvDCCAqSgAwIBAgIQB1YipOjUiolN9BPI8PjqpTANBgkqhkiG9w0BAQUFADBKMQswCQYDVQQG -EwJVUzEgMB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBH -bG9iYWwgQ0EwHhcNMDYxMTA3MTk0MjI4WhcNMjkxMjMxMTk1MjA2WjBKMQswCQYDVQQGEwJVUzEg -MB4GA1UEChMXU2VjdXJlVHJ1c3QgQ29ycG9yYXRpb24xGTAXBgNVBAMTEFNlY3VyZSBHbG9iYWwg -Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCvNS7YrGxVaQZx5RNoJLNP2MwhR/jx -YDiJiQPpvepeRlMJ3Fz1Wuj3RSoC6zFh1ykzTM7HfAo3fg+6MpjhHZevj8fcyTiW89sa/FHtaMbQ -bqR8JNGuQsiWUGMu4P51/pinX0kuleM5M2SOHqRfkNJnPLLZ/kG5VacJjnIFHovdRIWCQtBJwB1g -8NEXLJXr9qXBkqPFwqcIYA1gBBCWeZ4WNOaptvolRTnIHmX5k/Wq8VLcmZg9pYYaDDUz+kulBAYV -HDGA76oYa8J719rO+TMg1fW9ajMtgQT7sFzUnKPiXB3jqUJ1XnvUd+85VLrJChgbEplJL4hL/VBi -0XPnj3pDAgMBAAGjgZ0wgZowEwYJKwYBBAGCNxQCBAYeBABDAEEwCwYDVR0PBAQDAgGGMA8GA1Ud -EwEB/wQFMAMBAf8wHQYDVR0OBBYEFK9EBMJBfkiD2045AuzshHrmzsmkMDQGA1UdHwQtMCswKaAn -oCWGI2h0dHA6Ly9jcmwuc2VjdXJldHJ1c3QuY29tL1NHQ0EuY3JsMBAGCSsGAQQBgjcVAQQDAgEA -MA0GCSqGSIb3DQEBBQUAA4IBAQBjGghAfaReUw132HquHw0LURYD7xh8yOOvaliTFGCRsoTciE6+ -OYo68+aCiV0BN7OrJKQVDpI1WkpEXk5X+nXOH0jOZvQ8QCaSmGwb7iRGDBezUqXbpZGRzzfTb+cn -CDpOGR86p1hcF895P4vkp9MmI50mD1hp/Ed+stCNi5O/KU9DaXR2Z0vPB4zmAve14bRDtUstFJ/5 -3CYNv6ZHdAbYiNE6KTCEztI5gGIbqMdXSbxqVVFnFUq+NQfk1XWYN3kwFNspnWzFacxHVaIw98xc -f8LDmBxrThaA63p4ZUWiABqvDA1VZDRIuJK58bRQKfJPIx/abKwfROHdI3hRW8cW ------END CERTIFICATE----- - -COMODO Certification Authority -============================== ------BEGIN CERTIFICATE----- -MIIEHTCCAwWgAwIBAgIQToEtioJl4AsC7j41AkblPTANBgkqhkiG9w0BAQUFADCBgTELMAkGA1UE -BhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgG -A1UEChMRQ09NT0RPIENBIExpbWl0ZWQxJzAlBgNVBAMTHkNPTU9ETyBDZXJ0aWZpY2F0aW9uIEF1 -dGhvcml0eTAeFw0wNjEyMDEwMDAwMDBaFw0yOTEyMzEyMzU5NTlaMIGBMQswCQYDVQQGEwJHQjEb -MBkGA1UECBMSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHEwdTYWxmb3JkMRowGAYDVQQKExFD -T01PRE8gQ0EgTGltaXRlZDEnMCUGA1UEAxMeQ09NT0RPIENlcnRpZmljYXRpb24gQXV0aG9yaXR5 -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0ECLi3LjkRv3UcEbVASY06m/weaKXTuH -+7uIzg3jLz8GlvCiKVCZrts7oVewdFFxze1CkU1B/qnI2GqGd0S7WWaXUF601CxwRM/aN5VCaTww -xHGzUvAhTaHYujl8HJ6jJJ3ygxaYqhZ8Q5sVW7euNJH+1GImGEaaP+vB+fGQV+useg2L23IwambV -4EajcNxo2f8ESIl33rXp+2dtQem8Ob0y2WIC8bGoPW43nOIv4tOiJovGuFVDiOEjPqXSJDlqR6sA -1KGzqSX+DT+nHbrTUcELpNqsOO9VUCQFZUaTNE8tja3G1CEZ0o7KBWFxB3NH5YoZEr0ETc5OnKVI -rLsm9wIDAQABo4GOMIGLMB0GA1UdDgQWBBQLWOWLxkwVN6RAqTCpIb5HNlpW/zAOBgNVHQ8BAf8E -BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zBJBgNVHR8EQjBAMD6gPKA6hjhodHRwOi8vY3JsLmNvbW9k -b2NhLmNvbS9DT01PRE9DZXJ0aWZpY2F0aW9uQXV0aG9yaXR5LmNybDANBgkqhkiG9w0BAQUFAAOC -AQEAPpiem/Yb6dc5t3iuHXIYSdOH5EOC6z/JqvWote9VfCFSZfnVDeFs9D6Mk3ORLgLETgdxb8CP -OGEIqB6BCsAvIC9Bi5HcSEW88cbeunZrM8gALTFGTO3nnc+IlP8zwFboJIYmuNg4ON8qa90SzMc/ -RxdMosIGlgnW2/4/PEZB31jiVg88O8EckzXZOFKs7sjsLjBOlDW0JB9LeGna8gI4zJVSk/BwJVmc -IGfE7vmLV2H0knZ9P4SNVbfo5azV8fUZVqZa+5Acr5Pr5RzUZ5ddBA6+C4OmF4O5MBKgxTMVBbkN -+8cFduPYSo38NBejxiEovjBFMR7HeL5YYTisO+IBZQ== ------END CERTIFICATE----- - -Network Solutions Certificate Authority -======================================= ------BEGIN CERTIFICATE----- -MIID5jCCAs6gAwIBAgIQV8szb8JcFuZHFhfjkDFo4DANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQG -EwJVUzEhMB8GA1UEChMYTmV0d29yayBTb2x1dGlvbnMgTC5MLkMuMTAwLgYDVQQDEydOZXR3b3Jr -IFNvbHV0aW9ucyBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwHhcNMDYxMjAxMDAwMDAwWhcNMjkxMjMx -MjM1OTU5WjBiMQswCQYDVQQGEwJVUzEhMB8GA1UEChMYTmV0d29yayBTb2x1dGlvbnMgTC5MLkMu -MTAwLgYDVQQDEydOZXR3b3JrIFNvbHV0aW9ucyBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwggEiMA0G -CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkvH6SMG3G2I4rC7xGzuAnlt7e+foS0zwzc7MEL7xx -jOWftiJgPl9dzgn/ggwbmlFQGiaJ3dVhXRncEg8tCqJDXRfQNJIg6nPPOCwGJgl6cvf6UDL4wpPT -aaIjzkGxzOTVHzbRijr4jGPiFFlp7Q3Tf2vouAPlT2rlmGNpSAW+Lv8ztumXWWn4Zxmuk2GWRBXT -crA/vGp97Eh/jcOrqnErU2lBUzS1sLnFBgrEsEX1QV1uiUV7PTsmjHTC5dLRfbIR1PtYMiKagMnc -/Qzpf14Dl847ABSHJ3A4qY5usyd2mFHgBeMhqxrVhSI8KbWaFsWAqPS7azCPL0YCorEMIuDTAgMB -AAGjgZcwgZQwHQYDVR0OBBYEFCEwyfsA106Y2oeqKtCnLrFAMadMMA4GA1UdDwEB/wQEAwIBBjAP -BgNVHRMBAf8EBTADAQH/MFIGA1UdHwRLMEkwR6BFoEOGQWh0dHA6Ly9jcmwubmV0c29sc3NsLmNv -bS9OZXR3b3JrU29sdXRpb25zQ2VydGlmaWNhdGVBdXRob3JpdHkuY3JsMA0GCSqGSIb3DQEBBQUA -A4IBAQC7rkvnt1frf6ott3NHhWrB5KUd5Oc86fRZZXe1eltajSU24HqXLjjAV2CDmAaDn7l2em5Q -4LqILPxFzBiwmZVRDuwduIj/h1AcgsLj4DKAv6ALR8jDMe+ZZzKATxcheQxpXN5eNK4CtSbqUN9/ -GGUsyfJj4akH/nxxH2szJGoeBfcFaMBqEssuXmHLrijTfsK0ZpEmXzwuJF/LWA/rKOyvEZbz3Htv -wKeI8lN3s2Berq4o2jUsbzRF0ybh3uxbTydrFny9RAQYgrOJeRcQcT16ohZO9QHNpGxlaKFJdlxD -ydi8NmdspZS11My5vWo1ViHe2MPr+8ukYEywVaCge1ey ------END CERTIFICATE----- - -COMODO ECC Certification Authority -================================== ------BEGIN CERTIFICATE----- -MIICiTCCAg+gAwIBAgIQH0evqmIAcFBUTAGem2OZKjAKBggqhkjOPQQDAzCBhTELMAkGA1UEBhMC -R0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UE -ChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlvbiBB -dXRob3JpdHkwHhcNMDgwMzA2MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMCR0Ix -GzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMR -Q09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBFQ0MgQ2VydGlmaWNhdGlvbiBBdXRo -b3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQDR3svdcmCFYX7deSRFtSrYpn1PlILBs5BAH+X -4QokPB0BBO490o0JlwzgdeT6+3eKKvUDYEs2ixYjFq0JcfRK9ChQtP6IHG4/bC8vCVlbpVsLM5ni -wz2J+Wos77LTBumjQjBAMB0GA1UdDgQWBBR1cacZSBm8nZ3qQUfflMRId5nTeTAOBgNVHQ8BAf8E -BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjEA7wNbeqy3eApyt4jf/7VG -FAkK+qDmfQjGGoe9GKhzvSbKYAydzpmfz1wPMOG+FDHqAjAU9JM8SaczepBGR7NjfRObTrdvGDeA -U/7dIOA1mjbRxwG55tzd8/8dLDoWV9mSOdY= ------END CERTIFICATE----- - -Security Communication EV RootCA1 -================================= ------BEGIN CERTIFICATE----- -MIIDfTCCAmWgAwIBAgIBADANBgkqhkiG9w0BAQUFADBgMQswCQYDVQQGEwJKUDElMCMGA1UEChMc -U0VDT00gVHJ1c3QgU3lzdGVtcyBDTy4sTFRELjEqMCgGA1UECxMhU2VjdXJpdHkgQ29tbXVuaWNh -dGlvbiBFViBSb290Q0ExMB4XDTA3MDYwNjAyMTIzMloXDTM3MDYwNjAyMTIzMlowYDELMAkGA1UE -BhMCSlAxJTAjBgNVBAoTHFNFQ09NIFRydXN0IFN5c3RlbXMgQ08uLExURC4xKjAoBgNVBAsTIVNl -Y3VyaXR5IENvbW11bmljYXRpb24gRVYgUm9vdENBMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC -AQoCggEBALx/7FebJOD+nLpCeamIivqA4PUHKUPqjgo0No0c+qe1OXj/l3X3L+SqawSERMqm4miO -/VVQYg+kcQ7OBzgtQoVQrTyWb4vVog7P3kmJPdZkLjjlHmy1V4qe70gOzXppFodEtZDkBp2uoQSX -WHnvIEqCa4wiv+wfD+mEce3xDuS4GBPMVjZd0ZoeUWs5bmB2iDQL87PRsJ3KYeJkHcFGB7hj3R4z -ZbOOCVVSPbW9/wfrrWFVGCypaZhKqkDFMxRldAD5kd6vA0jFQFTcD4SQaCDFkpbcLuUCRarAX1T4 -bepJz11sS6/vmsJWXMY1VkJqMF/Cq/biPT+zyRGPMUzXn0kCAwEAAaNCMEAwHQYDVR0OBBYEFDVK -9U2vP9eCOKyrcWUXdYydVZPmMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MA0GCSqG -SIb3DQEBBQUAA4IBAQCoh+ns+EBnXcPBZsdAS5f8hxOQWsTvoMpfi7ent/HWtWS3irO4G8za+6xm -iEHO6Pzk2x6Ipu0nUBsCMCRGef4Eh3CXQHPRwMFXGZpppSeZq51ihPZRwSzJIxXYKLerJRO1RuGG -Av8mjMSIkh1W/hln8lXkgKNrnKt34VFxDSDbEJrbvXZ5B3eZKK2aXtqxT0QsNY6llsf9g/BYxnnW -mHyojf6GPgcWkuF75x3sM3Z+Qi5KhfmRiWiEA4Glm5q+4zfFVKtWOxgtQaQM+ELbmaDgcm+7XeEW -T1MKZPlO9L9OVL14bIjqv5wTJMJwaaJ/D8g8rQjJsJhAoyrniIPtd490 ------END CERTIFICATE----- - -OISTE WISeKey Global Root GA CA -=============================== ------BEGIN CERTIFICATE----- -MIID8TCCAtmgAwIBAgIQQT1yx/RrH4FDffHSKFTfmjANBgkqhkiG9w0BAQUFADCBijELMAkGA1UE -BhMCQ0gxEDAOBgNVBAoTB1dJU2VLZXkxGzAZBgNVBAsTEkNvcHlyaWdodCAoYykgMjAwNTEiMCAG -A1UECxMZT0lTVEUgRm91bmRhdGlvbiBFbmRvcnNlZDEoMCYGA1UEAxMfT0lTVEUgV0lTZUtleSBH -bG9iYWwgUm9vdCBHQSBDQTAeFw0wNTEyMTExNjAzNDRaFw0zNzEyMTExNjA5NTFaMIGKMQswCQYD -VQQGEwJDSDEQMA4GA1UEChMHV0lTZUtleTEbMBkGA1UECxMSQ29weXJpZ2h0IChjKSAyMDA1MSIw -IAYDVQQLExlPSVNURSBGb3VuZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBXSVNlS2V5 -IEdsb2JhbCBSb290IEdBIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAy0+zAJs9 -Nt350UlqaxBJH+zYK7LG+DKBKUOVTJoZIyEVRd7jyBxRVVuuk+g3/ytr6dTqvirdqFEr12bDYVxg -Asj1znJ7O7jyTmUIms2kahnBAbtzptf2w93NvKSLtZlhuAGio9RN1AU9ka34tAhxZK9w8RxrfvbD -d50kc3vkDIzh2TbhmYsFmQvtRTEJysIA2/dyoJaqlYfQjse2YXMNdmaM3Bu0Y6Kff5MTMPGhJ9vZ -/yxViJGg4E8HsChWjBgbl0SOid3gF27nKu+POQoxhILYQBRJLnpB5Kf+42TMwVlxSywhp1t94B3R -LoGbw9ho972WG6xwsRYUC9tguSYBBQIDAQABo1EwTzALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUw -AwEB/zAdBgNVHQ4EFgQUswN+rja8sHnR3JQmthG+IbJphpQwEAYJKwYBBAGCNxUBBAMCAQAwDQYJ -KoZIhvcNAQEFBQADggEBAEuh/wuHbrP5wUOxSPMowB0uyQlB+pQAHKSkq0lPjz0e701vvbyk9vIm -MMkQyh2I+3QZH4VFvbBsUfk2ftv1TDI6QU9bR8/oCy22xBmddMVHxjtqD6wU2zz0c5ypBd8A3HR4 -+vg1YFkCExh8vPtNsCBtQ7tgMHpnM1zFmdH4LTlSc/uMqpclXHLZCB6rTjzjgTGfA6b7wP4piFXa -hNVQA7bihKOmNqoROgHhGEvWRGizPflTdISzRpFGlgC3gCy24eMQ4tui5yiPAZZiFj4A4xylNoEY -okxSdsARo27mHbrjWr42U8U+dY+GaSlYU7Wcu2+fXMUY7N0v4ZjJ/L7fCg0= ------END CERTIFICATE----- - -Certigna -======== ------BEGIN CERTIFICATE----- -MIIDqDCCApCgAwIBAgIJAP7c4wEPyUj/MA0GCSqGSIb3DQEBBQUAMDQxCzAJBgNVBAYTAkZSMRIw -EAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hMB4XDTA3MDYyOTE1MTMwNVoXDTI3 -MDYyOTE1MTMwNVowNDELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCURoaW15b3RpczERMA8GA1UEAwwI -Q2VydGlnbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDIaPHJ1tazNHUmgh7stL7q -XOEm7RFHYeGifBZ4QCHkYJ5ayGPhxLGWkv8YbWkj4Sti993iNi+RB7lIzw7sebYs5zRLcAglozyH -GxnygQcPOJAZ0xH+hrTy0V4eHpbNgGzOOzGTtvKg0KmVEn2lmsxryIRWijOp5yIVUxbwzBfsV1/p -ogqYCd7jX5xv3EjjhQsVWqa6n6xI4wmy9/Qy3l40vhx4XUJbzg4ij02Q130yGLMLLGq/jj8UEYkg -DncUtT2UCIf3JR7VsmAA7G8qKCVuKj4YYxclPz5EIBb2JsglrgVKtOdjLPOMFlN+XPsRGgjBRmKf -Irjxwo1p3Po6WAbfAgMBAAGjgbwwgbkwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUGu3+QTmQ -tCRZvgHyUtVF9lo53BEwZAYDVR0jBF0wW4AUGu3+QTmQtCRZvgHyUtVF9lo53BGhOKQ2MDQxCzAJ -BgNVBAYTAkZSMRIwEAYDVQQKDAlEaGlteW90aXMxETAPBgNVBAMMCENlcnRpZ25hggkA/tzjAQ/J -SP8wDgYDVR0PAQH/BAQDAgEGMBEGCWCGSAGG+EIBAQQEAwIABzANBgkqhkiG9w0BAQUFAAOCAQEA -hQMeknH2Qq/ho2Ge6/PAD/Kl1NqV5ta+aDY9fm4fTIrv0Q8hbV6lUmPOEvjvKtpv6zf+EwLHyzs+ -ImvaYS5/1HI93TDhHkxAGYwP15zRgzB7mFncfca5DClMoTOi62c6ZYTTluLtdkVwj7Ur3vkj1klu -PBS1xp81HlDQwY9qcEQCYsuuHWhBp6pX6FOqB9IG9tUUBguRA3UsbHK1YZWaDYu5Def131TN3ubY -1gkIl2PlwS6wt0QmwCbAr1UwnjvVNioZBPRcHv/PLLf/0P2HQBHVESO7SMAhqaQoLf0V+LBOK/Qw -WyH8EZE0vkHve52Xdf+XlcCWWC/qu0bXu+TZLg== ------END CERTIFICATE----- - -Deutsche Telekom Root CA 2 -========================== ------BEGIN CERTIFICATE----- -MIIDnzCCAoegAwIBAgIBJjANBgkqhkiG9w0BAQUFADBxMQswCQYDVQQGEwJERTEcMBoGA1UEChMT -RGV1dHNjaGUgVGVsZWtvbSBBRzEfMB0GA1UECxMWVC1UZWxlU2VjIFRydXN0IENlbnRlcjEjMCEG -A1UEAxMaRGV1dHNjaGUgVGVsZWtvbSBSb290IENBIDIwHhcNOTkwNzA5MTIxMTAwWhcNMTkwNzA5 -MjM1OTAwWjBxMQswCQYDVQQGEwJERTEcMBoGA1UEChMTRGV1dHNjaGUgVGVsZWtvbSBBRzEfMB0G -A1UECxMWVC1UZWxlU2VjIFRydXN0IENlbnRlcjEjMCEGA1UEAxMaRGV1dHNjaGUgVGVsZWtvbSBS -b290IENBIDIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCrC6M14IspFLEUha88EOQ5 -bzVdSq7d6mGNlUn0b2SjGmBmpKlAIoTZ1KXleJMOaAGtuU1cOs7TuKhCQN/Po7qCWWqSG6wcmtoI -KyUn+WkjR/Hg6yx6m/UTAtB+NHzCnjwAWav12gz1MjwrrFDa1sPeg5TKqAyZMg4ISFZbavva4VhY -AUlfckE8FQYBjl2tqriTtM2e66foai1SNNs671x1Udrb8zH57nGYMsRUFUQM+ZtV7a3fGAigo4aK -Se5TBY8ZTNXeWHmb0mocQqvF1afPaA+W5OFhmHZhyJF81j4A4pFQh+GdCuatl9Idxjp9y7zaAzTV -jlsB9WoHtxa2bkp/AgMBAAGjQjBAMB0GA1UdDgQWBBQxw3kbuvVT1xfgiXotF2wKsyudMzAPBgNV -HRMECDAGAQH/AgEFMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQUFAAOCAQEAlGRZrTlk5ynr -E/5aw4sTV8gEJPB0d8Bg42f76Ymmg7+Wgnxu1MM9756AbrsptJh6sTtU6zkXR34ajgv8HzFZMQSy -zhfzLMdiNlXiItiJVbSYSKpk+tYcNthEeFpaIzpXl/V6ME+un2pMSyuOoAPjPuCp1NJ70rOo4nI8 -rZ7/gFnkm0W09juwzTkZmDLl6iFhkOQxIY40sfcvNUqFENrnijchvllj4PKFiDFT1FQUhXB59C4G -dyd1Lx+4ivn+xbrYNuSD7Odlt79jWvNGr4GUN9RBjNYj1h7P9WgbRGOiWrqnNVmh5XAFmw4jV5mU -Cm26OWMohpLzGITY+9HPBVZkVw== ------END CERTIFICATE----- - -Cybertrust Global Root -====================== ------BEGIN CERTIFICATE----- -MIIDoTCCAomgAwIBAgILBAAAAAABD4WqLUgwDQYJKoZIhvcNAQEFBQAwOzEYMBYGA1UEChMPQ3li -ZXJ0cnVzdCwgSW5jMR8wHQYDVQQDExZDeWJlcnRydXN0IEdsb2JhbCBSb290MB4XDTA2MTIxNTA4 -MDAwMFoXDTIxMTIxNTA4MDAwMFowOzEYMBYGA1UEChMPQ3liZXJ0cnVzdCwgSW5jMR8wHQYDVQQD -ExZDeWJlcnRydXN0IEdsb2JhbCBSb290MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA -+Mi8vRRQZhP/8NN57CPytxrHjoXxEnOmGaoQ25yiZXRadz5RfVb23CO21O1fWLE3TdVJDm71aofW -0ozSJ8bi/zafmGWgE07GKmSb1ZASzxQG9Dvj1Ci+6A74q05IlG2OlTEQXO2iLb3VOm2yHLtgwEZL -AfVJrn5GitB0jaEMAs7u/OePuGtm839EAL9mJRQr3RAwHQeWP032a7iPt3sMpTjr3kfb1V05/Iin -89cqdPHoWqI7n1C6poxFNcJQZZXcY4Lv3b93TZxiyWNzFtApD0mpSPCzqrdsxacwOUBdrsTiXSZT -8M4cIwhhqJQZugRiQOwfOHB3EgZxpzAYXSUnpQIDAQABo4GlMIGiMA4GA1UdDwEB/wQEAwIBBjAP -BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBS2CHsNesysIEyGVjJez6tuhS1wVzA/BgNVHR8EODA2 -MDSgMqAwhi5odHRwOi8vd3d3Mi5wdWJsaWMtdHJ1c3QuY29tL2NybC9jdC9jdHJvb3QuY3JsMB8G -A1UdIwQYMBaAFLYIew16zKwgTIZWMl7Pq26FLXBXMA0GCSqGSIb3DQEBBQUAA4IBAQBW7wojoFRO -lZfJ+InaRcHUowAl9B8Tq7ejhVhpwjCt2BWKLePJzYFa+HMjWqd8BfP9IjsO0QbE2zZMcwSO5bAi -5MXzLqXZI+O4Tkogp24CJJ8iYGd7ix1yCcUxXOl5n4BHPa2hCwcUPUf/A2kaDAtE52Mlp3+yybh2 -hO0j9n0Hq0V+09+zv+mKts2oomcrUtW3ZfA5TGOgkXmTUg9U3YO7n9GPp1Nzw8v/MOx8BLjYRB+T -X3EJIrduPuocA06dGiBh+4E37F78CkWr1+cXVdCg6mCbpvbjjFspwgZgFJ0tl0ypkxWdYcQBX0jW -WL1WMRJOEcgh4LMRkWXbtKaIOM5V ------END CERTIFICATE----- - -ePKI Root Certification Authority -================================= ------BEGIN CERTIFICATE----- -MIIFsDCCA5igAwIBAgIQFci9ZUdcr7iXAF7kBtK8nTANBgkqhkiG9w0BAQUFADBeMQswCQYDVQQG -EwJUVzEjMCEGA1UECgwaQ2h1bmdod2EgVGVsZWNvbSBDby4sIEx0ZC4xKjAoBgNVBAsMIWVQS0kg -Um9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTAeFw0wNDEyMjAwMjMxMjdaFw0zNDEyMjAwMjMx -MjdaMF4xCzAJBgNVBAYTAlRXMSMwIQYDVQQKDBpDaHVuZ2h3YSBUZWxlY29tIENvLiwgTHRkLjEq -MCgGA1UECwwhZVBLSSBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIICIjANBgkqhkiG9w0B -AQEFAAOCAg8AMIICCgKCAgEA4SUP7o3biDN1Z82tH306Tm2d0y8U82N0ywEhajfqhFAHSyZbCUNs -IZ5qyNUD9WBpj8zwIuQf5/dqIjG3LBXy4P4AakP/h2XGtRrBp0xtInAhijHyl3SJCRImHJ7K2RKi -lTza6We/CKBk49ZCt0Xvl/T29de1ShUCWH2YWEtgvM3XDZoTM1PRYfl61dd4s5oz9wCGzh1NlDiv -qOx4UXCKXBCDUSH3ET00hl7lSM2XgYI1TBnsZfZrxQWh7kcT1rMhJ5QQCtkkO7q+RBNGMD+XPNjX -12ruOzjjK9SXDrkb5wdJfzcq+Xd4z1TtW0ado4AOkUPB1ltfFLqfpo0kR0BZv3I4sjZsN/+Z0V0O -WQqraffAsgRFelQArr5T9rXn4fg8ozHSqf4hUmTFpmfwdQcGlBSBVcYn5AGPF8Fqcde+S/uUWH1+ -ETOxQvdibBjWzwloPn9s9h6PYq2lY9sJpx8iQkEeb5mKPtf5P0B6ebClAZLSnT0IFaUQAS2zMnao -lQ2zepr7BxB4EW/hj8e6DyUadCrlHJhBmd8hh+iVBmoKs2pHdmX2Os+PYhcZewoozRrSgx4hxyy/ -vv9haLdnG7t4TY3OZ+XkwY63I2binZB1NJipNiuKmpS5nezMirH4JYlcWrYvjB9teSSnUmjDhDXi -Zo1jDiVN1Rmy5nk3pyKdVDECAwEAAaNqMGgwHQYDVR0OBBYEFB4M97Zn8uGSJglFwFU5Lnc/Qkqi -MAwGA1UdEwQFMAMBAf8wOQYEZyoHAAQxMC8wLQIBADAJBgUrDgMCGgUAMAcGBWcqAwAABBRFsMLH -ClZ87lt4DJX5GFPBphzYEDANBgkqhkiG9w0BAQUFAAOCAgEACbODU1kBPpVJufGBuvl2ICO1J2B0 -1GqZNF5sAFPZn/KmsSQHRGoqxqWOeBLoR9lYGxMqXnmbnwoqZ6YlPwZpVnPDimZI+ymBV3QGypzq -KOg4ZyYr8dW1P2WT+DZdjo2NQCCHGervJ8A9tDkPJXtoUHRVnAxZfVo9QZQlUgjgRywVMRnVvwdV -xrsStZf0X4OFunHB2WyBEXYKCrC/gpf36j36+uwtqSiUO1bd0lEursC9CBWMd1I0ltabrNMdjmEP -NXubrjlpC2JgQCA2j6/7Nu4tCEoduL+bXPjqpRugc6bY+G7gMwRfaKonh+3ZwZCc7b3jajWvY9+r -GNm65ulK6lCKD2GTHuItGeIwlDWSXQ62B68ZgI9HkFFLLk3dheLSClIKF5r8GrBQAuUBo2M3IUxE -xJtRmREOc5wGj1QupyheRDmHVi03vYVElOEMSyycw5KFNGHLD7ibSkNS/jQ6fbjpKdx2qcgw+BRx -gMYeNkh0IkFch4LoGHGLQYlE535YW6i4jRPpp2zDR+2zGp1iro2C6pSe3VkQw63d4k3jMdXH7Ojy -sP6SHhYKGvzZ8/gntsm+HbRsZJB/9OTEW9c3rkIO3aQab3yIVMUWbuF6aC74Or8NpDyJO3inTmOD -BCEIZ43ygknQW/2xzQ+DhNQ+IIX3Sj0rnP0qCglN6oH4EZw= ------END CERTIFICATE----- - -T\xc3\x9c\x42\xC4\xB0TAK UEKAE K\xC3\xB6k Sertifika Hizmet Sa\xC4\x9Flay\xc4\xb1\x63\xc4\xb1s\xc4\xb1 - S\xC3\xBCr\xC3\xBCm 3 -============================================================================================================================= ------BEGIN CERTIFICATE----- -MIIFFzCCA/+gAwIBAgIBETANBgkqhkiG9w0BAQUFADCCASsxCzAJBgNVBAYTAlRSMRgwFgYDVQQH -DA9HZWJ6ZSAtIEtvY2FlbGkxRzBFBgNVBAoMPlTDvHJraXllIEJpbGltc2VsIHZlIFRla25vbG9q -aWsgQXJhxZ90xLFybWEgS3VydW11IC0gVMOcQsSwVEFLMUgwRgYDVQQLDD9VbHVzYWwgRWxla3Ry -b25payB2ZSBLcmlwdG9sb2ppIEFyYcWfdMSxcm1hIEVuc3RpdMO8c8O8IC0gVUVLQUUxIzAhBgNV -BAsMGkthbXUgU2VydGlmaWthc3lvbiBNZXJrZXppMUowSAYDVQQDDEFUw5xCxLBUQUsgVUVLQUUg -S8O2ayBTZXJ0aWZpa2EgSGl6bWV0IFNhxJ9sYXnEsWPEsXPEsSAtIFPDvHLDvG0gMzAeFw0wNzA4 -MjQxMTM3MDdaFw0xNzA4MjExMTM3MDdaMIIBKzELMAkGA1UEBhMCVFIxGDAWBgNVBAcMD0dlYnpl -IC0gS29jYWVsaTFHMEUGA1UECgw+VMO8cmtpeWUgQmlsaW1zZWwgdmUgVGVrbm9sb2ppayBBcmHF -n3TEsXJtYSBLdXJ1bXUgLSBUw5xCxLBUQUsxSDBGBgNVBAsMP1VsdXNhbCBFbGVrdHJvbmlrIHZl -IEtyaXB0b2xvamkgQXJhxZ90xLFybWEgRW5zdGl0w7xzw7wgLSBVRUtBRTEjMCEGA1UECwwaS2Ft -dSBTZXJ0aWZpa2FzeW9uIE1lcmtlemkxSjBIBgNVBAMMQVTDnELEsFRBSyBVRUtBRSBLw7ZrIFNl -cnRpZmlrYSBIaXptZXQgU2HEn2xhecSxY8Sxc8SxIC0gU8O8csO8bSAzMIIBIjANBgkqhkiG9w0B -AQEFAAOCAQ8AMIIBCgKCAQEAim1L/xCIOsP2fpTo6iBkcK4hgb46ezzb8R1Sf1n68yJMlaCQvEhO -Eav7t7WNeoMojCZG2E6VQIdhn8WebYGHV2yKO7Rm6sxA/OOqbLLLAdsyv9Lrhc+hDVXDWzhXcLh1 -xnnRFDDtG1hba+818qEhTsXOfJlfbLm4IpNQp81McGq+agV/E5wrHur+R84EpW+sky58K5+eeROR -6Oqeyjh1jmKwlZMq5d/pXpduIF9fhHpEORlAHLpVK/swsoHvhOPc7Jg4OQOFCKlUAwUp8MmPi+oL -hmUZEdPpCSPeaJMDyTYcIW7OjGbxmTDY17PDHfiBLqi9ggtm/oLL4eAagsNAgQIDAQABo0IwQDAd -BgNVHQ4EFgQUvYiHyY/2pAoLquvF/pEjnatKijIwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF -MAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAB18+kmPNOm3JpIWmgV050vQbTlswyb2zrgxvMTfvCr4 -N5EY3ATIZJkrGG2AA1nJrvhY0D7twyOfaTyGOBye79oneNGEN3GKPEs5z35FBtYt2IpNeBLWrcLT -y9LQQfMmNkqblWwM7uXRQydmwYj3erMgbOqwaSvHIOgMA8RBBZniP+Rr+KCGgceExh/VS4ESshYh -LBOhgLJeDEoTniDYYkCrkOpkSi+sDQESeUWoL4cZaMjihccwsnX5OD+ywJO0a+IDRM5noN+J1q2M -dqMTw5RhK2vZbMEHCiIHhWyFJEapvj+LeISCfiQMnf2BN+MlqO02TpUsyZyQ2uypQjyttgI= ------END CERTIFICATE----- - -certSIGN ROOT CA -================ ------BEGIN CERTIFICATE----- -MIIDODCCAiCgAwIBAgIGIAYFFnACMA0GCSqGSIb3DQEBBQUAMDsxCzAJBgNVBAYTAlJPMREwDwYD -VQQKEwhjZXJ0U0lHTjEZMBcGA1UECxMQY2VydFNJR04gUk9PVCBDQTAeFw0wNjA3MDQxNzIwMDRa -Fw0zMTA3MDQxNzIwMDRaMDsxCzAJBgNVBAYTAlJPMREwDwYDVQQKEwhjZXJ0U0lHTjEZMBcGA1UE -CxMQY2VydFNJR04gUk9PVCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALczuX7I -JUqOtdu0KBuqV5Do0SLTZLrTk+jUrIZhQGpgV2hUhE28alQCBf/fm5oqrl0Hj0rDKH/v+yv6efHH -rfAQUySQi2bJqIirr1qjAOm+ukbuW3N7LBeCgV5iLKECZbO9xSsAfsT8AzNXDe3i+s5dRdY4zTW2 -ssHQnIFKquSyAVwdj1+ZxLGt24gh65AIgoDzMKND5pCCrlUoSe1b16kQOA7+j0xbm0bqQfWwCHTD -0IgztnzXdN/chNFDDnU5oSVAKOp4yw4sLjmdjItuFhwvJoIQ4uNllAoEwF73XVv4EOLQunpL+943 -AAAaWyjj0pxzPjKHmKHJUS/X3qwzs08CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8B -Af8EBAMCAcYwHQYDVR0OBBYEFOCMm9slSbPxfIbWskKHC9BroNnkMA0GCSqGSIb3DQEBBQUAA4IB -AQA+0hyJLjX8+HXd5n9liPRyTMks1zJO890ZeUe9jjtbkw9QSSQTaxQGcu8J06Gh40CEyecYMnQ8 -SG4Pn0vU9x7Tk4ZkVJdjclDVVc/6IJMCopvDI5NOFlV2oHB5bc0hH88vLbwZ44gx+FkagQnIl6Z0 -x2DEW8xXjrJ1/RsCCdtZb3KTafcxQdaIOL+Hsr0Wefmq5L6IJd1hJyMctTEHBDa0GpC9oHRxUIlt -vBTjD4au8as+x6AJzKNI0eDbZOeStc+vckNwi/nDhDwTqn6Sm1dTk/pwwpEOMfmbZ13pljheX7Nz -TogVZ96edhBiIL5VaZVDADlN9u6wWk5JRFRYX0KD ------END CERTIFICATE----- - -CNNIC ROOT -========== ------BEGIN CERTIFICATE----- -MIIDVTCCAj2gAwIBAgIESTMAATANBgkqhkiG9w0BAQUFADAyMQswCQYDVQQGEwJDTjEOMAwGA1UE -ChMFQ05OSUMxEzARBgNVBAMTCkNOTklDIFJPT1QwHhcNMDcwNDE2MDcwOTE0WhcNMjcwNDE2MDcw -OTE0WjAyMQswCQYDVQQGEwJDTjEOMAwGA1UEChMFQ05OSUMxEzARBgNVBAMTCkNOTklDIFJPT1Qw -ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDTNfc/c3et6FtzF8LRb+1VvG7q6KR5smzD -o+/hn7E7SIX1mlwhIhAsxYLO2uOabjfhhyzcuQxauohV3/2q2x8x6gHx3zkBwRP9SFIhxFXf2tiz -VHa6dLG3fdfA6PZZxU3Iva0fFNrfWEQlMhkqx35+jq44sDB7R3IJMfAw28Mbdim7aXZOV/kbZKKT -VrdvmW7bCgScEeOAH8tjlBAKqeFkgjH5jCftppkA9nCTGPihNIaj3XrCGHn2emU1z5DrvTOTn1Or -czvmmzQgLx3vqR1jGqCA2wMv+SYahtKNu6m+UjqHZ0gNv7Sg2Ca+I19zN38m5pIEo3/PIKe38zrK -y5nLAgMBAAGjczBxMBEGCWCGSAGG+EIBAQQEAwIABzAfBgNVHSMEGDAWgBRl8jGtKvf33VKWCscC -wQ7vptU7ETAPBgNVHRMBAf8EBTADAQH/MAsGA1UdDwQEAwIB/jAdBgNVHQ4EFgQUZfIxrSr3991S -lgrHAsEO76bVOxEwDQYJKoZIhvcNAQEFBQADggEBAEs17szkrr/Dbq2flTtLP1se31cpolnKOOK5 -Gv+e5m4y3R6u6jW39ZORTtpC4cMXYFDy0VwmuYK36m3knITnA3kXr5g9lNvHugDnuL8BV8F3RTIM -O/G0HAiw/VGgod2aHRM2mm23xzy54cXZF/qD1T0VoDy7HgviyJA/qIYM/PmLXoXLT1tLYhFHxUV8 -BS9BsZ4QaRuZluBVeftOhpm4lNqGOGqTo+fLbuXf6iFViZx9fX+Y9QCJ7uOEwFyWtcVG6kbghVW2 -G8kS1sHNzYDzAgE8yGnLRUhj2JTQ7IUOO04RZfSCjKY9ri4ilAnIXOo8gV0WKgOXFlUJ24pBgp5m -mxE= ------END CERTIFICATE----- - -GeoTrust Primary Certification Authority - G3 -============================================= ------BEGIN CERTIFICATE----- -MIID/jCCAuagAwIBAgIQFaxulBmyeUtB9iepwxgPHzANBgkqhkiG9w0BAQsFADCBmDELMAkGA1UE -BhMCVVMxFjAUBgNVBAoTDUdlb1RydXN0IEluYy4xOTA3BgNVBAsTMChjKSAyMDA4IEdlb1RydXN0 -IEluYy4gLSBGb3IgYXV0aG9yaXplZCB1c2Ugb25seTE2MDQGA1UEAxMtR2VvVHJ1c3QgUHJpbWFy -eSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEczMB4XDTA4MDQwMjAwMDAwMFoXDTM3MTIwMTIz -NTk1OVowgZgxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMTkwNwYDVQQLEzAo -YykgMjAwOCBHZW9UcnVzdCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxNjA0BgNVBAMT -LUdlb1RydXN0IFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgLSBHMzCCASIwDQYJKoZI -hvcNAQEBBQADggEPADCCAQoCggEBANziXmJYHTNXOTIz+uvLh4yn1ErdBojqZI4xmKU4kB6Yzy5j -K/BGvESyiaHAKAxJcCGVn2TAppMSAmUmhsalifD614SgcK9PGpc/BkTVyetyEH3kMSj7HGHmKAdE -c5IiaacDiGydY8hS2pgn5whMcD60yRLBxWeDXTPzAxHsatBT4tG6NmCUgLthY2xbF37fQJQeqw3C -IShwiP/WJmxsYAQlTlV+fe+/lEjetx3dcI0FX4ilm/LC7urRQEFtYjgdVgbFA0dRIBn8exALDmKu -dlW/X3e+PkkBUz2YJQN2JFodtNuJ6nnltrM7P7pMKEF/BqxqjsHQ9gUdfeZChuOl1UcCAwEAAaNC -MEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFMR5yo6hTgMdHNxr -2zFblD4/MH8tMA0GCSqGSIb3DQEBCwUAA4IBAQAtxRPPVoB7eni9n64smefv2t+UXglpp+duaIy9 -cr5HqQ6XErhK8WTTOd8lNNTBzU6B8A8ExCSzNJbGpqow32hhc9f5joWJ7w5elShKKiePEI4ufIbE -Ap7aDHdlDkQNkv39sxY2+hENHYwOB4lqKVb3cvTdFZx3NWZXqxNT2I7BQMXXExZacse3aQHEerGD -AWh9jUGhlBjBJVz88P6DAod8DQ3PLghcSkANPuyBYeYk28rgDi0Hsj5W3I31QYUHSJsMC8tJP33s -t/3LjWeJGqvtux6jAAgIFyqCXDFdRootD4abdNlF+9RAsXqqaC2Gspki4cErx5z481+oghLrGREt ------END CERTIFICATE----- - -thawte Primary Root CA - G2 -=========================== ------BEGIN CERTIFICATE----- -MIICiDCCAg2gAwIBAgIQNfwmXNmET8k9Jj1Xm67XVjAKBggqhkjOPQQDAzCBhDELMAkGA1UEBhMC -VVMxFTATBgNVBAoTDHRoYXd0ZSwgSW5jLjE4MDYGA1UECxMvKGMpIDIwMDcgdGhhd3RlLCBJbmMu -IC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxJDAiBgNVBAMTG3RoYXd0ZSBQcmltYXJ5IFJvb3Qg -Q0EgLSBHMjAeFw0wNzExMDUwMDAwMDBaFw0zODAxMTgyMzU5NTlaMIGEMQswCQYDVQQGEwJVUzEV -MBMGA1UEChMMdGhhd3RlLCBJbmMuMTgwNgYDVQQLEy8oYykgMjAwNyB0aGF3dGUsIEluYy4gLSBG -b3IgYXV0aG9yaXplZCB1c2Ugb25seTEkMCIGA1UEAxMbdGhhd3RlIFByaW1hcnkgUm9vdCBDQSAt -IEcyMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEotWcgnuVnfFSeIf+iha/BebfowJPDQfGAFG6DAJS -LSKkQjnE/o/qycG+1E3/n3qe4rF8mq2nhglzh9HnmuN6papu+7qzcMBniKI11KOasf2twu8x+qi5 -8/sIxpHR+ymVo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQU -mtgAMADna3+FGO6Lts6KDPgR4bswCgYIKoZIzj0EAwMDaQAwZgIxAN344FdHW6fmCsO99YCKlzUN -G4k8VIZ3KMqh9HneteY4sPBlcIx/AlTCv//YoT7ZzwIxAMSNlPzcU9LcnXgWHxUzI1NS41oxXZ3K -rr0TKUQNJ1uo52icEvdYPy5yAlejj6EULg== ------END CERTIFICATE----- - -thawte Primary Root CA - G3 -=========================== ------BEGIN CERTIFICATE----- -MIIEKjCCAxKgAwIBAgIQYAGXt0an6rS0mtZLL/eQ+zANBgkqhkiG9w0BAQsFADCBrjELMAkGA1UE -BhMCVVMxFTATBgNVBAoTDHRoYXd0ZSwgSW5jLjEoMCYGA1UECxMfQ2VydGlmaWNhdGlvbiBTZXJ2 -aWNlcyBEaXZpc2lvbjE4MDYGA1UECxMvKGMpIDIwMDggdGhhd3RlLCBJbmMuIC0gRm9yIGF1dGhv -cml6ZWQgdXNlIG9ubHkxJDAiBgNVBAMTG3RoYXd0ZSBQcmltYXJ5IFJvb3QgQ0EgLSBHMzAeFw0w -ODA0MDIwMDAwMDBaFw0zNzEyMDEyMzU5NTlaMIGuMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMdGhh -d3RlLCBJbmMuMSgwJgYDVQQLEx9DZXJ0aWZpY2F0aW9uIFNlcnZpY2VzIERpdmlzaW9uMTgwNgYD -VQQLEy8oYykgMjAwOCB0aGF3dGUsIEluYy4gLSBGb3IgYXV0aG9yaXplZCB1c2Ugb25seTEkMCIG -A1UEAxMbdGhhd3RlIFByaW1hcnkgUm9vdCBDQSAtIEczMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEAsr8nLPvb2FvdeHsbnndmgcs+vHyu86YnmjSjaDFxODNi5PNxZnmxqWWjpYvVj2At -P0LMqmsywCPLLEHd5N/8YZzic7IilRFDGF/Eth9XbAoFWCLINkw6fKXRz4aviKdEAhN0cXMKQlkC -+BsUa0Lfb1+6a4KinVvnSr0eAXLbS3ToO39/fR8EtCab4LRarEc9VbjXsCZSKAExQGbY2SS99irY -7CFJXJv2eul/VTV+lmuNk5Mny5K76qxAwJ/C+IDPXfRa3M50hqY+bAtTyr2SzhkGcuYMXDhpxwTW -vGzOW/b3aJzcJRVIiKHpqfiYnODz1TEoYRFsZ5aNOZnLwkUkOQIDAQABo0IwQDAPBgNVHRMBAf8E -BTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUrWyqlGCc7eT/+j4KdCtjA/e2Wb8wDQYJ -KoZIhvcNAQELBQADggEBABpA2JVlrAmSicY59BDlqQ5mU1143vokkbvnRFHfxhY0Cu9qRFHqKweK -A3rD6z8KLFIWoCtDuSWQP3CpMyVtRRooOyfPqsMpQhvfO0zAMzRbQYi/aytlryjvsvXDqmbOe1bu -t8jLZ8HJnBoYuMTDSQPxYA5QzUbF83d597YV4Djbxy8ooAw/dyZ02SUS2jHaGh7cKUGRIjxpp7sC -8rZcJwOJ9Abqm+RyguOhCcHpABnTPtRwa7pxpqpYrvS76Wy274fMm7v/OeZWYdMKp8RcTGB7BXcm -er/YB1IsYvdwY9k5vG8cwnncdimvzsUsZAReiDZuMdRAGmI0Nj81Aa6sY6A= ------END CERTIFICATE----- - -GeoTrust Primary Certification Authority - G2 -============================================= ------BEGIN CERTIFICATE----- -MIICrjCCAjWgAwIBAgIQPLL0SAoA4v7rJDteYD7DazAKBggqhkjOPQQDAzCBmDELMAkGA1UEBhMC -VVMxFjAUBgNVBAoTDUdlb1RydXN0IEluYy4xOTA3BgNVBAsTMChjKSAyMDA3IEdlb1RydXN0IElu -Yy4gLSBGb3IgYXV0aG9yaXplZCB1c2Ugb25seTE2MDQGA1UEAxMtR2VvVHJ1c3QgUHJpbWFyeSBD -ZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAtIEcyMB4XDTA3MTEwNTAwMDAwMFoXDTM4MDExODIzNTk1 -OVowgZgxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1HZW9UcnVzdCBJbmMuMTkwNwYDVQQLEzAoYykg -MjAwNyBHZW9UcnVzdCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxNjA0BgNVBAMTLUdl -b1RydXN0IFByaW1hcnkgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgLSBHMjB2MBAGByqGSM49AgEG -BSuBBAAiA2IABBWx6P0DFUPlrOuHNxFi79KDNlJ9RVcLSo17VDs6bl8VAsBQps8lL33KSLjHUGMc -KiEIfJo22Av+0SbFWDEwKCXzXV2juLaltJLtbCyf691DiaI8S0iRHVDsJt/WYC69IaNCMEAwDwYD -VR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFBVfNVdRVfslsq0DafwBo/q+ -EVXVMAoGCCqGSM49BAMDA2cAMGQCMGSWWaboCd6LuvpaiIjwH5HTRqjySkwCY/tsXzjbLkGTqQ7m -ndwxHLKgpxgceeHHNgIwOlavmnRs9vuD4DPTCF+hnMJbn0bWtsuRBmOiBuczrD6ogRLQy7rQkgu2 -npaqBA+K ------END CERTIFICATE----- - -VeriSign Universal Root Certification Authority -=============================================== ------BEGIN CERTIFICATE----- -MIIEuTCCA6GgAwIBAgIQQBrEZCGzEyEDDrvkEhrFHTANBgkqhkiG9w0BAQsFADCBvTELMAkGA1UE -BhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQLExZWZXJpU2lnbiBUcnVzdCBO -ZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwOCBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRob3JpemVk -IHVzZSBvbmx5MTgwNgYDVQQDEy9WZXJpU2lnbiBVbml2ZXJzYWwgUm9vdCBDZXJ0aWZpY2F0aW9u -IEF1dGhvcml0eTAeFw0wODA0MDIwMDAwMDBaFw0zNzEyMDEyMzU5NTlaMIG9MQswCQYDVQQGEwJV -UzEXMBUGA1UEChMOVmVyaVNpZ24sIEluYy4xHzAdBgNVBAsTFlZlcmlTaWduIFRydXN0IE5ldHdv -cmsxOjA4BgNVBAsTMShjKSAyMDA4IFZlcmlTaWduLCBJbmMuIC0gRm9yIGF1dGhvcml6ZWQgdXNl -IG9ubHkxODA2BgNVBAMTL1ZlcmlTaWduIFVuaXZlcnNhbCBSb290IENlcnRpZmljYXRpb24gQXV0 -aG9yaXR5MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx2E3XrEBNNti1xWb/1hajCMj -1mCOkdeQmIN65lgZOIzF9uVkhbSicfvtvbnazU0AtMgtc6XHaXGVHzk8skQHnOgO+k1KxCHfKWGP -MiJhgsWHH26MfF8WIFFE0XBPV+rjHOPMee5Y2A7Cs0WTwCznmhcrewA3ekEzeOEz4vMQGn+HLL72 -9fdC4uW/h2KJXwBL38Xd5HVEMkE6HnFuacsLdUYI0crSK5XQz/u5QGtkjFdN/BMReYTtXlT2NJ8I -AfMQJQYXStrxHXpma5hgZqTZ79IugvHw7wnqRMkVauIDbjPTrJ9VAMf2CGqUuV/c4DPxhGD5WycR -tPwW8rtWaoAljQIDAQABo4GyMIGvMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMG0G -CCsGAQUFBwEMBGEwX6FdoFswWTBXMFUWCWltYWdlL2dpZjAhMB8wBwYFKw4DAhoEFI/l0xqGrI2O -a8PPgGrUSBgsexkuMCUWI2h0dHA6Ly9sb2dvLnZlcmlzaWduLmNvbS92c2xvZ28uZ2lmMB0GA1Ud -DgQWBBS2d/ppSEefUxLVwuoHMnYH0ZcHGTANBgkqhkiG9w0BAQsFAAOCAQEASvj4sAPmLGd75JR3 -Y8xuTPl9Dg3cyLk1uXBPY/ok+myDjEedO2Pzmvl2MpWRsXe8rJq+seQxIcaBlVZaDrHC1LGmWazx -Y8u4TB1ZkErvkBYoH1quEPuBUDgMbMzxPcP1Y+Oz4yHJJDnp/RVmRvQbEdBNc6N9Rvk97ahfYtTx -P/jgdFcrGJ2BtMQo2pSXpXDrrB2+BxHw1dvd5Yzw1TKwg+ZX4o+/vqGqvz0dtdQ46tewXDpPaj+P -wGZsY6rp2aQW9IHRlRQOfc2VNNnSj3BzgXucfr2YYdhFh5iQxeuGMMY1v/D/w1WIg0vvBZIGcfK4 -mJO37M2CYfE45k+XmCpajQ== ------END CERTIFICATE----- - -VeriSign Class 3 Public Primary Certification Authority - G4 -============================================================ ------BEGIN CERTIFICATE----- -MIIDhDCCAwqgAwIBAgIQL4D+I4wOIg9IZxIokYesszAKBggqhkjOPQQDAzCByjELMAkGA1UEBhMC -VVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQLExZWZXJpU2lnbiBUcnVzdCBOZXR3 -b3JrMTowOAYDVQQLEzEoYykgMjAwNyBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRob3JpemVkIHVz -ZSBvbmx5MUUwQwYDVQQDEzxWZXJpU2lnbiBDbGFzcyAzIFB1YmxpYyBQcmltYXJ5IENlcnRpZmlj -YXRpb24gQXV0aG9yaXR5IC0gRzQwHhcNMDcxMTA1MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCByjEL -MAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQLExZWZXJpU2lnbiBU -cnVzdCBOZXR3b3JrMTowOAYDVQQLEzEoYykgMjAwNyBWZXJpU2lnbiwgSW5jLiAtIEZvciBhdXRo -b3JpemVkIHVzZSBvbmx5MUUwQwYDVQQDEzxWZXJpU2lnbiBDbGFzcyAzIFB1YmxpYyBQcmltYXJ5 -IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzQwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASnVnp8 -Utpkmw4tXNherJI9/gHmGUo9FANL+mAnINmDiWn6VMaaGF5VKmTeBvaNSjutEDxlPZCIBIngMGGz -rl0Bp3vefLK+ymVhAIau2o970ImtTR1ZmkGxvEeA3J5iw/mjgbIwga8wDwYDVR0TAQH/BAUwAwEB -/zAOBgNVHQ8BAf8EBAMCAQYwbQYIKwYBBQUHAQwEYTBfoV2gWzBZMFcwVRYJaW1hZ2UvZ2lmMCEw -HzAHBgUrDgMCGgQUj+XTGoasjY5rw8+AatRIGCx7GS4wJRYjaHR0cDovL2xvZ28udmVyaXNpZ24u -Y29tL3ZzbG9nby5naWYwHQYDVR0OBBYEFLMWkf3upm7ktS5Jj4d4gYDs5bG1MAoGCCqGSM49BAMD -A2gAMGUCMGYhDBgmYFo4e1ZC4Kf8NoRRkSAsdk1DPcQdhCPQrNZ8NQbOzWm9kA3bbEhCHQ6qQgIx -AJw9SDkjOVgaFRJZap7v1VmyHVIsmXHNxynfGyphe3HR3vPA5Q06Sqotp9iGKt0uEA== ------END CERTIFICATE----- - -NetLock Arany (Class Gold) F?tan?s?tv?ny -======================================== ------BEGIN CERTIFICATE----- -MIIEFTCCAv2gAwIBAgIGSUEs5AAQMA0GCSqGSIb3DQEBCwUAMIGnMQswCQYDVQQGEwJIVTERMA8G -A1UEBwwIQnVkYXBlc3QxFTATBgNVBAoMDE5ldExvY2sgS2Z0LjE3MDUGA1UECwwuVGFuw7pzw610 -dsOhbnlraWFkw7NrIChDZXJ0aWZpY2F0aW9uIFNlcnZpY2VzKTE1MDMGA1UEAwwsTmV0TG9jayBB -cmFueSAoQ2xhc3MgR29sZCkgRsWRdGFuw7pzw610dsOhbnkwHhcNMDgxMjExMTUwODIxWhcNMjgx -MjA2MTUwODIxWjCBpzELMAkGA1UEBhMCSFUxETAPBgNVBAcMCEJ1ZGFwZXN0MRUwEwYDVQQKDAxO -ZXRMb2NrIEtmdC4xNzA1BgNVBAsMLlRhbsO6c8OtdHbDoW55a2lhZMOzayAoQ2VydGlmaWNhdGlv -biBTZXJ2aWNlcykxNTAzBgNVBAMMLE5ldExvY2sgQXJhbnkgKENsYXNzIEdvbGQpIEbFkXRhbsO6 -c8OtdHbDoW55MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxCRec75LbRTDofTjl5Bu -0jBFHjzuZ9lk4BqKf8owyoPjIMHj9DrTlF8afFttvzBPhCf2nx9JvMaZCpDyD/V/Q4Q3Y1GLeqVw -/HpYzY6b7cNGbIRwXdrzAZAj/E4wqX7hJ2Pn7WQ8oLjJM2P+FpD/sLj916jAwJRDC7bVWaaeVtAk -H3B5r9s5VA1lddkVQZQBr17s9o3x/61k/iCa11zr/qYfCGSji3ZVrR47KGAuhyXoqq8fxmRGILdw -fzzeSNuWU7c5d+Qa4scWhHaXWy+7GRWF+GmF9ZmnqfI0p6m2pgP8b4Y9VHx2BJtr+UBdADTHLpl1 -neWIA6pN+APSQnbAGwIDAKiLo0UwQzASBgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIB -BjAdBgNVHQ4EFgQUzPpnk/C2uNClwB7zU/2MU9+D15YwDQYJKoZIhvcNAQELBQADggEBAKt/7hwW -qZw8UQCgwBEIBaeZ5m8BiFRhbvG5GK1Krf6BQCOUL/t1fC8oS2IkgYIL9WHxHG64YTjrgfpioTta -YtOUZcTh5m2C+C8lcLIhJsFyUR+MLMOEkMNaj7rP9KdlpeuY0fsFskZ1FSNqb4VjMIDw1Z4fKRzC -bLBQWV2QWzuoDTDPv31/zvGdg73JRm4gpvlhUbohL3u+pRVjodSVh/GeufOJ8z2FuLjbvrW5Kfna -NwUASZQDhETnv0Mxz3WLJdH0pmT1kvarBes96aULNmLazAZfNou2XjG4Kvte9nHfRCaexOYNkbQu -dZWAUWpLMKawYqGT8ZvYzsRjdT9ZR7E= ------END CERTIFICATE----- - -Staat der Nederlanden Root CA - G2 -================================== ------BEGIN CERTIFICATE----- -MIIFyjCCA7KgAwIBAgIEAJiWjDANBgkqhkiG9w0BAQsFADBaMQswCQYDVQQGEwJOTDEeMBwGA1UE -CgwVU3RhYXQgZGVyIE5lZGVybGFuZGVuMSswKQYDVQQDDCJTdGFhdCBkZXIgTmVkZXJsYW5kZW4g -Um9vdCBDQSAtIEcyMB4XDTA4MDMyNjExMTgxN1oXDTIwMDMyNTExMDMxMFowWjELMAkGA1UEBhMC -TkwxHjAcBgNVBAoMFVN0YWF0IGRlciBOZWRlcmxhbmRlbjErMCkGA1UEAwwiU3RhYXQgZGVyIE5l -ZGVybGFuZGVuIFJvb3QgQ0EgLSBHMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMVZ -5291qj5LnLW4rJ4L5PnZyqtdj7U5EILXr1HgO+EASGrP2uEGQxGZqhQlEq0i6ABtQ8SpuOUfiUtn -vWFI7/3S4GCI5bkYYCjDdyutsDeqN95kWSpGV+RLufg3fNU254DBtvPUZ5uW6M7XxgpT0GtJlvOj -CwV3SPcl5XCsMBQgJeN/dVrlSPhOewMHBPqCYYdu8DvEpMfQ9XQ+pV0aCPKbJdL2rAQmPlU6Yiil -e7Iwr/g3wtG61jj99O9JMDeZJiFIhQGp5Rbn3JBV3w/oOM2ZNyFPXfUib2rFEhZgF1XyZWampzCR -OME4HYYEhLoaJXhena/MUGDWE4dS7WMfbWV9whUYdMrhfmQpjHLYFhN9C0lK8SgbIHRrxT3dsKpI -CT0ugpTNGmXZK4iambwYfp/ufWZ8Pr2UuIHOzZgweMFvZ9C+X+Bo7d7iscksWXiSqt8rYGPy5V65 -48r6f1CGPqI0GAwJaCgRHOThuVw+R7oyPxjMW4T182t0xHJ04eOLoEq9jWYv6q012iDTiIJh8BIi -trzQ1aTsr1SIJSQ8p22xcik/Plemf1WvbibG/ufMQFxRRIEKeN5KzlW/HdXZt1bv8Hb/C3m1r737 -qWmRRpdogBQ2HbN/uymYNqUg+oJgYjOk7Na6B6duxc8UpufWkjTYgfX8HV2qXB72o007uPc5AgMB -AAGjgZcwgZQwDwYDVR0TAQH/BAUwAwEB/zBSBgNVHSAESzBJMEcGBFUdIAAwPzA9BggrBgEFBQcC -ARYxaHR0cDovL3d3dy5wa2lvdmVyaGVpZC5ubC9wb2xpY2llcy9yb290LXBvbGljeS1HMjAOBgNV -HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJFoMocVHYnitfGsNig0jQt8YojrMA0GCSqGSIb3DQEBCwUA -A4ICAQCoQUpnKpKBglBu4dfYszk78wIVCVBR7y29JHuIhjv5tLySCZa59sCrI2AGeYwRTlHSeYAz -+51IvuxBQ4EffkdAHOV6CMqqi3WtFMTC6GY8ggen5ieCWxjmD27ZUD6KQhgpxrRW/FYQoAUXvQwj -f/ST7ZwaUb7dRUG/kSS0H4zpX897IZmflZ85OkYcbPnNe5yQzSipx6lVu6xiNGI1E0sUOlWDuYaN -kqbG9AclVMwWVxJKgnjIFNkXgiYtXSAfea7+1HAWFpWD2DU5/1JddRwWxRNVz0fMdWVSSt7wsKfk -CpYL+63C4iWEst3kvX5ZbJvw8NjnyvLplzh+ib7M+zkXYT9y2zqR2GUBGR2tUKRXCnxLvJxxcypF -URmFzI79R6d0lR2o0a9OF7FpJsKqeFdbxU2n5Z4FF5TKsl+gSRiNNOkmbEgeqmiSBeGCc1qb3Adb -CG19ndeNIdn8FCCqwkXfP+cAslHkwvgFuXkajDTznlvkN1trSt8sV4pAWja63XVECDdCcAz+3F4h -oKOKwJCcaNpQ5kUQR3i2TtJlycM33+FCY7BXN0Ute4qcvwXqZVUz9zkQxSgqIXobisQk+T8VyJoV -IPVVYpbtbZNQvOSqeK3Zywplh6ZmwcSBo3c6WB4L7oOLnR7SUqTMHW+wmG2UMbX4cQrcufx9MmDm -66+KAQ== ------END CERTIFICATE----- - -Hongkong Post Root CA 1 -======================= ------BEGIN CERTIFICATE----- -MIIDMDCCAhigAwIBAgICA+gwDQYJKoZIhvcNAQEFBQAwRzELMAkGA1UEBhMCSEsxFjAUBgNVBAoT -DUhvbmdrb25nIFBvc3QxIDAeBgNVBAMTF0hvbmdrb25nIFBvc3QgUm9vdCBDQSAxMB4XDTAzMDUx -NTA1MTMxNFoXDTIzMDUxNTA0NTIyOVowRzELMAkGA1UEBhMCSEsxFjAUBgNVBAoTDUhvbmdrb25n -IFBvc3QxIDAeBgNVBAMTF0hvbmdrb25nIFBvc3QgUm9vdCBDQSAxMIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEArP84tulmAknjorThkPlAj3n54r15/gK97iSSHSL22oVyaf7XPwnU3ZG1 -ApzQjVrhVcNQhrkpJsLj2aDxaQMoIIBFIi1WpztUlVYiWR8o3x8gPW2iNr4joLFutbEnPzlTCeqr -auh0ssJlXI6/fMN4hM2eFvz1Lk8gKgifd/PFHsSaUmYeSF7jEAaPIpjhZY4bXSNmO7ilMlHIhqqh -qZ5/dpTCpmy3QfDVyAY45tQM4vM7TG1QjMSDJ8EThFk9nnV0ttgCXjqQesBCNnLsak3c78QA3xMY -V18meMjWCnl3v/evt3a5pQuEF10Q6m/hq5URX208o1xNg1vysxmKgIsLhwIDAQABoyYwJDASBgNV -HRMBAf8ECDAGAQH/AgEDMA4GA1UdDwEB/wQEAwIBxjANBgkqhkiG9w0BAQUFAAOCAQEADkbVPK7i -h9legYsCmEEIjEy82tvuJxuC52pF7BaLT4Wg87JwvVqWuspube5Gi27nKi6Wsxkz67SfqLI37pio -l7Yutmcn1KZJ/RyTZXaeQi/cImyaT/JaFTmxcdcrUehtHJjA2Sr0oYJ71clBoiMBdDhViw+5Lmei -IAQ32pwL0xch4I+XeTRvhEgCIDMb5jREn5Fw9IBehEPCKdJsEhTkYY2sEJCehFC78JZvRZ+K88ps -T/oROhUVRsPNH4NbLUES7VBnQRM9IauUiqpOfMGx+6fWtScvl6tu4B3i0RwsH0Ti/L6RoZz71ilT -c4afU9hDDl3WY4JxHYB0yvbiAmvZWg== ------END CERTIFICATE----- - -SecureSign RootCA11 -=================== ------BEGIN CERTIFICATE----- -MIIDbTCCAlWgAwIBAgIBATANBgkqhkiG9w0BAQUFADBYMQswCQYDVQQGEwJKUDErMCkGA1UEChMi -SmFwYW4gQ2VydGlmaWNhdGlvbiBTZXJ2aWNlcywgSW5jLjEcMBoGA1UEAxMTU2VjdXJlU2lnbiBS -b290Q0ExMTAeFw0wOTA0MDgwNDU2NDdaFw0yOTA0MDgwNDU2NDdaMFgxCzAJBgNVBAYTAkpQMSsw -KQYDVQQKEyJKYXBhbiBDZXJ0aWZpY2F0aW9uIFNlcnZpY2VzLCBJbmMuMRwwGgYDVQQDExNTZWN1 -cmVTaWduIFJvb3RDQTExMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA/XeqpRyQBTvL -TJszi1oURaTnkBbR31fSIRCkF/3frNYfp+TbfPfs37gD2pRY/V1yfIw/XwFndBWW4wI8h9uuywGO -wvNmxoVF9ALGOrVisq/6nL+k5tSAMJjzDbaTj6nU2DbysPyKyiyhFTOVMdrAG/LuYpmGYz+/3ZMq -g6h2uRMft85OQoWPIucuGvKVCbIFtUROd6EgvanyTgp9UK31BQ1FT0Zx/Sg+U/sE2C3XZR1KG/rP -O7AxmjVuyIsG0wCR8pQIZUyxNAYAeoni8McDWc/V1uinMrPmmECGxc0nEovMe863ETxiYAcjPitA -bpSACW22s293bzUIUPsCh8U+iQIDAQABo0IwQDAdBgNVHQ4EFgQUW/hNT7KlhtQ60vFjmqC+CfZX -t94wDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAKCh -OBZmLqdWHyGcBvod7bkixTgm2E5P7KN/ed5GIaGHd48HCJqypMWvDzKYC3xmKbabfSVSSUOrTC4r -bnpwrxYO4wJs+0LmGJ1F2FXI6Dvd5+H0LgscNFxsWEr7jIhQX5Ucv+2rIrVls4W6ng+4reV6G4pQ -Oh29Dbx7VFALuUKvVaAYga1lme++5Jy/xIWrQbJUb9wlze144o4MjQlJ3WN7WmmWAiGovVJZ6X01 -y8hSyn+B/tlr0/cR7SXf+Of5pPpyl4RTDaXQMhhRdlkUbA/r7F+AjHVDg8OFmP9Mni0N5HeDk061 -lgeLKBObjBmNQSdJQO7e5iNEOdyhIta6A/I= ------END CERTIFICATE----- - -ACEDICOM Root -============= ------BEGIN CERTIFICATE----- -MIIFtTCCA52gAwIBAgIIYY3HhjsBggUwDQYJKoZIhvcNAQEFBQAwRDEWMBQGA1UEAwwNQUNFRElD -T00gUm9vdDEMMAoGA1UECwwDUEtJMQ8wDQYDVQQKDAZFRElDT00xCzAJBgNVBAYTAkVTMB4XDTA4 -MDQxODE2MjQyMloXDTI4MDQxMzE2MjQyMlowRDEWMBQGA1UEAwwNQUNFRElDT00gUm9vdDEMMAoG -A1UECwwDUEtJMQ8wDQYDVQQKDAZFRElDT00xCzAJBgNVBAYTAkVTMIICIjANBgkqhkiG9w0BAQEF -AAOCAg8AMIICCgKCAgEA/5KV4WgGdrQsyFhIyv2AVClVYyT/kGWbEHV7w2rbYgIB8hiGtXxaOLHk -WLn709gtn70yN78sFW2+tfQh0hOR2QetAQXW8713zl9CgQr5auODAKgrLlUTY4HKRxx7XBZXehuD -YAQ6PmXDzQHe3qTWDLqO3tkE7hdWIpuPY/1NFgu3e3eM+SW10W2ZEi5PGrjm6gSSrj0RuVFCPYew -MYWveVqc/udOXpJPQ/yrOq2lEiZmueIM15jO1FillUAKt0SdE3QrwqXrIhWYENiLxQSfHY9g5QYb -m8+5eaA9oiM/Qj9r+hwDezCNzmzAv+YbX79nuIQZ1RXve8uQNjFiybwCq0Zfm/4aaJQ0PZCOrfbk -HQl/Sog4P75n/TSW9R28MHTLOO7VbKvU/PQAtwBbhTIWdjPp2KOZnQUAqhbm84F9b32qhm2tFXTT -xKJxqvQUfecyuB+81fFOvW8XAjnXDpVCOscAPukmYxHqC9FK/xidstd7LzrZlvvoHpKuE1XI2Sf2 -3EgbsCTBheN3nZqk8wwRHQ3ItBTutYJXCb8gWH8vIiPYcMt5bMlL8qkqyPyHK9caUPgn6C9D4zq9 -2Fdx/c6mUlv53U3t5fZvie27k5x2IXXwkkwp9y+cAS7+UEaeZAwUswdbxcJzbPEHXEUkFDWug/Fq -TYl6+rPYLWbwNof1K1MCAwEAAaOBqjCBpzAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFKaz -4SsrSbbXc6GqlPUB53NlTKxQMA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUprPhKytJttdzoaqU -9QHnc2VMrFAwRAYDVR0gBD0wOzA5BgRVHSAAMDEwLwYIKwYBBQUHAgEWI2h0dHA6Ly9hY2VkaWNv -bS5lZGljb21ncm91cC5jb20vZG9jMA0GCSqGSIb3DQEBBQUAA4ICAQDOLAtSUWImfQwng4/F9tqg -aHtPkl7qpHMyEVNEskTLnewPeUKzEKbHDZ3Ltvo/Onzqv4hTGzz3gvoFNTPhNahXwOf9jU8/kzJP -eGYDdwdY6ZXIfj7QeQCM8htRM5u8lOk6e25SLTKeI6RF+7YuE7CLGLHdztUdp0J/Vb77W7tH1Pwk -zQSulgUV1qzOMPPKC8W64iLgpq0i5ALudBF/TP94HTXa5gI06xgSYXcGCRZj6hitoocf8seACQl1 -ThCojz2GuHURwCRiipZ7SkXp7FnFvmuD5uHorLUwHv4FB4D54SMNUI8FmP8sX+g7tq3PgbUhh8oI -KiMnMCArz+2UW6yyetLHKKGKC5tNSixthT8Jcjxn4tncB7rrZXtaAWPWkFtPF2Y9fwsZo5NjEFIq -nxQWWOLcpfShFosOkYuByptZ+thrkQdlVV9SH686+5DdaaVbnG0OLLb6zqylfDJKZ0DcMDQj3dcE -I2bw/FWAp/tmGYI1Z2JwOV5vx+qQQEQIHriy1tvuWacNGHk0vFQYXlPKNFHtRQrmjseCNj6nOGOp -MCwXEGCSn1WHElkQwg9naRHMTh5+Spqtr0CodaxWkHS4oJyleW/c6RrIaQXpuvoDs3zk4E7Czp3o -tkYNbn5XOmeUwssfnHdKZ05phkOTOPu220+DkdRgfks+KzgHVZhepA== ------END CERTIFICATE----- - -Microsec e-Szigno Root CA 2009 -============================== ------BEGIN CERTIFICATE----- -MIIECjCCAvKgAwIBAgIJAMJ+QwRORz8ZMA0GCSqGSIb3DQEBCwUAMIGCMQswCQYDVQQGEwJIVTER -MA8GA1UEBwwIQnVkYXBlc3QxFjAUBgNVBAoMDU1pY3Jvc2VjIEx0ZC4xJzAlBgNVBAMMHk1pY3Jv -c2VjIGUtU3ppZ25vIFJvb3QgQ0EgMjAwOTEfMB0GCSqGSIb3DQEJARYQaW5mb0BlLXN6aWduby5o -dTAeFw0wOTA2MTYxMTMwMThaFw0yOTEyMzAxMTMwMThaMIGCMQswCQYDVQQGEwJIVTERMA8GA1UE -BwwIQnVkYXBlc3QxFjAUBgNVBAoMDU1pY3Jvc2VjIEx0ZC4xJzAlBgNVBAMMHk1pY3Jvc2VjIGUt -U3ppZ25vIFJvb3QgQ0EgMjAwOTEfMB0GCSqGSIb3DQEJARYQaW5mb0BlLXN6aWduby5odTCCASIw -DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOn4j/NjrdqG2KfgQvvPkd6mJviZpWNwrZuuyjNA -fW2WbqEORO7hE52UQlKavXWFdCyoDh2Tthi3jCyoz/tccbna7P7ofo/kLx2yqHWH2Leh5TvPmUpG -0IMZfcChEhyVbUr02MelTTMuhTlAdX4UfIASmFDHQWe4oIBhVKZsTh/gnQ4H6cm6M+f+wFUoLAKA -pxn1ntxVUwOXewdI/5n7N4okxFnMUBBjjqqpGrCEGob5X7uxUG6k0QrM1XF+H6cbfPVTbiJfyyvm -1HxdrtbCxkzlBQHZ7Vf8wSN5/PrIJIOV87VqUQHQd9bpEqH5GoP7ghu5sJf0dgYzQ0mg/wu1+rUC -AwEAAaOBgDB+MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTLD8bf -QkPMPcu1SCOhGnqmKrs0aDAfBgNVHSMEGDAWgBTLD8bfQkPMPcu1SCOhGnqmKrs0aDAbBgNVHREE -FDASgRBpbmZvQGUtc3ppZ25vLmh1MA0GCSqGSIb3DQEBCwUAA4IBAQDJ0Q5eLtXMs3w+y/w9/w0o -lZMEyL/azXm4Q5DwpL7v8u8hmLzU1F0G9u5C7DBsoKqpyvGvivo/C3NqPuouQH4frlRheesuCDfX -I/OMn74dseGkddug4lQUsbocKaQY9hK6ohQU4zE1yED/t+AFdlfBHFny+L/k7SViXITwfn4fs775 -tyERzAMBVnCnEJIeGzSBHq2cGsMEPO0CYdYeBvNfOofyK/FFh+U9rNHHV4S9a67c2Pm2G2JwCz02 -yULyMtd6YebS2z3PyKnJm9zbWETXbzivf3jTo60adbocwTZ8jx5tHMN1Rq41Bab2XD0h7lbwyYIi -LXpUq3DDfSJlgnCW ------END CERTIFICATE----- - -GlobalSign Root CA - R3 -======================= ------BEGIN CERTIFICATE----- -MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4GA1UECxMXR2xv -YmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2Jh -bFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxT -aWduIFJvb3QgQ0EgLSBSMzETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2ln -bjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWt -iHL8RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsTgHeMCOFJ -0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmmKPZpO/bLyCiR5Z2KYVc3 -rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zdQQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjl -OCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZXriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2 -xmmFghcCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE -FI/wS3+oLkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZURUm7 -lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMpjjM5RcOO5LlXbKr8 -EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK6fBdRoyV3XpYKBovHd7NADdBj+1E -bddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQXmcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18 -YIvDQVETI53O9zJrlAGomecsMx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7r -kpeDMdmztcpHWD9f ------END CERTIFICATE----- - -Autoridad de Certificacion Firmaprofesional CIF A62634068 -========================================================= ------BEGIN CERTIFICATE----- -MIIGFDCCA/ygAwIBAgIIU+w77vuySF8wDQYJKoZIhvcNAQEFBQAwUTELMAkGA1UEBhMCRVMxQjBA -BgNVBAMMOUF1dG9yaWRhZCBkZSBDZXJ0aWZpY2FjaW9uIEZpcm1hcHJvZmVzaW9uYWwgQ0lGIEE2 -MjYzNDA2ODAeFw0wOTA1MjAwODM4MTVaFw0zMDEyMzEwODM4MTVaMFExCzAJBgNVBAYTAkVTMUIw -QAYDVQQDDDlBdXRvcmlkYWQgZGUgQ2VydGlmaWNhY2lvbiBGaXJtYXByb2Zlc2lvbmFsIENJRiBB -NjI2MzQwNjgwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDKlmuO6vj78aI14H9M2uDD -Utd9thDIAl6zQyrET2qyyhxdKJp4ERppWVevtSBC5IsP5t9bpgOSL/UR5GLXMnE42QQMcas9UX4P -B99jBVzpv5RvwSmCwLTaUbDBPLutN0pcyvFLNg4kq7/DhHf9qFD0sefGL9ItWY16Ck6WaVICqjaY -7Pz6FIMMNx/Jkjd/14Et5cS54D40/mf0PmbR0/RAz15iNA9wBj4gGFrO93IbJWyTdBSTo3OxDqqH -ECNZXyAFGUftaI6SEspd/NYrspI8IM/hX68gvqB2f3bl7BqGYTM+53u0P6APjqK5am+5hyZvQWyI -plD9amML9ZMWGxmPsu2bm8mQ9QEM3xk9Dz44I8kvjwzRAv4bVdZO0I08r0+k8/6vKtMFnXkIoctX -MbScyJCyZ/QYFpM6/EfY0XiWMR+6KwxfXZmtY4laJCB22N/9q06mIqqdXuYnin1oKaPnirjaEbsX -LZmdEyRG98Xi2J+Of8ePdG1asuhy9azuJBCtLxTa/y2aRnFHvkLfuwHb9H/TKI8xWVvTyQKmtFLK -bpf7Q8UIJm+K9Lv9nyiqDdVF8xM6HdjAeI9BZzwelGSuewvF6NkBiDkal4ZkQdU7hwxu+g/GvUgU -vzlN1J5Bto+WHWOWk9mVBngxaJ43BjuAiUVhOSPHG0SjFeUc+JIwuwIDAQABo4HvMIHsMBIGA1Ud -EwEB/wQIMAYBAf8CAQEwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRlzeurNR4APn7VdMActHNH -DhpkLzCBpgYDVR0gBIGeMIGbMIGYBgRVHSAAMIGPMC8GCCsGAQUFBwIBFiNodHRwOi8vd3d3LmZp -cm1hcHJvZmVzaW9uYWwuY29tL2NwczBcBggrBgEFBQcCAjBQHk4AUABhAHMAZQBvACAAZABlACAA -bABhACAAQgBvAG4AYQBuAG8AdgBhACAANAA3ACAAQgBhAHIAYwBlAGwAbwBuAGEAIAAwADgAMAAx -ADcwDQYJKoZIhvcNAQEFBQADggIBABd9oPm03cXF661LJLWhAqvdpYhKsg9VSytXjDvlMd3+xDLx -51tkljYyGOylMnfX40S2wBEqgLk9am58m9Ot/MPWo+ZkKXzR4Tgegiv/J2Wv+xYVxC5xhOW1//qk -R71kMrv2JYSiJ0L1ILDCExARzRAVukKQKtJE4ZYm6zFIEv0q2skGz3QeqUvVhyj5eTSSPi5E6PaP -T481PyWzOdxjKpBrIF/EUhJOlywqrJ2X3kjyo2bbwtKDlaZmp54lD+kLM5FlClrD2VQS3a/DTg4f -Jl4N3LON7NWBcN7STyQF82xO9UxJZo3R/9ILJUFI/lGExkKvgATP0H5kSeTy36LssUzAKh3ntLFl -osS88Zj0qnAHY7S42jtM+kAiMFsRpvAFDsYCA0irhpuF3dvd6qJ2gHN99ZwExEWN57kci57q13XR -crHedUTnQn3iV2t93Jm8PYMo6oCTjcVMZcFwgbg4/EMxsvYDNEeyrPsiBsse3RdHHF9mudMaotoR -saS8I8nkvof/uZS2+F0gStRf571oe2XyFR7SOqkt6dhrJKyXWERHrVkY8SFlcN7ONGCoQPHzPKTD -KCOM/iczQ0CgFzzr6juwcqajuUpLXhZI9LK8yIySxZ2frHI2vDSANGupi5LAuBft7HZT9SQBjLMi -6Et8Vcad+qMUu2WFbm5PEn4KPJ2V ------END CERTIFICATE----- - -Izenpe.com -========== ------BEGIN CERTIFICATE----- -MIIF8TCCA9mgAwIBAgIQALC3WhZIX7/hy/WL1xnmfTANBgkqhkiG9w0BAQsFADA4MQswCQYDVQQG -EwJFUzEUMBIGA1UECgwLSVpFTlBFIFMuQS4xEzARBgNVBAMMCkl6ZW5wZS5jb20wHhcNMDcxMjEz -MTMwODI4WhcNMzcxMjEzMDgyNzI1WjA4MQswCQYDVQQGEwJFUzEUMBIGA1UECgwLSVpFTlBFIFMu -QS4xEzARBgNVBAMMCkl6ZW5wZS5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDJ -03rKDx6sp4boFmVqscIbRTJxldn+EFvMr+eleQGPicPK8lVx93e+d5TzcqQsRNiekpsUOqHnJJAK -ClaOxdgmlOHZSOEtPtoKct2jmRXagaKH9HtuJneJWK3W6wyyQXpzbm3benhB6QiIEn6HLmYRY2xU -+zydcsC8Lv/Ct90NduM61/e0aL6i9eOBbsFGb12N4E3GVFWJGjMxCrFXuaOKmMPsOzTFlUFpfnXC -PCDFYbpRR6AgkJOhkEvzTnyFRVSa0QUmQbC1TR0zvsQDyCV8wXDbO/QJLVQnSKwv4cSsPsjLkkxT -OTcj7NMB+eAJRE1NZMDhDVqHIrytG6P+JrUV86f8hBnp7KGItERphIPzidF0BqnMC9bC3ieFUCbK -F7jJeodWLBoBHmy+E60QrLUk9TiRodZL2vG70t5HtfG8gfZZa88ZU+mNFctKy6lvROUbQc/hhqfK -0GqfvEyNBjNaooXlkDWgYlwWTvDjovoDGrQscbNYLN57C9saD+veIR8GdwYDsMnvmfzAuU8Lhij+ -0rnq49qlw0dpEuDb8PYZi+17cNcC1u2HGCgsBCRMd+RIihrGO5rUD8r6ddIBQFqNeb+Lz0vPqhbB -leStTIo+F5HUsWLlguWABKQDfo2/2n+iD5dPDNMN+9fR5XJ+HMh3/1uaD7euBUbl8agW7EekFwID -AQABo4H2MIHzMIGwBgNVHREEgagwgaWBD2luZm9AaXplbnBlLmNvbaSBkTCBjjFHMEUGA1UECgw+ -SVpFTlBFIFMuQS4gLSBDSUYgQTAxMzM3MjYwLVJNZXJjLlZpdG9yaWEtR2FzdGVpeiBUMTA1NSBG -NjIgUzgxQzBBBgNVBAkMOkF2ZGEgZGVsIE1lZGl0ZXJyYW5lbyBFdG9yYmlkZWEgMTQgLSAwMTAx -MCBWaXRvcmlhLUdhc3RlaXowDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0O -BBYEFB0cZQ6o8iV7tJHP5LGx5r1VdGwFMA0GCSqGSIb3DQEBCwUAA4ICAQB4pgwWSp9MiDrAyw6l -Fn2fuUhfGI8NYjb2zRlrrKvV9pF9rnHzP7MOeIWblaQnIUdCSnxIOvVFfLMMjlF4rJUT3sb9fbga -kEyrkgPH7UIBzg/YsfqikuFgba56awmqxinuaElnMIAkejEWOVt+8Rwu3WwJrfIxwYJOubv5vr8q -hT/AQKM6WfxZSzwoJNu0FXWuDYi6LnPAvViH5ULy617uHjAimcs30cQhbIHsvm0m5hzkQiCeR7Cs -g1lwLDXWrzY0tM07+DKo7+N4ifuNRSzanLh+QBxh5z6ikixL8s36mLYp//Pye6kfLqCTVyvehQP5 -aTfLnnhqBbTFMXiJ7HqnheG5ezzevh55hM6fcA5ZwjUukCox2eRFekGkLhObNA5me0mrZJfQRsN5 -nXJQY6aYWwa9SG3YOYNw6DXwBdGqvOPbyALqfP2C2sJbUjWumDqtujWTI6cfSN01RpiyEGjkpTHC -ClguGYEQyVB1/OpaFs4R1+7vUIgtYf8/QnMFlEPVjjxOAToZpR9GTnfQXeWBIiGH/pR9hNiTrdZo -Q0iy2+tzJOeRf1SktoA+naM8THLCV8Sg1Mw4J87VBp6iSNnpn86CcDaTmjvfliHjWbcM2pE38P1Z -WrOZyGlsQyYBNWNgVYkDOnXYukrZVP/u3oDYLdE41V4tC5h9Pmzb/CaIxw== ------END CERTIFICATE----- - -Chambers of Commerce Root - 2008 -================================ ------BEGIN CERTIFICATE----- -MIIHTzCCBTegAwIBAgIJAKPaQn6ksa7aMA0GCSqGSIb3DQEBBQUAMIGuMQswCQYDVQQGEwJFVTFD -MEEGA1UEBxM6TWFkcmlkIChzZWUgY3VycmVudCBhZGRyZXNzIGF0IHd3dy5jYW1lcmZpcm1hLmNv -bS9hZGRyZXNzKTESMBAGA1UEBRMJQTgyNzQzMjg3MRswGQYDVQQKExJBQyBDYW1lcmZpcm1hIFMu -QS4xKTAnBgNVBAMTIENoYW1iZXJzIG9mIENvbW1lcmNlIFJvb3QgLSAyMDA4MB4XDTA4MDgwMTEy -Mjk1MFoXDTM4MDczMTEyMjk1MFowga4xCzAJBgNVBAYTAkVVMUMwQQYDVQQHEzpNYWRyaWQgKHNl -ZSBjdXJyZW50IGFkZHJlc3MgYXQgd3d3LmNhbWVyZmlybWEuY29tL2FkZHJlc3MpMRIwEAYDVQQF -EwlBODI3NDMyODcxGzAZBgNVBAoTEkFDIENhbWVyZmlybWEgUy5BLjEpMCcGA1UEAxMgQ2hhbWJl -cnMgb2YgQ29tbWVyY2UgUm9vdCAtIDIwMDgwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC -AQCvAMtwNyuAWko6bHiUfaN/Gh/2NdW928sNRHI+JrKQUrpjOyhYb6WzbZSm891kDFX29ufyIiKA -XuFixrYp4YFs8r/lfTJqVKAyGVn+H4vXPWCGhSRv4xGzdz4gljUha7MI2XAuZPeEklPWDrCQiorj -h40G072QDuKZoRuGDtqaCrsLYVAGUvGef3bsyw/QHg3PmTA9HMRFEFis1tPo1+XqxQEHd9ZR5gN/ -ikilTWh1uem8nk4ZcfUyS5xtYBkL+8ydddy/Js2Pk3g5eXNeJQ7KXOt3EgfLZEFHcpOrUMPrCXZk -NNI5t3YRCQ12RcSprj1qr7V9ZS+UWBDsXHyvfuK2GNnQm05aSd+pZgvMPMZ4fKecHePOjlO+Bd5g -D2vlGts/4+EhySnB8esHnFIbAURRPHsl18TlUlRdJQfKFiC4reRB7noI/plvg6aRArBsNlVq5331 -lubKgdaX8ZSD6e2wsWsSaR6s+12pxZjptFtYer49okQ6Y1nUCyXeG0+95QGezdIp1Z8XGQpvvwyQ -0wlf2eOKNcx5Wk0ZN5K3xMGtr/R5JJqyAQuxr1yW84Ay+1w9mPGgP0revq+ULtlVmhduYJ1jbLhj -ya6BXBg14JC7vjxPNyK5fuvPnnchpj04gftI2jE9K+OJ9dC1vX7gUMQSibMjmhAxhduub+84Mxh2 -EQIDAQABo4IBbDCCAWgwEgYDVR0TAQH/BAgwBgEB/wIBDDAdBgNVHQ4EFgQU+SSsD7K1+HnA+mCI -G8TZTQKeFxkwgeMGA1UdIwSB2zCB2IAU+SSsD7K1+HnA+mCIG8TZTQKeFxmhgbSkgbEwga4xCzAJ -BgNVBAYTAkVVMUMwQQYDVQQHEzpNYWRyaWQgKHNlZSBjdXJyZW50IGFkZHJlc3MgYXQgd3d3LmNh -bWVyZmlybWEuY29tL2FkZHJlc3MpMRIwEAYDVQQFEwlBODI3NDMyODcxGzAZBgNVBAoTEkFDIENh -bWVyZmlybWEgUy5BLjEpMCcGA1UEAxMgQ2hhbWJlcnMgb2YgQ29tbWVyY2UgUm9vdCAtIDIwMDiC -CQCj2kJ+pLGu2jAOBgNVHQ8BAf8EBAMCAQYwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUH -AgEWHGh0dHA6Ly9wb2xpY3kuY2FtZXJmaXJtYS5jb20wDQYJKoZIhvcNAQEFBQADggIBAJASryI1 -wqM58C7e6bXpeHxIvj99RZJe6dqxGfwWPJ+0W2aeaufDuV2I6A+tzyMP3iU6XsxPpcG1Lawk0lgH -3qLPaYRgM+gQDROpI9CF5Y57pp49chNyM/WqfcZjHwj0/gF/JM8rLFQJ3uIrbZLGOU8W6jx+ekbU -RWpGqOt1glanq6B8aBMz9p0w8G8nOSQjKpD9kCk18pPfNKXG9/jvjA9iSnyu0/VU+I22mlaHFoI6 -M6taIgj3grrqLuBHmrS1RaMFO9ncLkVAO+rcf+g769HsJtg1pDDFOqxXnrN2pSB7+R5KBWIBpih1 -YJeSDW4+TTdDDZIVnBgizVGZoCkaPF+KMjNbMMeJL0eYD6MDxvbxrN8y8NmBGuScvfaAFPDRLLmF -9dijscilIeUcE5fuDr3fKanvNFNb0+RqE4QGtjICxFKuItLcsiFCGtpA8CnJ7AoMXOLQusxI0zcK -zBIKinmwPQN/aUv0NCB9szTqjktk9T79syNnFQ0EuPAtwQlRPLJsFfClI9eDdOTlLsn+mCdCxqvG -nrDQWzilm1DefhiYtUU79nm06PcaewaD+9CL2rvHvRirCG88gGtAPxkZumWK5r7VXNM21+9AUiRg -OGcEMeyP84LG3rlV8zsxkVrctQgVrXYlCg17LofiDKYGvCYQbTed7N14jHyAxfDZd0jQ ------END CERTIFICATE----- - -Global Chambersign Root - 2008 -============================== ------BEGIN CERTIFICATE----- -MIIHSTCCBTGgAwIBAgIJAMnN0+nVfSPOMA0GCSqGSIb3DQEBBQUAMIGsMQswCQYDVQQGEwJFVTFD -MEEGA1UEBxM6TWFkcmlkIChzZWUgY3VycmVudCBhZGRyZXNzIGF0IHd3dy5jYW1lcmZpcm1hLmNv -bS9hZGRyZXNzKTESMBAGA1UEBRMJQTgyNzQzMjg3MRswGQYDVQQKExJBQyBDYW1lcmZpcm1hIFMu -QS4xJzAlBgNVBAMTHkdsb2JhbCBDaGFtYmVyc2lnbiBSb290IC0gMjAwODAeFw0wODA4MDExMjMx -NDBaFw0zODA3MzExMjMxNDBaMIGsMQswCQYDVQQGEwJFVTFDMEEGA1UEBxM6TWFkcmlkIChzZWUg -Y3VycmVudCBhZGRyZXNzIGF0IHd3dy5jYW1lcmZpcm1hLmNvbS9hZGRyZXNzKTESMBAGA1UEBRMJ -QTgyNzQzMjg3MRswGQYDVQQKExJBQyBDYW1lcmZpcm1hIFMuQS4xJzAlBgNVBAMTHkdsb2JhbCBD -aGFtYmVyc2lnbiBSb290IC0gMjAwODCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMDf -VtPkOpt2RbQT2//BthmLN0EYlVJH6xedKYiONWwGMi5HYvNJBL99RDaxccy9Wglz1dmFRP+RVyXf -XjaOcNFccUMd2drvXNL7G706tcuto8xEpw2uIRU/uXpbknXYpBI4iRmKt4DS4jJvVpyR1ogQC7N0 -ZJJ0YPP2zxhPYLIj0Mc7zmFLmY/CDNBAspjcDahOo7kKrmCgrUVSY7pmvWjg+b4aqIG7HkF4ddPB -/gBVsIdU6CeQNR1MM62X/JcumIS/LMmjv9GYERTtY/jKmIhYF5ntRQOXfjyGHoiMvvKRhI9lNNgA -TH23MRdaKXoKGCQwoze1eqkBfSbW+Q6OWfH9GzO1KTsXO0G2Id3UwD2ln58fQ1DJu7xsepeY7s2M -H/ucUa6LcL0nn3HAa6x9kGbo1106DbDVwo3VyJ2dwW3Q0L9R5OP4wzg2rtandeavhENdk5IMagfe -Ox2YItaswTXbo6Al/3K1dh3ebeksZixShNBFks4c5eUzHdwHU1SjqoI7mjcv3N2gZOnm3b2u/GSF -HTynyQbehP9r6GsaPMWis0L7iwk+XwhSx2LE1AVxv8Rk5Pihg+g+EpuoHtQ2TS9x9o0o9oOpE9Jh -wZG7SMA0j0GMS0zbaRL/UJScIINZc+18ofLx/d33SdNDWKBWY8o9PeU1VlnpDsogzCtLkykPAgMB -AAGjggFqMIIBZjASBgNVHRMBAf8ECDAGAQH/AgEMMB0GA1UdDgQWBBS5CcqcHtvTbDprru1U8VuT -BjUuXjCB4QYDVR0jBIHZMIHWgBS5CcqcHtvTbDprru1U8VuTBjUuXqGBsqSBrzCBrDELMAkGA1UE -BhMCRVUxQzBBBgNVBAcTOk1hZHJpZCAoc2VlIGN1cnJlbnQgYWRkcmVzcyBhdCB3d3cuY2FtZXJm -aXJtYS5jb20vYWRkcmVzcykxEjAQBgNVBAUTCUE4Mjc0MzI4NzEbMBkGA1UEChMSQUMgQ2FtZXJm -aXJtYSBTLkEuMScwJQYDVQQDEx5HbG9iYWwgQ2hhbWJlcnNpZ24gUm9vdCAtIDIwMDiCCQDJzdPp -1X0jzjAOBgNVHQ8BAf8EBAMCAQYwPQYDVR0gBDYwNDAyBgRVHSAAMCowKAYIKwYBBQUHAgEWHGh0 -dHA6Ly9wb2xpY3kuY2FtZXJmaXJtYS5jb20wDQYJKoZIhvcNAQEFBQADggIBAICIf3DekijZBZRG -/5BXqfEv3xoNa/p8DhxJJHkn2EaqbylZUohwEurdPfWbU1Rv4WCiqAm57OtZfMY18dwY6fFn5a+6 -ReAJ3spED8IXDneRRXozX1+WLGiLwUePmJs9wOzL9dWCkoQ10b42OFZyMVtHLaoXpGNR6woBrX/s -dZ7LoR/xfxKxueRkf2fWIyr0uDldmOghp+G9PUIadJpwr2hsUF1Jz//7Dl3mLEfXgTpZALVza2Mg -9jFFCDkO9HB+QHBaP9BrQql0PSgvAm11cpUJjUhjxsYjV5KTXjXBjfkK9yydYhz2rXzdpjEetrHH -foUm+qRqtdpjMNHvkzeyZi99Bffnt0uYlDXA2TopwZ2yUDMdSqlapskD7+3056huirRXhOukP9Du -qqqHW2Pok+JrqNS4cnhrG+055F3Lm6qH1U9OAP7Zap88MQ8oAgF9mOinsKJknnn4SPIVqczmyETr -P3iZ8ntxPjzxmKfFGBI/5rsoM0LpRQp8bfKGeS/Fghl9CYl8slR2iK7ewfPM4W7bMdaTrpmg7yVq -c5iJWzouE4gev8CSlDQb4ye3ix5vQv/n6TebUB0tovkC7stYWDpxvGjjqsGvHCgfotwjZT+B6q6Z -09gwzxMNTxXJhLynSC34MCN32EZLeW32jO06f2ARePTpm67VVMB0gNELQp/B ------END CERTIFICATE----- - -Go Daddy Root Certificate Authority - G2 -======================================== ------BEGIN CERTIFICATE----- -MIIDxTCCAq2gAwIBAgIBADANBgkqhkiG9w0BAQsFADCBgzELMAkGA1UEBhMCVVMxEDAOBgNVBAgT -B0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxGjAYBgNVBAoTEUdvRGFkZHkuY29tLCBJbmMu -MTEwLwYDVQQDEyhHbyBEYWRkeSBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5 -MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgYMxCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6 -b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMRowGAYDVQQKExFHb0RhZGR5LmNvbSwgSW5jLjExMC8G -A1UEAxMoR28gRGFkZHkgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZI -hvcNAQEBBQADggEPADCCAQoCggEBAL9xYgjx+lk09xvJGKP3gElY6SKDE6bFIEMBO4Tx5oVJnyfq -9oQbTqC023CYxzIBsQU+B07u9PpPL1kwIuerGVZr4oAH/PMWdYA5UXvl+TW2dE6pjYIT5LY/qQOD -+qK+ihVqf94Lw7YZFAXK6sOoBJQ7RnwyDfMAZiLIjWltNowRGLfTshxgtDj6AozO091GB94KPutd -fMh8+7ArU6SSYmlRJQVhGkSBjCypQ5Yj36w6gZoOKcUcqeldHraenjAKOc7xiID7S13MMuyFYkMl -NAJWJwGRtDtwKj9useiciAF9n9T521NtYJ2/LOdYq7hfRvzOxBsDPAnrSTFcaUaz4EcCAwEAAaNC -MEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFDqahQcQZyi27/a9 -BUFuIMGU2g/eMA0GCSqGSIb3DQEBCwUAA4IBAQCZ21151fmXWWcDYfF+OwYxdS2hII5PZYe096ac -vNjpL9DbWu7PdIxztDhC2gV7+AJ1uP2lsdeu9tfeE8tTEH6KRtGX+rcuKxGrkLAngPnon1rpN5+r -5N9ss4UXnT3ZJE95kTXWXwTrgIOrmgIttRD02JDHBHNA7XIloKmf7J6raBKZV8aPEjoJpL1E/QYV -N8Gb5DKj7Tjo2GTzLH4U/ALqn83/B2gX2yKQOC16jdFU8WnjXzPKej17CuPKf1855eJ1usV2GDPO -LPAvTK33sefOT6jEm0pUBsV/fdUID+Ic/n4XuKxe9tQWskMJDE32p2u0mYRlynqI4uJEvlz36hz1 ------END CERTIFICATE----- - -Starfield Root Certificate Authority - G2 -========================================= ------BEGIN CERTIFICATE----- -MIID3TCCAsWgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMxEDAOBgNVBAgT -B0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNobm9s -b2dpZXMsIEluYy4xMjAwBgNVBAMTKVN0YXJmaWVsZCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0 -eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgY8xCzAJBgNVBAYTAlVTMRAw -DgYDVQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFyZmllbGQg -VGVjaG5vbG9naWVzLCBJbmMuMTIwMAYDVQQDEylTdGFyZmllbGQgUm9vdCBDZXJ0aWZpY2F0ZSBB -dXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL3twQP89o/8ArFv -W59I2Z154qK3A2FWGMNHttfKPTUuiUP3oWmb3ooa/RMgnLRJdzIpVv257IzdIvpy3Cdhl+72WoTs -bhm5iSzchFvVdPtrX8WJpRBSiUZV9Lh1HOZ/5FSuS/hVclcCGfgXcVnrHigHdMWdSL5stPSksPNk -N3mSwOxGXn/hbVNMYq/NHwtjuzqd+/x5AJhhdM8mgkBj87JyahkNmcrUDnXMN/uLicFZ8WJ/X7Nf -ZTD4p7dNdloedl40wOiWVpmKs/B/pM293DIxfJHP4F8R+GuqSVzRmZTRouNjWwl2tVZi4Ut0HZbU -JtQIBFnQmA4O5t78w+wfkPECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC -AQYwHQYDVR0OBBYEFHwMMh+n2TB/xH1oo2Kooc6rB1snMA0GCSqGSIb3DQEBCwUAA4IBAQARWfol -TwNvlJk7mh+ChTnUdgWUXuEok21iXQnCoKjUsHU48TRqneSfioYmUeYs0cYtbpUgSpIB7LiKZ3sx -4mcujJUDJi5DnUox9g61DLu34jd/IroAow57UvtruzvE03lRTs2Q9GcHGcg8RnoNAX3FWOdt5oUw -F5okxBDgBPfg8n/Uqgr/Qh037ZTlZFkSIHc40zI+OIF1lnP6aI+xy84fxez6nH7PfrHxBy22/L/K -pL/QlwVKvOoYKAKQvVR4CSFx09F9HdkWsKlhPdAKACL8x3vLCWRFCztAgfd9fDL1mMpYjn0q7pBZ -c2T5NnReJaH1ZgUufzkVqSr7UIuOhWn0 ------END CERTIFICATE----- - -Starfield Services Root Certificate Authority - G2 -================================================== ------BEGIN CERTIFICATE----- -MIID7zCCAtegAwIBAgIBADANBgkqhkiG9w0BAQsFADCBmDELMAkGA1UEBhMCVVMxEDAOBgNVBAgT -B0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoTHFN0YXJmaWVsZCBUZWNobm9s -b2dpZXMsIEluYy4xOzA5BgNVBAMTMlN0YXJmaWVsZCBTZXJ2aWNlcyBSb290IENlcnRpZmljYXRl -IEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgZgxCzAJBgNV -BAYTAlVTMRAwDgYDVQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxT -dGFyZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTswOQYDVQQDEzJTdGFyZmllbGQgU2VydmljZXMg -Um9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC -AQoCggEBANUMOsQq+U7i9b4Zl1+OiFOxHz/Lz58gE20pOsgPfTz3a3Y4Y9k2YKibXlwAgLIvWX/2 -h/klQ4bnaRtSmpDhcePYLQ1Ob/bISdm28xpWriu2dBTrz/sm4xq6HZYuajtYlIlHVv8loJNwU4Pa -hHQUw2eeBGg6345AWh1KTs9DkTvnVtYAcMtS7nt9rjrnvDH5RfbCYM8TWQIrgMw0R9+53pBlbQLP -LJGmpufehRhJfGZOozptqbXuNC66DQO4M99H67FrjSXZm86B0UVGMpZwh94CDklDhbZsc7tk6mFB -rMnUVN+HL8cisibMn1lUaJ/8viovxFUcdUBgF4UCVTmLfwUCAwEAAaNCMEAwDwYDVR0TAQH/BAUw -AwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJxfAN+qAdcwKziIorhtSpzyEZGDMA0GCSqG -SIb3DQEBCwUAA4IBAQBLNqaEd2ndOxmfZyMIbw5hyf2E3F/YNoHN2BtBLZ9g3ccaaNnRbobhiCPP -E95Dz+I0swSdHynVv/heyNXBve6SbzJ08pGCL72CQnqtKrcgfU28elUSwhXqvfdqlS5sdJ/PHLTy -xQGjhdByPq1zqwubdQxtRbeOlKyWN7Wg0I8VRw7j6IPdj/3vQQF3zCepYoUz8jcI73HPdwbeyBkd -iEDPfUYd/x7H4c7/I9vG+o1VTqkC50cRRj70/b17KSa7qWFiNyi2LSr2EIZkyXCn0q23KXB56jza -YyWf/Wi3MOxw+3WKt21gZ7IeyLnp2KhvAotnDU0mV3HaIPzBSlCNsSi6 ------END CERTIFICATE----- - -AffirmTrust Commercial -====================== ------BEGIN CERTIFICATE----- -MIIDTDCCAjSgAwIBAgIId3cGJyapsXwwDQYJKoZIhvcNAQELBQAwRDELMAkGA1UEBhMCVVMxFDAS -BgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBDb21tZXJjaWFsMB4XDTEw -MDEyOTE0MDYwNloXDTMwMTIzMTE0MDYwNlowRDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmly -bVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBDb21tZXJjaWFsMIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEA9htPZwcroRX1BiLLHwGy43NFBkRJLLtJJRTWzsO3qyxPxkEylFf6Eqdb -DuKPHx6GGaeqtS25Xw2Kwq+FNXkyLbscYjfysVtKPcrNcV/pQr6U6Mje+SJIZMblq8Yrba0F8PrV -C8+a5fBQpIs7R6UjW3p6+DM/uO+Zl+MgwdYoic+U+7lF7eNAFxHUdPALMeIrJmqbTFeurCA+ukV6 -BfO9m2kVrn1OIGPENXY6BwLJN/3HR+7o8XYdcxXyl6S1yHp52UKqK39c/s4mT6NmgTWvRLpUHhww -MmWd5jyTXlBOeuM61G7MGvv50jeuJCqrVwMiKA1JdX+3KNp1v47j3A55MQIDAQABo0IwQDAdBgNV -HQ4EFgQUnZPGU4teyq8/nx4P5ZmVvCT2lI8wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC -AQYwDQYJKoZIhvcNAQELBQADggEBAFis9AQOzcAN/wr91LoWXym9e2iZWEnStB03TX8nfUYGXUPG -hi4+c7ImfU+TqbbEKpqrIZcUsd6M06uJFdhrJNTxFq7YpFzUf1GO7RgBsZNjvbz4YYCanrHOQnDi -qX0GJX0nof5v7LMeJNrjS1UaADs1tDvZ110w/YETifLCBivtZ8SOyUOyXGsViQK8YvxO8rUzqrJv -0wqiUOP2O+guRMLbZjipM1ZI8W0bM40NjD9gN53Tym1+NH4Nn3J2ixufcv1SNUFFApYvHLKac0kh -sUlHRUe072o0EclNmsxZt9YCnlpOZbWUrhvfKbAW8b8Angc6F2S1BLUjIZkKlTuXfO8= ------END CERTIFICATE----- - -AffirmTrust Networking -====================== ------BEGIN CERTIFICATE----- -MIIDTDCCAjSgAwIBAgIIfE8EORzUmS0wDQYJKoZIhvcNAQEFBQAwRDELMAkGA1UEBhMCVVMxFDAS -BgNVBAoMC0FmZmlybVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBOZXR3b3JraW5nMB4XDTEw -MDEyOTE0MDgyNFoXDTMwMTIzMTE0MDgyNFowRDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmly -bVRydXN0MR8wHQYDVQQDDBZBZmZpcm1UcnVzdCBOZXR3b3JraW5nMIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEAtITMMxcua5Rsa2FSoOujz3mUTOWUgJnLVWREZY9nZOIG41w3SfYvm4SE -Hi3yYJ0wTsyEheIszx6e/jarM3c1RNg1lho9Nuh6DtjVR6FqaYvZ/Ls6rnla1fTWcbuakCNrmreI -dIcMHl+5ni36q1Mr3Lt2PpNMCAiMHqIjHNRqrSK6mQEubWXLviRmVSRLQESxG9fhwoXA3hA/Pe24 -/PHxI1Pcv2WXb9n5QHGNfb2V1M6+oF4nI979ptAmDgAp6zxG8D1gvz9Q0twmQVGeFDdCBKNwV6gb -h+0t+nvujArjqWaJGctB+d1ENmHP4ndGyH329JKBNv3bNPFyfvMMFr20FQIDAQABo0IwQDAdBgNV -HQ4EFgQUBx/S55zawm6iQLSwelAQUHTEyL0wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC -AQYwDQYJKoZIhvcNAQEFBQADggEBAIlXshZ6qML91tmbmzTCnLQyFE2npN/svqe++EPbkTfOtDIu -UFUaNU52Q3Eg75N3ThVwLofDwR1t3Mu1J9QsVtFSUzpE0nPIxBsFZVpikpzuQY0x2+c06lkh1QF6 -12S4ZDnNye2v7UsDSKegmQGA3GWjNq5lWUhPgkvIZfFXHeVZLgo/bNjR9eUJtGxUAArgFU2HdW23 -WJZa3W3SAKD0m0i+wzekujbgfIeFlxoVot4uolu9rxj5kFDNcFn4J2dHy8egBzp90SxdbBk6ZrV9 -/ZFvgrG+CJPbFEfxojfHRZ48x3evZKiT3/Zpg4Jg8klCNO1aAFSFHBY2kgxc+qatv9s= ------END CERTIFICATE----- - -AffirmTrust Premium -=================== ------BEGIN CERTIFICATE----- -MIIFRjCCAy6gAwIBAgIIbYwURrGmCu4wDQYJKoZIhvcNAQEMBQAwQTELMAkGA1UEBhMCVVMxFDAS -BgNVBAoMC0FmZmlybVRydXN0MRwwGgYDVQQDDBNBZmZpcm1UcnVzdCBQcmVtaXVtMB4XDTEwMDEy -OTE0MTAzNloXDTQwMTIzMTE0MTAzNlowQTELMAkGA1UEBhMCVVMxFDASBgNVBAoMC0FmZmlybVRy -dXN0MRwwGgYDVQQDDBNBZmZpcm1UcnVzdCBQcmVtaXVtMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A -MIICCgKCAgEAxBLfqV/+Qd3d9Z+K4/as4Tx4mrzY8H96oDMq3I0gW64tb+eT2TZwamjPjlGjhVtn -BKAQJG9dKILBl1fYSCkTtuG+kU3fhQxTGJoeJKJPj/CihQvL9Cl/0qRY7iZNyaqoe5rZ+jjeRFcV -5fiMyNlI4g0WJx0eyIOFJbe6qlVBzAMiSy2RjYvmia9mx+n/K+k8rNrSs8PhaJyJ+HoAVt70VZVs -+7pk3WKL3wt3MutizCaam7uqYoNMtAZ6MMgpv+0GTZe5HMQxK9VfvFMSF5yZVylmd2EhMQcuJUmd -GPLu8ytxjLW6OQdJd/zvLpKQBY0tL3d770O/Nbua2Plzpyzy0FfuKE4mX4+QaAkvuPjcBukumj5R -p9EixAqnOEhss/n/fauGV+O61oV4d7pD6kh/9ti+I20ev9E2bFhc8e6kGVQa9QPSdubhjL08s9NI -S+LI+H+SqHZGnEJlPqQewQcDWkYtuJfzt9WyVSHvutxMAJf7FJUnM7/oQ0dG0giZFmA7mn7S5u04 -6uwBHjxIVkkJx0w3AJ6IDsBz4W9m6XJHMD4Q5QsDyZpCAGzFlH5hxIrff4IaC1nEWTJ3s7xgaVY5 -/bQGeyzWZDbZvUjthB9+pSKPKrhC9IK31FOQeE4tGv2Bb0TXOwF0lkLgAOIua+rF7nKsu7/+6qqo -+Nz2snmKtmcCAwEAAaNCMEAwHQYDVR0OBBYEFJ3AZ6YMItkm9UWrpmVSESfYRaxjMA8GA1UdEwEB -/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBDAUAA4ICAQCzV00QYk465KzquByv -MiPIs0laUZx2KI15qldGF9X1Uva3ROgIRL8YhNILgM3FEv0AVQVhh0HctSSePMTYyPtwni94loMg -Nt58D2kTiKV1NpgIpsbfrM7jWNa3Pt668+s0QNiigfV4Py/VpfzZotReBA4Xrf5B8OWycvpEgjNC -6C1Y91aMYj+6QrCcDFx+LmUmXFNPALJ4fqENmS2NuB2OosSw/WDQMKSOyARiqcTtNd56l+0OOF6S -L5Nwpamcb6d9Ex1+xghIsV5n61EIJenmJWtSKZGc0jlzCFfemQa0W50QBuHCAKi4HEoCChTQwUHK -+4w1IX2COPKpVJEZNZOUbWo6xbLQu4mGk+ibyQ86p3q4ofB4Rvr8Ny/lioTz3/4E2aFooC8k4gmV -BtWVyuEklut89pMFu+1z6S3RdTnX5yTb2E5fQ4+e0BQ5v1VwSJlXMbSc7kqYA5YwH2AG7hsj/oFg -IxpHYoWlzBk0gG+zrBrjn/B7SK3VAdlntqlyk+otZrWyuOQ9PLLvTIzq6we/qzWaVYa8GKa1qF60 -g2xraUDTn9zxw2lrueFtCfTxqlB2Cnp9ehehVZZCmTEJ3WARjQUwfuaORtGdFNrHF+QFlozEJLUb -zxQHskD4o55BhrwE0GuWyCqANP2/7waj3VjFhT0+j/6eKeC2uAloGRwYQw== ------END CERTIFICATE----- - -AffirmTrust Premium ECC -======================= ------BEGIN CERTIFICATE----- -MIIB/jCCAYWgAwIBAgIIdJclisc/elQwCgYIKoZIzj0EAwMwRTELMAkGA1UEBhMCVVMxFDASBgNV -BAoMC0FmZmlybVRydXN0MSAwHgYDVQQDDBdBZmZpcm1UcnVzdCBQcmVtaXVtIEVDQzAeFw0xMDAx -MjkxNDIwMjRaFw00MDEyMzExNDIwMjRaMEUxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtBZmZpcm1U -cnVzdDEgMB4GA1UEAwwXQWZmaXJtVHJ1c3QgUHJlbWl1bSBFQ0MwdjAQBgcqhkjOPQIBBgUrgQQA -IgNiAAQNMF4bFZ0D0KF5Nbc6PJJ6yhUczWLznCZcBz3lVPqj1swS6vQUX+iOGasvLkjmrBhDeKzQ -N8O9ss0s5kfiGuZjuD0uL3jET9v0D6RoTFVya5UdThhClXjMNzyR4ptlKymjQjBAMB0GA1UdDgQW -BBSaryl6wBE1NSZRMADDav5A1a7WPDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAK -BggqhkjOPQQDAwNnADBkAjAXCfOHiFBar8jAQr9HX/VsaobgxCd05DhT1wV/GzTjxi+zygk8N53X -57hG8f2h4nECMEJZh0PUUd+60wkyWs6Iflc9nF9Ca/UHLbXwgpP5WW+uZPpY5Yse42O+tYHNbwKM -eQ== ------END CERTIFICATE----- - -Certum Trusted Network CA -========================= ------BEGIN CERTIFICATE----- -MIIDuzCCAqOgAwIBAgIDBETAMA0GCSqGSIb3DQEBBQUAMH4xCzAJBgNVBAYTAlBMMSIwIAYDVQQK -ExlVbml6ZXRvIFRlY2hub2xvZ2llcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2VydGlmaWNhdGlv -biBBdXRob3JpdHkxIjAgBgNVBAMTGUNlcnR1bSBUcnVzdGVkIE5ldHdvcmsgQ0EwHhcNMDgxMDIy -MTIwNzM3WhcNMjkxMjMxMTIwNzM3WjB+MQswCQYDVQQGEwJQTDEiMCAGA1UEChMZVW5pemV0byBU -ZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENlcnRpZmljYXRpb24gQXV0aG9yaXR5 -MSIwIAYDVQQDExlDZXJ0dW0gVHJ1c3RlZCBOZXR3b3JrIENBMIIBIjANBgkqhkiG9w0BAQEFAAOC -AQ8AMIIBCgKCAQEA4/t9o3K6wvDJFIf1awFO4W5AB7ptJ11/91sts1rHUV+rpDKmYYe2bg+G0jAC -l/jXaVehGDldamR5xgFZrDwxSjh80gTSSyjoIF87B6LMTXPb865Px1bVWqeWifrzq2jUI4ZZJ88J -J7ysbnKDHDBy3+Ci6dLhdHUZvSqeexVUBBvXQzmtVSjF4hq79MDkrjhJM8x2hZ85RdKknvISjFH4 -fOQtf/WsX+sWn7Et0brMkUJ3TCXJkDhv2/DM+44el1k+1WBO5gUo7Ul5E0u6SNsv+XLTOcr+H9g0 -cvW0QM8xAcPs3hEtF10fuFDRXhmnad4HMyjKUJX5p1TLVIZQRan5SQIDAQABo0IwQDAPBgNVHRMB -Af8EBTADAQH/MB0GA1UdDgQWBBQIds3LB/8k9sXN7buQvOKEN0Z19zAOBgNVHQ8BAf8EBAMCAQYw -DQYJKoZIhvcNAQEFBQADggEBAKaorSLOAT2mo/9i0Eidi15ysHhE49wcrwn9I0j6vSrEuVUEtRCj -jSfeC4Jj0O7eDDd5QVsisrCaQVymcODU0HfLI9MA4GxWL+FpDQ3Zqr8hgVDZBqWo/5U30Kr+4rP1 -mS1FhIrlQgnXdAIv94nYmem8J9RHjboNRhx3zxSkHLmkMcScKHQDNP8zGSal6Q10tz6XxnboJ5aj -Zt3hrvJBW8qYVoNzcOSGGtIxQbovvi0TWnZvTuhOgQ4/WwMioBK+ZlgRSssDxLQqKi2WF+A5VLxI -03YnnZotBqbJ7DnSq9ufmgsnAjUpsUCV5/nonFWIGUbWtzT1fs45mtk48VH3Tyw= ------END CERTIFICATE----- - -Certinomis - Autorit? Racine -============================ ------BEGIN CERTIFICATE----- -MIIFnDCCA4SgAwIBAgIBATANBgkqhkiG9w0BAQUFADBjMQswCQYDVQQGEwJGUjETMBEGA1UEChMK -Q2VydGlub21pczEXMBUGA1UECxMOMDAwMiA0MzM5OTg5MDMxJjAkBgNVBAMMHUNlcnRpbm9taXMg -LSBBdXRvcml0w6kgUmFjaW5lMB4XDTA4MDkxNzA4Mjg1OVoXDTI4MDkxNzA4Mjg1OVowYzELMAkG -A1UEBhMCRlIxEzARBgNVBAoTCkNlcnRpbm9taXMxFzAVBgNVBAsTDjAwMDIgNDMzOTk4OTAzMSYw -JAYDVQQDDB1DZXJ0aW5vbWlzIC0gQXV0b3JpdMOpIFJhY2luZTCCAiIwDQYJKoZIhvcNAQEBBQAD -ggIPADCCAgoCggIBAJ2Fn4bT46/HsmtuM+Cet0I0VZ35gb5j2CN2DpdUzZlMGvE5x4jYF1AMnmHa -wE5V3udauHpOd4cN5bjr+p5eex7Ezyh0x5P1FMYiKAT5kcOrJ3NqDi5N8y4oH3DfVS9O7cdxbwly -Lu3VMpfQ8Vh30WC8Tl7bmoT2R2FFK/ZQpn9qcSdIhDWerP5pqZ56XjUl+rSnSTV3lqc2W+HN3yNw -2F1MpQiD8aYkOBOo7C+ooWfHpi2GR+6K/OybDnT0K0kCe5B1jPyZOQE51kqJ5Z52qz6WKDgmi92N -jMD2AR5vpTESOH2VwnHu7XSu5DaiQ3XV8QCb4uTXzEIDS3h65X27uK4uIJPT5GHfceF2Z5c/tt9q -c1pkIuVC28+BA5PY9OMQ4HL2AHCs8MF6DwV/zzRpRbWT5BnbUhYjBYkOjUjkJW+zeL9i9Qf6lSTC -lrLooyPCXQP8w9PlfMl1I9f09bze5N/NgL+RiH2nE7Q5uiy6vdFrzPOlKO1Enn1So2+WLhl+HPNb -xxaOu2B9d2ZHVIIAEWBsMsGoOBvrbpgT1u449fCfDu/+MYHB0iSVL1N6aaLwD4ZFjliCK0wi1F6g -530mJ0jfJUaNSih8hp75mxpZuWW/Bd22Ql095gBIgl4g9xGC3srYn+Y3RyYe63j3YcNBZFgCQfna -4NH4+ej9Uji29YnfAgMBAAGjWzBZMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0G -A1UdDgQWBBQNjLZh2kS40RR9w759XkjwzspqsDAXBgNVHSAEEDAOMAwGCiqBegFWAgIAAQEwDQYJ -KoZIhvcNAQEFBQADggIBACQ+YAZ+He86PtvqrxyaLAEL9MW12Ukx9F1BjYkMTv9sov3/4gbIOZ/x -WqndIlgVqIrTseYyCYIDbNc/CMf4uboAbbnW/FIyXaR/pDGUu7ZMOH8oMDX/nyNTt7buFHAAQCva -R6s0fl6nVjBhK4tDrP22iCj1a7Y+YEq6QpA0Z43q619FVDsXrIvkxmUP7tCMXWY5zjKn2BCXwH40 -nJ+U8/aGH88bc62UeYdocMMzpXDn2NU4lG9jeeu/Cg4I58UvD0KgKxRA/yHgBcUn4YQRE7rWhh1B -CxMjidPJC+iKunqjo3M3NYB9Ergzd0A4wPpeMNLytqOx1qKVl4GbUu1pTP+A5FPbVFsDbVRfsbjv -JL1vnxHDx2TCDyhihWZeGnuyt++uNckZM6i4J9szVb9o4XVIRFb7zdNIu0eJOqxp9YDG5ERQL1TE -qkPFMTFYvZbF6nVsmnWxTfj3l/+WFvKXTej28xH5On2KOG4Ey+HTRRWqpdEdnV1j6CTmNhTih60b -WfVEm/vXd3wfAXBioSAaosUaKPQhA+4u2cGA6rnZgtZbdsLLO7XSAPCjDuGtbkD326C00EauFddE -wk01+dIL8hf2rGbVJLJP0RyZwG71fet0BLj5TXcJ17TPBzAJ8bgAVtkXFhYKK4bfjwEZGuW7gmP/ -vgt2Fl43N+bYdJeimUV5 ------END CERTIFICATE----- - -TWCA Root Certification Authority -================================= ------BEGIN CERTIFICATE----- -MIIDezCCAmOgAwIBAgIBATANBgkqhkiG9w0BAQUFADBfMQswCQYDVQQGEwJUVzESMBAGA1UECgwJ -VEFJV0FOLUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFUV0NBIFJvb3QgQ2VydGlmaWNh -dGlvbiBBdXRob3JpdHkwHhcNMDgwODI4MDcyNDMzWhcNMzAxMjMxMTU1OTU5WjBfMQswCQYDVQQG -EwJUVzESMBAGA1UECgwJVEFJV0FOLUNBMRAwDgYDVQQLDAdSb290IENBMSowKAYDVQQDDCFUV0NB -IFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK -AoIBAQCwfnK4pAOU5qfeCTiRShFAh6d8WWQUe7UREN3+v9XAu1bihSX0NXIP+FPQQeFEAcK0HMMx -QhZHhTMidrIKbw/lJVBPhYa+v5guEGcevhEFhgWQxFnQfHgQsIBct+HHK3XLfJ+utdGdIzdjp9xC -oi2SBBtQwXu4PhvJVgSLL1KbralW6cH/ralYhzC2gfeXRfwZVzsrb+RH9JlF/h3x+JejiB03HFyP -4HYlmlD4oFT/RJB2I9IyxsOrBr/8+7/zrX2SYgJbKdM1o5OaQ2RgXbL6Mv87BK9NQGr5x+PvI/1r -y+UPizgN7gr8/g+YnzAx3WxSZfmLgb4i4RxYA7qRG4kHAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIB -BjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqOFsmjd6LWvJPelSDGRjjCDWmujANBgkqhkiG -9w0BAQUFAAOCAQEAPNV3PdrfibqHDAhUaiBQkr6wQT25JmSDCi/oQMCXKCeCMErJk/9q56YAf4lC -mtYR5VPOL8zy2gXE/uJQxDqGfczafhAJO5I1KlOy/usrBdlsXebQ79NqZp4VKIV66IIArB6nCWlW -QtNoURi+VJq/REG6Sb4gumlc7rh3zc5sH62Dlhh9DrUUOYTxKOkto557HnpyWoOzeW/vtPzQCqVY -T0bf+215WfKEIlKuD8z7fDvnaspHYcN6+NOSBB+4IIThNlQWx0DeO4pz3N/GCUzf7Nr/1FNCocny -Yh0igzyXxfkZYiesZSLX0zzG5Y6yU8xJzrww/nsOM5D77dIUkR8Hrw== ------END CERTIFICATE----- - -Security Communication RootCA2 -============================== ------BEGIN CERTIFICATE----- -MIIDdzCCAl+gAwIBAgIBADANBgkqhkiG9w0BAQsFADBdMQswCQYDVQQGEwJKUDElMCMGA1UEChMc -U0VDT00gVHJ1c3QgU3lzdGVtcyBDTy4sTFRELjEnMCUGA1UECxMeU2VjdXJpdHkgQ29tbXVuaWNh -dGlvbiBSb290Q0EyMB4XDTA5MDUyOTA1MDAzOVoXDTI5MDUyOTA1MDAzOVowXTELMAkGA1UEBhMC -SlAxJTAjBgNVBAoTHFNFQ09NIFRydXN0IFN5c3RlbXMgQ08uLExURC4xJzAlBgNVBAsTHlNlY3Vy -aXR5IENvbW11bmljYXRpb24gUm9vdENBMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB -ANAVOVKxUrO6xVmCxF1SrjpDZYBLx/KWvNs2l9amZIyoXvDjChz335c9S672XewhtUGrzbl+dp++ -+T42NKA7wfYxEUV0kz1XgMX5iZnK5atq1LXaQZAQwdbWQonCv/Q4EpVMVAX3NuRFg3sUZdbcDE3R -3n4MqzvEFb46VqZab3ZpUql6ucjrappdUtAtCms1FgkQhNBqyjoGADdH5H5XTz+L62e4iKrFvlNV -spHEfbmwhRkGeC7bYRr6hfVKkaHnFtWOojnflLhwHyg/i/xAXmODPIMqGplrz95Zajv8bxbXH/1K -EOtOghY6rCcMU/Gt1SSwawNQwS08Ft1ENCcadfsCAwEAAaNCMEAwHQYDVR0OBBYEFAqFqXdlBZh8 -QIH4D5csOPEK7DzPMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEB -CwUAA4IBAQBMOqNErLlFsceTfsgLCkLfZOoc7llsCLqJX2rKSpWeeo8HxdpFcoJxDjrSzG+ntKEj -u/Ykn8sX/oymzsLS28yN/HH8AynBbF0zX2S2ZTuJbxh2ePXcokgfGT+Ok+vx+hfuzU7jBBJV1uXk -3fs+BXziHV7Gp7yXT2g69ekuCkO2r1dcYmh8t/2jioSgrGK+KwmHNPBqAbubKVY8/gA3zyNs8U6q -tnRGEmyR7jTV7JqR50S+kDFy1UkC9gLl9B/rfNmWVan/7Ir5mUf/NVoCqgTLiluHcSmRvaS0eg29 -mvVXIwAHIRc/SjnRBUkLp7Y3gaVdjKozXoEofKd9J+sAro03 ------END CERTIFICATE----- - -EC-ACC -====== ------BEGIN CERTIFICATE----- -MIIFVjCCBD6gAwIBAgIQ7is969Qh3hSoYqwE893EATANBgkqhkiG9w0BAQUFADCB8zELMAkGA1UE -BhMCRVMxOzA5BgNVBAoTMkFnZW5jaWEgQ2F0YWxhbmEgZGUgQ2VydGlmaWNhY2lvIChOSUYgUS0w -ODAxMTc2LUkpMSgwJgYDVQQLEx9TZXJ2ZWlzIFB1YmxpY3MgZGUgQ2VydGlmaWNhY2lvMTUwMwYD -VQQLEyxWZWdldSBodHRwczovL3d3dy5jYXRjZXJ0Lm5ldC92ZXJhcnJlbCAoYykwMzE1MDMGA1UE -CxMsSmVyYXJxdWlhIEVudGl0YXRzIGRlIENlcnRpZmljYWNpbyBDYXRhbGFuZXMxDzANBgNVBAMT -BkVDLUFDQzAeFw0wMzAxMDcyMzAwMDBaFw0zMTAxMDcyMjU5NTlaMIHzMQswCQYDVQQGEwJFUzE7 -MDkGA1UEChMyQWdlbmNpYSBDYXRhbGFuYSBkZSBDZXJ0aWZpY2FjaW8gKE5JRiBRLTA4MDExNzYt -SSkxKDAmBgNVBAsTH1NlcnZlaXMgUHVibGljcyBkZSBDZXJ0aWZpY2FjaW8xNTAzBgNVBAsTLFZl -Z2V1IGh0dHBzOi8vd3d3LmNhdGNlcnQubmV0L3ZlcmFycmVsIChjKTAzMTUwMwYDVQQLEyxKZXJh -cnF1aWEgRW50aXRhdHMgZGUgQ2VydGlmaWNhY2lvIENhdGFsYW5lczEPMA0GA1UEAxMGRUMtQUND -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsyLHT+KXQpWIR4NA9h0X84NzJB5R85iK -w5K4/0CQBXCHYMkAqbWUZRkiFRfCQ2xmRJoNBD45b6VLeqpjt4pEndljkYRm4CgPukLjbo73FCeT -ae6RDqNfDrHrZqJyTxIThmV6PttPB/SnCWDaOkKZx7J/sxaVHMf5NLWUhdWZXqBIoH7nF2W4onW4 -HvPlQn2v7fOKSGRdghST2MDk/7NQcvJ29rNdQlB50JQ+awwAvthrDk4q7D7SzIKiGGUzE3eeml0a -E9jD2z3Il3rucO2n5nzbcc8tlGLfbdb1OL4/pYUKGbio2Al1QnDE6u/LDsg0qBIimAy4E5S2S+zw -0JDnJwIDAQABo4HjMIHgMB0GA1UdEQQWMBSBEmVjX2FjY0BjYXRjZXJ0Lm5ldDAPBgNVHRMBAf8E -BTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUoMOLRKo3pUW/l4Ba0fF4opvpXY0wfwYD -VR0gBHgwdjB0BgsrBgEEAfV4AQMBCjBlMCwGCCsGAQUFBwIBFiBodHRwczovL3d3dy5jYXRjZXJ0 -Lm5ldC92ZXJhcnJlbDA1BggrBgEFBQcCAjApGidWZWdldSBodHRwczovL3d3dy5jYXRjZXJ0Lm5l -dC92ZXJhcnJlbCAwDQYJKoZIhvcNAQEFBQADggEBAKBIW4IB9k1IuDlVNZyAelOZ1Vr/sXE7zDkJ -lF7W2u++AVtd0x7Y/X1PzaBB4DSTv8vihpw3kpBWHNzrKQXlxJ7HNd+KDM3FIUPpqojlNcAZQmNa -Al6kSBg6hW/cnbw/nZzBh7h6YQjpdwt/cKt63dmXLGQehb+8dJahw3oS7AwaboMMPOhyRp/7SNVe -l+axofjk70YllJyJ22k4vuxcDlbHZVHlUIiIv0LVKz3l+bqeLrPK9HOSAgu+TGbrIP65y7WZf+a2 -E/rKS03Z7lNGBjvGTq2TWoF+bCpLagVFjPIhpDGQh2xlnJ2lYJU6Un/10asIbvPuW/mIPX64b24D -5EI= ------END CERTIFICATE----- - -Hellenic Academic and Research Institutions RootCA 2011 -======================================================= ------BEGIN CERTIFICATE----- -MIIEMTCCAxmgAwIBAgIBADANBgkqhkiG9w0BAQUFADCBlTELMAkGA1UEBhMCR1IxRDBCBgNVBAoT -O0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ2VydC4gQXV0aG9y -aXR5MUAwPgYDVQQDEzdIZWxsZW5pYyBBY2FkZW1pYyBhbmQgUmVzZWFyY2ggSW5zdGl0dXRpb25z -IFJvb3RDQSAyMDExMB4XDTExMTIwNjEzNDk1MloXDTMxMTIwMTEzNDk1MlowgZUxCzAJBgNVBAYT -AkdSMUQwQgYDVQQKEztIZWxsZW5pYyBBY2FkZW1pYyBhbmQgUmVzZWFyY2ggSW5zdGl0dXRpb25z -IENlcnQuIEF1dGhvcml0eTFAMD4GA1UEAxM3SGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNo -IEluc3RpdHV0aW9ucyBSb290Q0EgMjAxMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB -AKlTAOMupvaO+mDYLZU++CwqVE7NuYRhlFhPjz2L5EPzdYmNUeTDN9KKiE15HrcS3UN4SoqS5tdI -1Q+kOilENbgH9mgdVc04UfCMJDGFr4PJfel3r+0ae50X+bOdOFAPplp5kYCvN66m0zH7tSYJnTxa -71HFK9+WXesyHgLacEnsbgzImjeN9/E2YEsmLIKe0HjzDQ9jpFEw4fkrJxIH2Oq9GGKYsFk3fb7u -8yBRQlqD75O6aRXxYp2fmTmCobd0LovUxQt7L/DICto9eQqakxylKHJzkUOap9FNhYS5qXSPFEDH -3N6sQWRstBmbAmNtJGSPRLIl6s5ddAxjMlyNh+UCAwEAAaOBiTCBhjAPBgNVHRMBAf8EBTADAQH/ -MAsGA1UdDwQEAwIBBjAdBgNVHQ4EFgQUppFC/RNhSiOeCKQp5dgTBCPuQSUwRwYDVR0eBEAwPqA8 -MAWCAy5ncjAFggMuZXUwBoIELmVkdTAGggQub3JnMAWBAy5ncjAFgQMuZXUwBoEELmVkdTAGgQQu -b3JnMA0GCSqGSIb3DQEBBQUAA4IBAQAf73lB4XtuP7KMhjdCSk4cNx6NZrokgclPEg8hwAOXhiVt -XdMiKahsog2p6z0GW5k6x8zDmjR/qw7IThzh+uTczQ2+vyT+bOdrwg3IBp5OjWEopmr95fZi6hg8 -TqBTnbI6nOulnJEWtk2C4AwFSKls9cz4y51JtPACpf1wA+2KIaWuE4ZJwzNzvoc7dIsXRSZMFpGD -/md9zU1jZ/rzAxKWeAaNsWftjj++n08C9bMJL/NMh98qy5V8AcysNnq/onN694/BtZqhFLKPM58N -7yLcZnuEvUUXBj08yrl3NI/K6s8/MT7jiOOASSXIl7WdmplNsDz4SgCbZN2fOUvRJ9e4 ------END CERTIFICATE----- - -Actalis Authentication Root CA -============================== ------BEGIN CERTIFICATE----- -MIIFuzCCA6OgAwIBAgIIVwoRl0LE48wwDQYJKoZIhvcNAQELBQAwazELMAkGA1UEBhMCSVQxDjAM -BgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlzIFMucC5BLi8wMzM1ODUyMDk2NzEnMCUGA1UE -AwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290IENBMB4XDTExMDkyMjExMjIwMloXDTMwMDky -MjExMjIwMlowazELMAkGA1UEBhMCSVQxDjAMBgNVBAcMBU1pbGFuMSMwIQYDVQQKDBpBY3RhbGlz -IFMucC5BLi8wMzM1ODUyMDk2NzEnMCUGA1UEAwweQWN0YWxpcyBBdXRoZW50aWNhdGlvbiBSb290 -IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAp8bEpSmkLO/lGMWwUKNvUTufClrJ -wkg4CsIcoBh/kbWHuUA/3R1oHwiD1S0eiKD4j1aPbZkCkpAW1V8IbInX4ay8IMKx4INRimlNAJZa -by/ARH6jDuSRzVju3PvHHkVH3Se5CAGfpiEd9UEtL0z9KK3giq0itFZljoZUj5NDKd45RnijMCO6 -zfB9E1fAXdKDa0hMxKufgFpbOr3JpyI/gCczWw63igxdBzcIy2zSekciRDXFzMwujt0q7bd9Zg1f -YVEiVRvjRuPjPdA1YprbrxTIW6HMiRvhMCb8oJsfgadHHwTrozmSBp+Z07/T6k9QnBn+locePGX2 -oxgkg4YQ51Q+qDp2JE+BIcXjDwL4k5RHILv+1A7TaLndxHqEguNTVHnd25zS8gebLra8Pu2Fbe8l -EfKXGkJh90qX6IuxEAf6ZYGyojnP9zz/GPvG8VqLWeICrHuS0E4UT1lF9gxeKF+w6D9Fz8+vm2/7 -hNN3WpVvrJSEnu68wEqPSpP4RCHiMUVhUE4Q2OM1fEwZtN4Fv6MGn8i1zeQf1xcGDXqVdFUNaBr8 -EBtiZJ1t4JWgw5QHVw0U5r0F+7if5t+L4sbnfpb2U8WANFAoWPASUHEXMLrmeGO89LKtmyuy/uE5 -jF66CyCU3nuDuP/jVo23Eek7jPKxwV2dpAtMK9myGPW1n0sCAwEAAaNjMGEwHQYDVR0OBBYEFFLY -iDrIn3hm7YnzezhwlMkCAjbQMA8GA1UdEwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUUtiIOsifeGbt -ifN7OHCUyQICNtAwDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4ICAQALe3KHwGCmSUyI -WOYdiPcUZEim2FgKDk8TNd81HdTtBjHIgT5q1d07GjLukD0R0i70jsNjLiNmsGe+b7bAEzlgqqI0 -JZN1Ut6nna0Oh4lScWoWPBkdg/iaKWW+9D+a2fDzWochcYBNy+A4mz+7+uAwTc+G02UQGRjRlwKx -K3JCaKygvU5a2hi/a5iB0P2avl4VSM0RFbnAKVy06Ij3Pjaut2L9HmLecHgQHEhb2rykOLpn7VU+ -Xlff1ANATIGk0k9jpwlCCRT8AKnCgHNPLsBA2RF7SOp6AsDT6ygBJlh0wcBzIm2Tlf05fbsq4/aC -4yyXX04fkZT6/iyj2HYauE2yOE+b+h1IYHkm4vP9qdCa6HCPSXrW5b0KDtst842/6+OkfcvHlXHo -2qN8xcL4dJIEG4aspCJTQLas/kx2z/uUMsA1n3Y/buWQbqCmJqK4LL7RK4X9p2jIugErsWx0Hbhz -lefut8cl8ABMALJ+tguLHPPAUJ4lueAI3jZm/zel0btUZCzJJ7VLkn5l/9Mt4blOvH+kQSGQQXem -OR/qnuOf0GZvBeyqdn6/axag67XH/JJULysRJyU3eExRarDzzFhdFPFqSBX/wge2sY0PjlxQRrM9 -vwGYT7JZVEc+NHt4bVaTLnPqZih4zR0Uv6CPLy64Lo7yFIrM6bV8+2ydDKXhlg== ------END CERTIFICATE----- - -Trustis FPS Root CA -=================== ------BEGIN CERTIFICATE----- -MIIDZzCCAk+gAwIBAgIQGx+ttiD5JNM2a/fH8YygWTANBgkqhkiG9w0BAQUFADBFMQswCQYDVQQG -EwJHQjEYMBYGA1UEChMPVHJ1c3RpcyBMaW1pdGVkMRwwGgYDVQQLExNUcnVzdGlzIEZQUyBSb290 -IENBMB4XDTAzMTIyMzEyMTQwNloXDTI0MDEyMTExMzY1NFowRTELMAkGA1UEBhMCR0IxGDAWBgNV -BAoTD1RydXN0aXMgTGltaXRlZDEcMBoGA1UECxMTVHJ1c3RpcyBGUFMgUm9vdCBDQTCCASIwDQYJ -KoZIhvcNAQEBBQADggEPADCCAQoCggEBAMVQe547NdDfxIzNjpvto8A2mfRC6qc+gIMPpqdZh8mQ -RUN+AOqGeSoDvT03mYlmt+WKVoaTnGhLaASMk5MCPjDSNzoiYYkchU59j9WvezX2fihHiTHcDnlk -H5nSW7r+f2C/revnPDgpai/lkQtV/+xvWNUtyd5MZnGPDNcE2gfmHhjjvSkCqPoc4Vu5g6hBSLwa -cY3nYuUtsuvffM/bq1rKMfFMIvMFE/eC+XN5DL7XSxzA0RU8k0Fk0ea+IxciAIleH2ulrG6nS4zt -o3Lmr2NNL4XSFDWaLk6M6jKYKIahkQlBOrTh4/L68MkKokHdqeMDx4gVOxzUGpTXn2RZEm0CAwEA -AaNTMFEwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBS6+nEleYtXQSUhhgtx67JkDoshZzAd -BgNVHQ4EFgQUuvpxJXmLV0ElIYYLceuyZA6LIWcwDQYJKoZIhvcNAQEFBQADggEBAH5Y//01GX2c -GE+esCu8jowU/yyg2kdbw++BLa8F6nRIW/M+TgfHbcWzk88iNVy2P3UnXwmWzaD+vkAMXBJV+JOC -yinpXj9WV4s4NvdFGkwozZ5BuO1WTISkQMi4sKUraXAEasP41BIy+Q7DsdwyhEQsb8tGD+pmQQ9P -8Vilpg0ND2HepZ5dfWWhPBfnqFVO76DH7cZEf1T1o+CP8HxVIo8ptoGj4W1OLBuAZ+ytIJ8MYmHV -l/9D7S3B2l0pKoU/rGXuhg8FjZBf3+6f9L/uHfuY5H+QK4R4EA5sSVPvFVtlRkpdr7r7OnIdzfYl -iB6XzCGcKQENZetX2fNXlrtIzYE= ------END CERTIFICATE----- - -StartCom Certification Authority -================================ ------BEGIN CERTIFICATE----- -MIIHhzCCBW+gAwIBAgIBLTANBgkqhkiG9w0BAQsFADB9MQswCQYDVQQGEwJJTDEWMBQGA1UEChMN -U3RhcnRDb20gTHRkLjErMCkGA1UECxMiU2VjdXJlIERpZ2l0YWwgQ2VydGlmaWNhdGUgU2lnbmlu -ZzEpMCcGA1UEAxMgU3RhcnRDb20gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMDYwOTE3MTk0 -NjM3WhcNMzYwOTE3MTk0NjM2WjB9MQswCQYDVQQGEwJJTDEWMBQGA1UEChMNU3RhcnRDb20gTHRk -LjErMCkGA1UECxMiU2VjdXJlIERpZ2l0YWwgQ2VydGlmaWNhdGUgU2lnbmluZzEpMCcGA1UEAxMg -U3RhcnRDb20gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw -ggIKAoICAQDBiNsJvGxGfHiflXu1M5DycmLWwTYgIiRezul38kMKogZkpMyONvg45iPwbm2xPN1y -o4UcodM9tDMr0y+v/uqwQVlntsQGfQqedIXWeUyAN3rfOQVSWff0G0ZDpNKFhdLDcfN1YjS6LIp/ -Ho/u7TTQEceWzVI9ujPW3U3eCztKS5/CJi/6tRYccjV3yjxd5srhJosaNnZcAdt0FCX+7bWgiA/d -eMotHweXMAEtcnn6RtYTKqi5pquDSR3l8u/d5AGOGAqPY1MWhWKpDhk6zLVmpsJrdAfkK+F2PrRt -2PZE4XNiHzvEvqBTViVsUQn3qqvKv3b9bZvzndu/PWa8DFaqr5hIlTpL36dYUNk4dalb6kMMAv+Z -6+hsTXBbKWWc3apdzK8BMewM69KN6Oqce+Zu9ydmDBpI125C4z/eIT574Q1w+2OqqGwaVLRcJXrJ -osmLFqa7LH4XXgVNWG4SHQHuEhANxjJ/GP/89PrNbpHoNkm+Gkhpi8KWTRoSsmkXwQqQ1vp5Iki/ -untp+HDH+no32NgN0nZPV/+Qt+OR0t3vwmC3Zzrd/qqc8NSLf3Iizsafl7b4r4qgEKjZ+xjGtrVc -UjyJthkqcwEKDwOzEmDyei+B26Nu/yYwl/WL3YlXtq09s68rxbd2AvCl1iuahhQqcvbjM4xdCUsT -37uMdBNSSwIDAQABo4ICEDCCAgwwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYD -VR0OBBYEFE4L7xqkQFulF2mHMMo0aEPQQa7yMB8GA1UdIwQYMBaAFE4L7xqkQFulF2mHMMo0aEPQ -Qa7yMIIBWgYDVR0gBIIBUTCCAU0wggFJBgsrBgEEAYG1NwEBATCCATgwLgYIKwYBBQUHAgEWImh0 -dHA6Ly93d3cuc3RhcnRzc2wuY29tL3BvbGljeS5wZGYwNAYIKwYBBQUHAgEWKGh0dHA6Ly93d3cu -c3RhcnRzc2wuY29tL2ludGVybWVkaWF0ZS5wZGYwgc8GCCsGAQUFBwICMIHCMCcWIFN0YXJ0IENv -bW1lcmNpYWwgKFN0YXJ0Q29tKSBMdGQuMAMCAQEagZZMaW1pdGVkIExpYWJpbGl0eSwgcmVhZCB0 -aGUgc2VjdGlvbiAqTGVnYWwgTGltaXRhdGlvbnMqIG9mIHRoZSBTdGFydENvbSBDZXJ0aWZpY2F0 -aW9uIEF1dGhvcml0eSBQb2xpY3kgYXZhaWxhYmxlIGF0IGh0dHA6Ly93d3cuc3RhcnRzc2wuY29t -L3BvbGljeS5wZGYwEQYJYIZIAYb4QgEBBAQDAgAHMDgGCWCGSAGG+EIBDQQrFilTdGFydENvbSBG -cmVlIFNTTCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTANBgkqhkiG9w0BAQsFAAOCAgEAjo/n3JR5 -fPGFf59Jb2vKXfuM/gTFwWLRfUKKvFO3lANmMD+x5wqnUCBVJX92ehQN6wQOQOY+2IirByeDqXWm -N3PH/UvSTa0XQMhGvjt/UfzDtgUx3M2FIk5xt/JxXrAaxrqTi3iSSoX4eA+D/i+tLPfkpLst0OcN -Org+zvZ49q5HJMqjNTbOx8aHmNrs++myziebiMMEofYLWWivydsQD032ZGNcpRJvkrKTlMeIFw6T -tn5ii5B/q06f/ON1FE8qMt9bDeD1e5MNq6HPh+GlBEXoPBKlCcWw0bdT82AUuoVpaiF8H3VhFyAX -e2w7QSlc4axa0c2Mm+tgHRns9+Ww2vl5GKVFP0lDV9LdJNUso/2RjSe15esUBppMeyG7Oq0wBhjA -2MFrLH9ZXF2RsXAiV+uKa0hK1Q8p7MZAwC+ITGgBF3f0JBlPvfrhsiAhS90a2Cl9qrjeVOwhVYBs -HvUwyKMQ5bLmKhQxw4UtjJixhlpPiVktucf3HMiKf8CdBUrmQk9io20ppB+Fq9vlgcitKj1MXVuE -JnHEhV5xJMqlG2zYYdMa4FTbzrqpMrUi9nNBCV24F10OD5mQ1kfabwo6YigUZ4LZ8dCAWZvLMdib -D4x3TrVoivJs9iQOLWxwxXPR3hTQcY+203sC9uO41Alua551hDnmfyWl8kgAwKQB2j8= ------END CERTIFICATE----- - -StartCom Certification Authority G2 -=================================== ------BEGIN CERTIFICATE----- -MIIFYzCCA0ugAwIBAgIBOzANBgkqhkiG9w0BAQsFADBTMQswCQYDVQQGEwJJTDEWMBQGA1UEChMN -U3RhcnRDb20gTHRkLjEsMCoGA1UEAxMjU3RhcnRDb20gQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkg -RzIwHhcNMTAwMTAxMDEwMDAxWhcNMzkxMjMxMjM1OTAxWjBTMQswCQYDVQQGEwJJTDEWMBQGA1UE -ChMNU3RhcnRDb20gTHRkLjEsMCoGA1UEAxMjU3RhcnRDb20gQ2VydGlmaWNhdGlvbiBBdXRob3Jp -dHkgRzIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2iTZbB7cgNr2Cu+EWIAOVeq8O -o1XJJZlKxdBWQYeQTSFgpBSHO839sj60ZwNq7eEPS8CRhXBF4EKe3ikj1AENoBB5uNsDvfOpL9HG -4A/LnooUCri99lZi8cVytjIl2bLzvWXFDSxu1ZJvGIsAQRSCb0AgJnooD/Uefyf3lLE3PbfHkffi -Aez9lInhzG7TNtYKGXmu1zSCZf98Qru23QumNK9LYP5/Q0kGi4xDuFby2X8hQxfqp0iVAXV16iul -Q5XqFYSdCI0mblWbq9zSOdIxHWDirMxWRST1HFSr7obdljKF+ExP6JV2tgXdNiNnvP8V4so75qbs -O+wmETRIjfaAKxojAuuKHDp2KntWFhxyKrOq42ClAJ8Em+JvHhRYW6Vsi1g8w7pOOlz34ZYrPu8H -vKTlXcxNnw3h3Kq74W4a7I/htkxNeXJdFzULHdfBR9qWJODQcqhaX2YtENwvKhOuJv4KHBnM0D4L -nMgJLvlblnpHnOl68wVQdJVznjAJ85eCXuaPOQgeWeU1FEIT/wCc976qUM/iUUjXuG+v+E5+M5iS -FGI6dWPPe/regjupuznixL0sAA7IF6wT700ljtizkC+p2il9Ha90OrInwMEePnWjFqmveiJdnxMa -z6eg6+OGCtP95paV1yPIN93EfKo2rJgaErHgTuixO/XWb/Ew1wIDAQABo0IwQDAPBgNVHRMBAf8E -BTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUS8W0QGutHLOlHGVuRjaJhwUMDrYwDQYJ -KoZIhvcNAQELBQADggIBAHNXPyzVlTJ+N9uWkusZXn5T50HsEbZH77Xe7XRcxfGOSeD8bpkTzZ+K -2s06Ctg6Wgk/XzTQLwPSZh0avZyQN8gMjgdalEVGKua+etqhqaRpEpKwfTbURIfXUfEpY9Z1zRbk -J4kd+MIySP3bmdCPX1R0zKxnNBFi2QwKN4fRoxdIjtIXHfbX/dtl6/2o1PXWT6RbdejF0mCy2wl+ -JYt7ulKSnj7oxXehPOBKc2thz4bcQ///If4jXSRK9dNtD2IEBVeC2m6kMyV5Sy5UGYvMLD0w6dEG -/+gyRr61M3Z3qAFdlsHB1b6uJcDJHgoJIIihDsnzb02CVAAgp9KP5DlUFy6NHrgbuxu9mk47EDTc -nIhT76IxW1hPkWLIwpqazRVdOKnWvvgTtZ8SafJQYqz7Fzf07rh1Z2AQ+4NQ+US1dZxAF7L+/Xld -blhYXzD8AK6vM8EOTmy6p6ahfzLbOOCxchcKK5HsamMm7YnUeMx0HgX4a/6ManY5Ka5lIxKVCCIc -l85bBu4M4ru8H0ST9tg4RQUh7eStqxK2A6RCLi3ECToDZ2mEmuFZkIoohdVddLHRDiBYmxOlsGOm -7XtH/UVVMKTumtTm4ofvmMkyghEpIrwACjFeLQ/Ajulrso8uBtjRkcfGEvRM/TAXw8HaOFvjqerm -obp573PYtlNXLfbQ4ddI ------END CERTIFICATE----- - -Buypass Class 2 Root CA -======================= ------BEGIN CERTIFICATE----- -MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEdMBsGA1UECgwU -QnV5cGFzcyBBUy05ODMxNjMzMjcxIDAeBgNVBAMMF0J1eXBhc3MgQ2xhc3MgMiBSb290IENBMB4X -DTEwMTAyNjA4MzgwM1oXDTQwMTAyNjA4MzgwM1owTjELMAkGA1UEBhMCTk8xHTAbBgNVBAoMFEJ1 -eXBhc3MgQVMtOTgzMTYzMzI3MSAwHgYDVQQDDBdCdXlwYXNzIENsYXNzIDIgUm9vdCBDQTCCAiIw -DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANfHXvfBB9R3+0Mh9PT1aeTuMgHbo4Yf5FkNuud1 -g1Lr6hxhFUi7HQfKjK6w3Jad6sNgkoaCKHOcVgb/S2TwDCo3SbXlzwx87vFKu3MwZfPVL4O2fuPn -9Z6rYPnT8Z2SdIrkHJasW4DptfQxh6NR/Md+oW+OU3fUl8FVM5I+GC911K2GScuVr1QGbNgGE41b -/+EmGVnAJLqBcXmQRFBoJJRfuLMR8SlBYaNByyM21cHxMlAQTn/0hpPshNOOvEu/XAFOBz3cFIqU -CqTqc/sLUegTBxj6DvEr0VQVfTzh97QZQmdiXnfgolXsttlpF9U6r0TtSsWe5HonfOV116rLJeff -awrbD02TTqigzXsu8lkBarcNuAeBfos4GzjmCleZPe4h6KP1DBbdi+w0jpwqHAAVF41og9JwnxgI -zRFo1clrUs3ERo/ctfPYV3Me6ZQ5BL/T3jjetFPsaRyifsSP5BtwrfKi+fv3FmRmaZ9JUaLiFRhn -Bkp/1Wy1TbMz4GHrXb7pmA8y1x1LPC5aAVKRCfLf6o3YBkBjqhHk/sM3nhRSP/TizPJhk9H9Z2vX -Uq6/aKtAQ6BXNVN48FP4YUIHZMbXb5tMOA1jrGKvNouicwoN9SG9dKpN6nIDSdvHXx1iY8f93ZHs -M+71bbRuMGjeyNYmsHVee7QHIJihdjK4TWxPAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD -VR0OBBYEFMmAd+BikoL1RpzzuvdMw964o605MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsF -AAOCAgEAU18h9bqwOlI5LJKwbADJ784g7wbylp7ppHR/ehb8t/W2+xUbP6umwHJdELFx7rxP462s -A20ucS6vxOOto70MEae0/0qyexAQH6dXQbLArvQsWdZHEIjzIVEpMMpghq9Gqx3tOluwlN5E40EI -osHsHdb9T7bWR9AUC8rmyrV7d35BH16Dx7aMOZawP5aBQW9gkOLo+fsicdl9sz1Gv7SEr5AcD48S -aq/v7h56rgJKihcrdv6sVIkkLE8/trKnToyokZf7KcZ7XC25y2a2t6hbElGFtQl+Ynhw/qlqYLYd -DnkM/crqJIByw5c/8nerQyIKx+u2DISCLIBrQYoIwOula9+ZEsuK1V6ADJHgJgg2SMX6OBE1/yWD -LfJ6v9r9jv6ly0UsH8SIU653DtmadsWOLB2jutXsMq7Aqqz30XpN69QH4kj3Io6wpJ9qzo6ysmD0 -oyLQI+uUWnpp3Q+/QFesa1lQ2aOZ4W7+jQF5JyMV3pKdewlNWudLSDBaGOYKbeaP4NK75t98biGC -wWg5TbSYWGZizEqQXsP6JwSxeRV0mcy+rSDeJmAc61ZRpqPq5KM/p/9h3PFaTWwyI0PurKju7koS -CTxdccK+efrCh2gdC/1cacwG0Jp9VJkqyTkaGa9LKkPzY11aWOIv4x3kqdbQCtCev9eBCfHJxyYN -rJgWVqA= ------END CERTIFICATE----- - -Buypass Class 3 Root CA -======================= ------BEGIN CERTIFICATE----- -MIIFWTCCA0GgAwIBAgIBAjANBgkqhkiG9w0BAQsFADBOMQswCQYDVQQGEwJOTzEdMBsGA1UECgwU -QnV5cGFzcyBBUy05ODMxNjMzMjcxIDAeBgNVBAMMF0J1eXBhc3MgQ2xhc3MgMyBSb290IENBMB4X -DTEwMTAyNjA4Mjg1OFoXDTQwMTAyNjA4Mjg1OFowTjELMAkGA1UEBhMCTk8xHTAbBgNVBAoMFEJ1 -eXBhc3MgQVMtOTgzMTYzMzI3MSAwHgYDVQQDDBdCdXlwYXNzIENsYXNzIDMgUm9vdCBDQTCCAiIw -DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKXaCpUWUOOV8l6ddjEGMnqb8RB2uACatVI2zSRH -sJ8YZLya9vrVediQYkwiL944PdbgqOkcLNt4EemOaFEVcsfzM4fkoF0LXOBXByow9c3EN3coTRiR -5r/VUv1xLXA+58bEiuPwKAv0dpihi4dVsjoT/Lc+JzeOIuOoTyrvYLs9tznDDgFHmV0ST9tD+leh -7fmdvhFHJlsTmKtdFoqwNxxXnUX/iJY2v7vKB3tvh2PX0DJq1l1sDPGzbjniazEuOQAnFN44wOwZ -ZoYS6J1yFhNkUsepNxz9gjDthBgd9K5c/3ATAOux9TN6S9ZV+AWNS2mw9bMoNlwUxFFzTWsL8TQH -2xc519woe2v1n/MuwU8XKhDzzMro6/1rqy6any2CbgTUUgGTLT2G/H783+9CHaZr77kgxve9oKeV -/afmiSTYzIw0bOIjL9kSGiG5VZFvC5F5GQytQIgLcOJ60g7YaEi7ghM5EFjp2CoHxhLbWNvSO1UQ -RwUVZ2J+GGOmRj8JDlQyXr8NYnon74Do29lLBlo3WiXQCBJ31G8JUJc9yB3D34xFMFbG02SrZvPA -Xpacw8Tvw3xrizp5f7NJzz3iiZ+gMEuFuZyUJHmPfWupRWgPK9Dx2hzLabjKSWJtyNBjYt1gD1iq -j6G8BaVmos8bdrKEZLFMOVLAMLrwjEsCsLa3AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD -VR0OBBYEFEe4zf/lb+74suwvTg75JbCOPGvDMA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQsF -AAOCAgEAACAjQTUEkMJAYmDv4jVM1z+s4jSQuKFvdvoWFqRINyzpkMLyPPgKn9iB5btb2iUspKdV -cSQy9sgL8rxq+JOssgfCX5/bzMiKqr5qb+FJEMwx14C7u8jYog5kV+qi9cKpMRXSIGrs/CIBKM+G -uIAeqcwRpTzyFrNHnfzSgCHEy9BHcEGhyoMZCCxt8l13nIoUE9Q2HJLw5QY33KbmkJs4j1xrG0aG -Q0JfPgEHU1RdZX33inOhmlRaHylDFCfChQ+1iHsaO5S3HWCntZznKWlXWpuTekMwGwPXYshApqr8 -ZORK15FTAaggiG6cX0S5y2CBNOxv033aSF/rtJC8LakcC6wc1aJoIIAE1vyxjy+7SjENSoYc6+I2 -KSb12tjE8nVhz36udmNKekBlk4f4HoCMhuWG1o8O/FMsYOgWYRqiPkN7zTlgVGr18okmAWiDSKIz -6MkEkbIRNBE+6tBDGR8Dk5AM/1E9V/RBbuHLoL7ryWPNbczk+DaqaJ3tvV2XcEQNtg413OEMXbug -UZTLfhbrES+jkkXITHHZvMmZUldGL1DPvTVp9D0VzgalLA8+9oG6lLvDu79leNKGef9JOxqDDPDe -eOzI8k1MGt6CKfjBWtrt7uYnXuhF0J0cUahoq0Tj0Itq4/g7u9xN12TyUb7mqqta6THuBrxzvxNi -Cp/HuZc= ------END CERTIFICATE----- - -T-TeleSec GlobalRoot Class 3 -============================ ------BEGIN CERTIFICATE----- -MIIDwzCCAqugAwIBAgIBATANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoM -IlQtU3lzdGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBU -cnVzdCBDZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDMwHhcNMDgx -MDAxMTAyOTU2WhcNMzMxMDAxMjM1OTU5WjCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoMIlQtU3lz -dGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBD -ZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDMwggEiMA0GCSqGSIb3 -DQEBAQUAA4IBDwAwggEKAoIBAQC9dZPwYiJvJK7genasfb3ZJNW4t/zN8ELg63iIVl6bmlQdTQyK -9tPPcPRStdiTBONGhnFBSivwKixVA9ZIw+A5OO3yXDw/RLyTPWGrTs0NvvAgJ1gORH8EGoel15YU -NpDQSXuhdfsaa3Ox+M6pCSzyU9XDFES4hqX2iys52qMzVNn6chr3IhUciJFrf2blw2qAsCTz34ZF -iP0Zf3WHHx+xGwpzJFu5ZeAsVMhg02YXP+HMVDNzkQI6pn97djmiH5a2OK61yJN0HZ65tOVgnS9W -0eDrXltMEnAMbEQgqxHY9Bn20pxSN+f6tsIxO0rUFJmtxxr1XV/6B7h8DR/Wgx6zAgMBAAGjQjBA -MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBS1A/d2O2GCahKqGFPr -AyGUv/7OyjANBgkqhkiG9w0BAQsFAAOCAQEAVj3vlNW92nOyWL6ukK2YJ5f+AbGwUgC4TeQbIXQb -fsDuXmkqJa9c1h3a0nnJ85cp4IaH3gRZD/FZ1GSFS5mvJQQeyUapl96Cshtwn5z2r3Ex3XsFpSzT -ucpH9sry9uetuUg/vBa3wW306gmv7PO15wWeph6KU1HWk4HMdJP2udqmJQV0eVp+QD6CSyYRMG7h -P0HHRwA11fXT91Q+gT3aSWqas+8QPebrb9HIIkfLzM8BMZLZGOMivgkeGj5asuRrDFR6fUNOuIml -e9eiPZaGzPImNC1qkp2aGtAw4l1OBLBfiyB+d8E9lYLRRpo7PHi4b6HQDWSieB4pTpPDpFQUWw== ------END CERTIFICATE----- - -EE Certification Centre Root CA -=============================== ------BEGIN CERTIFICATE----- -MIIEAzCCAuugAwIBAgIQVID5oHPtPwBMyonY43HmSjANBgkqhkiG9w0BAQUFADB1MQswCQYDVQQG -EwJFRTEiMCAGA1UECgwZQVMgU2VydGlmaXRzZWVyaW1pc2tlc2t1czEoMCYGA1UEAwwfRUUgQ2Vy -dGlmaWNhdGlvbiBDZW50cmUgUm9vdCBDQTEYMBYGCSqGSIb3DQEJARYJcGtpQHNrLmVlMCIYDzIw -MTAxMDMwMTAxMDMwWhgPMjAzMDEyMTcyMzU5NTlaMHUxCzAJBgNVBAYTAkVFMSIwIAYDVQQKDBlB -UyBTZXJ0aWZpdHNlZXJpbWlza2Vza3VzMSgwJgYDVQQDDB9FRSBDZXJ0aWZpY2F0aW9uIENlbnRy -ZSBSb290IENBMRgwFgYJKoZIhvcNAQkBFglwa2lAc2suZWUwggEiMA0GCSqGSIb3DQEBAQUAA4IB -DwAwggEKAoIBAQDIIMDs4MVLqwd4lfNE7vsLDP90jmG7sWLqI9iroWUyeuuOF0+W2Ap7kaJjbMeM -TC55v6kF/GlclY1i+blw7cNRfdCT5mzrMEvhvH2/UpvObntl8jixwKIy72KyaOBhU8E2lf/slLo2 -rpwcpzIP5Xy0xm90/XsY6KxX7QYgSzIwWFv9zajmofxwvI6Sc9uXp3whrj3B9UiHbCe9nyV0gVWw -93X2PaRka9ZP585ArQ/dMtO8ihJTmMmJ+xAdTX7Nfh9WDSFwhfYggx/2uh8Ej+p3iDXE/+pOoYtN -P2MbRMNE1CV2yreN1x5KZmTNXMWcg+HCCIia7E6j8T4cLNlsHaFLAgMBAAGjgYowgYcwDwYDVR0T -AQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFBLyWj7qVhy/zQas8fElyalL1BSZ -MEUGA1UdJQQ+MDwGCCsGAQUFBwMCBggrBgEFBQcDAQYIKwYBBQUHAwMGCCsGAQUFBwMEBggrBgEF -BQcDCAYIKwYBBQUHAwkwDQYJKoZIhvcNAQEFBQADggEBAHv25MANqhlHt01Xo/6tu7Fq1Q+e2+Rj -xY6hUFaTlrg4wCQiZrxTFGGVv9DHKpY5P30osxBAIWrEr7BSdxjhlthWXePdNl4dp1BUoMUq5KqM -lIpPnTX/dqQGE5Gion0ARD9V04I8GtVbvFZMIi5GQ4okQC3zErg7cBqklrkar4dBGmoYDQZPxz5u -uSlNDUmJEYcyW+ZLBMjkXOZ0c5RdFpgTlf7727FE5TpwrDdr5rMzcijJs1eg9gIWiAYLtqZLICjU -3j2LrTcFU3T+bsy8QxdxXvnFzBqpYe73dgzzcvRyrc9yAjYHR8/vGVCJYMzpJJUPwssd8m92kMfM -dcGWxZ0= ------END CERTIFICATE----- - -TURKTRUST Certificate Services Provider Root 2007 -================================================= ------BEGIN CERTIFICATE----- -MIIEPTCCAyWgAwIBAgIBATANBgkqhkiG9w0BAQUFADCBvzE/MD0GA1UEAww2VMOcUktUUlVTVCBF -bGVrdHJvbmlrIFNlcnRpZmlrYSBIaXptZXQgU2HEn2xhecSxY8Sxc8SxMQswCQYDVQQGEwJUUjEP -MA0GA1UEBwwGQW5rYXJhMV4wXAYDVQQKDFVUw5xSS1RSVVNUIEJpbGdpIMSwbGV0acWfaW0gdmUg -QmlsacWfaW0gR8O8dmVubGnEn2kgSGl6bWV0bGVyaSBBLsWeLiAoYykgQXJhbMSxayAyMDA3MB4X -DTA3MTIyNTE4MzcxOVoXDTE3MTIyMjE4MzcxOVowgb8xPzA9BgNVBAMMNlTDnFJLVFJVU1QgRWxl -a3Ryb25payBTZXJ0aWZpa2EgSGl6bWV0IFNhxJ9sYXnEsWPEsXPEsTELMAkGA1UEBhMCVFIxDzAN -BgNVBAcMBkFua2FyYTFeMFwGA1UECgxVVMOcUktUUlVTVCBCaWxnaSDEsGxldGnFn2ltIHZlIEJp -bGnFn2ltIEfDvHZlbmxpxJ9pIEhpem1ldGxlcmkgQS7Fni4gKGMpIEFyYWzEsWsgMjAwNzCCASIw -DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKu3PgqMyKVYFeaK7yc9SrToJdPNM8Ig3BnuiD9N -YvDdE3ePYakqtdTyuTFYKTsvP2qcb3N2Je40IIDu6rfwxArNK4aUyeNgsURSsloptJGXg9i3phQv -KUmi8wUG+7RP2qFsmmaf8EMJyupyj+sA1zU511YXRxcw9L6/P8JorzZAwan0qafoEGsIiveGHtya -KhUG9qPw9ODHFNRRf8+0222vR5YXm3dx2KdxnSQM9pQ/hTEST7ruToK4uT6PIzdezKKqdfcYbwnT -rqdUKDT74eA7YH2gvnmJhsifLfkKS8RQouf9eRbHegsYz85M733WB2+Y8a+xwXrXgTW4qhe04MsC -AwEAAaNCMEAwHQYDVR0OBBYEFCnFkKslrxHkYb+j/4hhkeYO/pyBMA4GA1UdDwEB/wQEAwIBBjAP -BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQAQDdr4Ouwo0RSVgrESLFF6QSU2TJ/s -Px+EnWVUXKgWAkD6bho3hO9ynYYKVZ1WKKxmLNA6VpM0ByWtCLCPyA8JWcqdmBzlVPi5RX9ql2+I -aE1KBiY3iAIOtsbWcpnOa3faYjGkVh+uX4132l32iPwa2Z61gfAyuOOI0JzzaqC5mxRZNTZPz/OO -Xl0XrRWV2N2y1RVuAE6zS89mlOTgzbUF2mNXi+WzqtvALhyQRNsaXRik7r4EW5nVcV9VZWRi1aKb -BFmGyGJ353yCRWo9F7/snXUMrqNvWtMvmDb08PUZqxFdyKbjKlhqQgnDvZImZjINXQhVdP+MmNAK -poRq0Tl9 ------END CERTIFICATE----- - -D-TRUST Root Class 3 CA 2 2009 -============================== ------BEGIN CERTIFICATE----- -MIIEMzCCAxugAwIBAgIDCYPzMA0GCSqGSIb3DQEBCwUAME0xCzAJBgNVBAYTAkRFMRUwEwYDVQQK -DAxELVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgMjAwOTAe -Fw0wOTExMDUwODM1NThaFw0yOTExMDUwODM1NThaME0xCzAJBgNVBAYTAkRFMRUwEwYDVQQKDAxE -LVRydXN0IEdtYkgxJzAlBgNVBAMMHkQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgMjAwOTCCASIw -DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANOySs96R+91myP6Oi/WUEWJNTrGa9v+2wBoqOAD -ER03UAifTUpolDWzU9GUY6cgVq/eUXjsKj3zSEhQPgrfRlWLJ23DEE0NkVJD2IfgXU42tSHKXzlA -BF9bfsyjxiupQB7ZNoTWSPOSHjRGICTBpFGOShrvUD9pXRl/RcPHAY9RySPocq60vFYJfxLLHLGv -KZAKyVXMD9O0Gu1HNVpK7ZxzBCHQqr0ME7UAyiZsxGsMlFqVlNpQmvH/pStmMaTJOKDfHR+4CS7z -p+hnUquVH+BGPtikw8paxTGA6Eian5Rp/hnd2HN8gcqW3o7tszIFZYQ05ub9VxC1X3a/L7AQDcUC -AwEAAaOCARowggEWMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFP3aFMSfMN4hvR5COfyrYyNJ -4PGEMA4GA1UdDwEB/wQEAwIBBjCB0wYDVR0fBIHLMIHIMIGAoH6gfIZ6bGRhcDovL2RpcmVjdG9y -eS5kLXRydXN0Lm5ldC9DTj1ELVRSVVNUJTIwUm9vdCUyMENsYXNzJTIwMyUyMENBJTIwMiUyMDIw -MDksTz1ELVRydXN0JTIwR21iSCxDPURFP2NlcnRpZmljYXRlcmV2b2NhdGlvbmxpc3QwQ6BBoD+G -PWh0dHA6Ly93d3cuZC10cnVzdC5uZXQvY3JsL2QtdHJ1c3Rfcm9vdF9jbGFzc18zX2NhXzJfMjAw -OS5jcmwwDQYJKoZIhvcNAQELBQADggEBAH+X2zDI36ScfSF6gHDOFBJpiBSVYEQBrLLpME+bUMJm -2H6NMLVwMeniacfzcNsgFYbQDfC+rAF1hM5+n02/t2A7nPPKHeJeaNijnZflQGDSNiH+0LS4F9p0 -o3/U37CYAqxva2ssJSRyoWXuJVrl5jLn8t+rSfrzkGkj2wTZ51xY/GXUl77M/C4KzCUqNQT4YJEV -dT1B/yMfGchs64JTBKbkTCJNjYy6zltz7GRUUG3RnFX7acM2w4y8PIWmawomDeCTmGCufsYkl4ph -X5GOZpIJhzbNi5stPvZR1FDUWSi9g/LMKHtThm3YJohw1+qRzT65ysCQblrGXnRl11z+o+I= ------END CERTIFICATE----- - -D-TRUST Root Class 3 CA 2 EV 2009 -================================= ------BEGIN CERTIFICATE----- -MIIEQzCCAyugAwIBAgIDCYP0MA0GCSqGSIb3DQEBCwUAMFAxCzAJBgNVBAYTAkRFMRUwEwYDVQQK -DAxELVRydXN0IEdtYkgxKjAoBgNVBAMMIUQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgRVYgMjAw -OTAeFw0wOTExMDUwODUwNDZaFw0yOTExMDUwODUwNDZaMFAxCzAJBgNVBAYTAkRFMRUwEwYDVQQK -DAxELVRydXN0IEdtYkgxKjAoBgNVBAMMIUQtVFJVU1QgUm9vdCBDbGFzcyAzIENBIDIgRVYgMjAw -OTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJnxhDRwui+3MKCOvXwEz75ivJn9gpfS -egpnljgJ9hBOlSJzmY3aFS3nBfwZcyK3jpgAvDw9rKFs+9Z5JUut8Mxk2og+KbgPCdM03TP1YtHh -zRnp7hhPTFiu4h7WDFsVWtg6uMQYZB7jM7K1iXdODL/ZlGsTl28So/6ZqQTMFexgaDbtCHu39b+T -7WYxg4zGcTSHThfqr4uRjRxWQa4iN1438h3Z0S0NL2lRp75mpoo6Kr3HGrHhFPC+Oh25z1uxav60 -sUYgovseO3Dvk5h9jHOW8sXvhXCtKSb8HgQ+HKDYD8tSg2J87otTlZCpV6LqYQXY+U3EJ/pure35 -11H3a6UCAwEAAaOCASQwggEgMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFNOUikxiEyoZLsyv -cop9NteaHNxnMA4GA1UdDwEB/wQEAwIBBjCB3QYDVR0fBIHVMIHSMIGHoIGEoIGBhn9sZGFwOi8v -ZGlyZWN0b3J5LmQtdHJ1c3QubmV0L0NOPUQtVFJVU1QlMjBSb290JTIwQ2xhc3MlMjAzJTIwQ0El -MjAyJTIwRVYlMjAyMDA5LE89RC1UcnVzdCUyMEdtYkgsQz1ERT9jZXJ0aWZpY2F0ZXJldm9jYXRp -b25saXN0MEagRKBChkBodHRwOi8vd3d3LmQtdHJ1c3QubmV0L2NybC9kLXRydXN0X3Jvb3RfY2xh -c3NfM19jYV8yX2V2XzIwMDkuY3JsMA0GCSqGSIb3DQEBCwUAA4IBAQA07XtaPKSUiO8aEXUHL7P+ -PPoeUSbrh/Yp3uDx1MYkCenBz1UbtDDZzhr+BlGmFaQt77JLvyAoJUnRpjZ3NOhk31KxEcdzes05 -nsKtjHEh8lprr988TlWvsoRlFIm5d8sqMb7Po23Pb0iUMkZv53GMoKaEGTcH8gNFCSuGdXzfX2lX -ANtu2KZyIktQ1HWYVt+3GP9DQ1CuekR78HlR10M9p9OB0/DJT7naxpeG0ILD5EJt/rDiZE4OJudA -NCa1CInXCGNjOCd1HjPqbqjdn5lPdE2BiYBL3ZqXKVwvvoFBuYz/6n1gBp7N1z3TLqMVvKjmJuVv -w9y4AyHqnxbxLFS1 ------END CERTIFICATE----- - -PSCProcert -========== ------BEGIN CERTIFICATE----- -MIIJhjCCB26gAwIBAgIBCzANBgkqhkiG9w0BAQsFADCCAR4xPjA8BgNVBAMTNUF1dG9yaWRhZCBk -ZSBDZXJ0aWZpY2FjaW9uIFJhaXogZGVsIEVzdGFkbyBWZW5lem9sYW5vMQswCQYDVQQGEwJWRTEQ -MA4GA1UEBxMHQ2FyYWNhczEZMBcGA1UECBMQRGlzdHJpdG8gQ2FwaXRhbDE2MDQGA1UEChMtU2lz -dGVtYSBOYWNpb25hbCBkZSBDZXJ0aWZpY2FjaW9uIEVsZWN0cm9uaWNhMUMwQQYDVQQLEzpTdXBl -cmludGVuZGVuY2lhIGRlIFNlcnZpY2lvcyBkZSBDZXJ0aWZpY2FjaW9uIEVsZWN0cm9uaWNhMSUw -IwYJKoZIhvcNAQkBFhZhY3JhaXpAc3VzY2VydGUuZ29iLnZlMB4XDTEwMTIyODE2NTEwMFoXDTIw -MTIyNTIzNTk1OVowgdExJjAkBgkqhkiG9w0BCQEWF2NvbnRhY3RvQHByb2NlcnQubmV0LnZlMQ8w -DQYDVQQHEwZDaGFjYW8xEDAOBgNVBAgTB01pcmFuZGExKjAoBgNVBAsTIVByb3ZlZWRvciBkZSBD -ZXJ0aWZpY2Fkb3MgUFJPQ0VSVDE2MDQGA1UEChMtU2lzdGVtYSBOYWNpb25hbCBkZSBDZXJ0aWZp -Y2FjaW9uIEVsZWN0cm9uaWNhMQswCQYDVQQGEwJWRTETMBEGA1UEAxMKUFNDUHJvY2VydDCCAiIw -DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANW39KOUM6FGqVVhSQ2oh3NekS1wwQYalNo97BVC -wfWMrmoX8Yqt/ICV6oNEolt6Vc5Pp6XVurgfoCfAUFM+jbnADrgV3NZs+J74BCXfgI8Qhd19L3uA -3VcAZCP4bsm+lU/hdezgfl6VzbHvvnpC2Mks0+saGiKLt38GieU89RLAu9MLmV+QfI4tL3czkkoh -RqipCKzx9hEC2ZUWno0vluYC3XXCFCpa1sl9JcLB/KpnheLsvtF8PPqv1W7/U0HU9TI4seJfxPmO -EO8GqQKJ/+MMbpfg353bIdD0PghpbNjU5Db4g7ayNo+c7zo3Fn2/omnXO1ty0K+qP1xmk6wKImG2 -0qCZyFSTXai20b1dCl53lKItwIKOvMoDKjSuc/HUtQy9vmebVOvh+qBa7Dh+PsHMosdEMXXqP+UH -0quhJZb25uSgXTcYOWEAM11G1ADEtMo88aKjPvM6/2kwLkDd9p+cJsmWN63nOaK/6mnbVSKVUyqU -td+tFjiBdWbjxywbk5yqjKPK2Ww8F22c3HxT4CAnQzb5EuE8XL1mv6JpIzi4mWCZDlZTOpx+FIyw -Bm/xhnaQr/2v/pDGj59/i5IjnOcVdo/Vi5QTcmn7K2FjiO/mpF7moxdqWEfLcU8UC17IAggmosvp -r2uKGcfLFFb14dq12fy/czja+eevbqQ34gcnAgMBAAGjggMXMIIDEzASBgNVHRMBAf8ECDAGAQH/ -AgEBMDcGA1UdEgQwMC6CD3N1c2NlcnRlLmdvYi52ZaAbBgVghl4CAqASDBBSSUYtRy0yMDAwNDAz -Ni0wMB0GA1UdDgQWBBRBDxk4qpl/Qguk1yeYVKIXTC1RVDCCAVAGA1UdIwSCAUcwggFDgBStuyId -xuDSAaj9dlBSk+2YwU2u06GCASakggEiMIIBHjE+MDwGA1UEAxM1QXV0b3JpZGFkIGRlIENlcnRp -ZmljYWNpb24gUmFpeiBkZWwgRXN0YWRvIFZlbmV6b2xhbm8xCzAJBgNVBAYTAlZFMRAwDgYDVQQH -EwdDYXJhY2FzMRkwFwYDVQQIExBEaXN0cml0byBDYXBpdGFsMTYwNAYDVQQKEy1TaXN0ZW1hIE5h -Y2lvbmFsIGRlIENlcnRpZmljYWNpb24gRWxlY3Ryb25pY2ExQzBBBgNVBAsTOlN1cGVyaW50ZW5k -ZW5jaWEgZGUgU2VydmljaW9zIGRlIENlcnRpZmljYWNpb24gRWxlY3Ryb25pY2ExJTAjBgkqhkiG -9w0BCQEWFmFjcmFpekBzdXNjZXJ0ZS5nb2IudmWCAQowDgYDVR0PAQH/BAQDAgEGME0GA1UdEQRG -MESCDnByb2NlcnQubmV0LnZloBUGBWCGXgIBoAwMClBTQy0wMDAwMDKgGwYFYIZeAgKgEgwQUklG -LUotMzE2MzUzNzMtNzB2BgNVHR8EbzBtMEagRKBChkBodHRwOi8vd3d3LnN1c2NlcnRlLmdvYi52 -ZS9sY3IvQ0VSVElGSUNBRE8tUkFJWi1TSEEzODRDUkxERVIuY3JsMCOgIaAfhh1sZGFwOi8vYWNy -YWl6LnN1c2NlcnRlLmdvYi52ZTA3BggrBgEFBQcBAQQrMCkwJwYIKwYBBQUHMAGGG2h0dHA6Ly9v -Y3NwLnN1c2NlcnRlLmdvYi52ZTBBBgNVHSAEOjA4MDYGBmCGXgMBAjAsMCoGCCsGAQUFBwIBFh5o -dHRwOi8vd3d3LnN1c2NlcnRlLmdvYi52ZS9kcGMwDQYJKoZIhvcNAQELBQADggIBACtZ6yKZu4Sq -T96QxtGGcSOeSwORR3C7wJJg7ODU523G0+1ng3dS1fLld6c2suNUvtm7CpsR72H0xpkzmfWvADmN -g7+mvTV+LFwxNG9s2/NkAZiqlCxB3RWGymspThbASfzXg0gTB1GEMVKIu4YXx2sviiCtxQuPcD4q -uxtxj7mkoP3YldmvWb8lK5jpY5MvYB7Eqvh39YtsL+1+LrVPQA3uvFd359m21D+VJzog1eWuq2w1 -n8GhHVnchIHuTQfiSLaeS5UtQbHh6N5+LwUeaO6/u5BlOsju6rEYNxxik6SgMexxbJHmpHmJWhSn -FFAFTKQAVzAswbVhltw+HoSvOULP5dAssSS830DD7X9jSr3hTxJkhpXzsOfIt+FTvZLm8wyWuevo -5pLtp4EJFAv8lXrPj9Y0TzYS3F7RNHXGRoAvlQSMx4bEqCaJqD8Zm4G7UaRKhqsLEQ+xrmNTbSjq -3TNWOByyrYDT13K9mmyZY+gAu0F2BbdbmRiKw7gSXFbPVgx96OLP7bx0R/vu0xdOIk9W/1DzLuY5 -poLWccret9W6aAjtmcz9opLLabid+Qqkpj5PkygqYWwHJgD/ll9ohri4zspV4KuxPX+Y1zMOWj3Y -eMLEYC/HYvBhkdI4sPaeVdtAgAUSM84dkpvRabP/v/GSCmE1P93+hvS84Bpxs2Km ------END CERTIFICATE----- - -China Internet Network Information Center EV Certificates Root -============================================================== ------BEGIN CERTIFICATE----- -MIID9zCCAt+gAwIBAgIESJ8AATANBgkqhkiG9w0BAQUFADCBijELMAkGA1UEBhMCQ04xMjAwBgNV -BAoMKUNoaW5hIEludGVybmV0IE5ldHdvcmsgSW5mb3JtYXRpb24gQ2VudGVyMUcwRQYDVQQDDD5D -aGluYSBJbnRlcm5ldCBOZXR3b3JrIEluZm9ybWF0aW9uIENlbnRlciBFViBDZXJ0aWZpY2F0ZXMg -Um9vdDAeFw0xMDA4MzEwNzExMjVaFw0zMDA4MzEwNzExMjVaMIGKMQswCQYDVQQGEwJDTjEyMDAG -A1UECgwpQ2hpbmEgSW50ZXJuZXQgTmV0d29yayBJbmZvcm1hdGlvbiBDZW50ZXIxRzBFBgNVBAMM -PkNoaW5hIEludGVybmV0IE5ldHdvcmsgSW5mb3JtYXRpb24gQ2VudGVyIEVWIENlcnRpZmljYXRl -cyBSb290MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm35z7r07eKpkQ0H1UN+U8i6y -jUqORlTSIRLIOTJCBumD1Z9S7eVnAztUwYyZmczpwA//DdmEEbK40ctb3B75aDFk4Zv6dOtouSCV -98YPjUesWgbdYavi7NifFy2cyjw1l1VxzUOFsUcW9SxTgHbP0wBkvUCZ3czY28Sf1hNfQYOL+Q2H -klY0bBoQCxfVWhyXWIQ8hBouXJE0bhlffxdpxWXvayHG1VA6v2G5BY3vbzQ6sm8UY78WO5upKv23 -KzhmBsUs4qpnHkWnjQRmQvaPK++IIGmPMowUc9orhpFjIpryp9vOiYurXccUwVswah+xt54ugQEC -7c+WXmPbqOY4twIDAQABo2MwYTAfBgNVHSMEGDAWgBR8cks5x8DbYqVPm6oYNJKiyoOCWTAPBgNV -HRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUfHJLOcfA22KlT5uqGDSSosqD -glkwDQYJKoZIhvcNAQEFBQADggEBACrDx0M3j92tpLIM7twUbY8opJhJywyA6vPtI2Z1fcXTIWd5 -0XPFtQO3WKwMVC/GVhMPMdoG52U7HW8228gd+f2ABsqjPWYWqJ1MFn3AlUa1UeTiH9fqBk1jjZaM -7+czV0I664zBechNdn3e9rG3geCg+aF4RhcaVpjwTj2rHO3sOdwHSPdj/gauwqRcalsyiMXHM4Ws -ZkJHwlgkmeHlPuV1LI5D1l08eB6olYIpUNHRFrrvwb562bTYzB5MRuF3sTGrvSrIzo9uoV1/A3U0 -5K2JRVRevq4opbs/eHnrc7MKDf2+yfdWrPa37S+bISnHOLaVxATywy39FCqQmbkHzJ8= ------END CERTIFICATE----- - -Swisscom Root CA 2 -================== ------BEGIN CERTIFICATE----- -MIIF2TCCA8GgAwIBAgIQHp4o6Ejy5e/DfEoeWhhntjANBgkqhkiG9w0BAQsFADBkMQswCQYDVQQG -EwJjaDERMA8GA1UEChMIU3dpc3Njb20xJTAjBgNVBAsTHERpZ2l0YWwgQ2VydGlmaWNhdGUgU2Vy -dmljZXMxGzAZBgNVBAMTElN3aXNzY29tIFJvb3QgQ0EgMjAeFw0xMTA2MjQwODM4MTRaFw0zMTA2 -MjUwNzM4MTRaMGQxCzAJBgNVBAYTAmNoMREwDwYDVQQKEwhTd2lzc2NvbTElMCMGA1UECxMcRGln -aXRhbCBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczEbMBkGA1UEAxMSU3dpc3Njb20gUm9vdCBDQSAyMIIC -IjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAlUJOhJ1R5tMJ6HJaI2nbeHCOFvErjw0DzpPM -LgAIe6szjPTpQOYXTKueuEcUMncy3SgM3hhLX3af+Dk7/E6J2HzFZ++r0rk0X2s682Q2zsKwzxNo -ysjL67XiPS4h3+os1OD5cJZM/2pYmLcX5BtS5X4HAB1f2uY+lQS3aYg5oUFgJWFLlTloYhyxCwWJ -wDaCFCE/rtuh/bxvHGCGtlOUSbkrRsVPACu/obvLP+DHVxxX6NZp+MEkUp2IVd3Chy50I9AU/SpH -Wrumnf2U5NGKpV+GY3aFy6//SSj8gO1MedK75MDvAe5QQQg1I3ArqRa0jG6F6bYRzzHdUyYb3y1a -SgJA/MTAtukxGggo5WDDH8SQjhBiYEQN7Aq+VRhxLKX0srwVYv8c474d2h5Xszx+zYIdkeNL6yxS -NLCK/RJOlrDrcH+eOfdmQrGrrFLadkBXeyq96G4DsguAhYidDMfCd7Camlf0uPoTXGiTOmekl9Ab -mbeGMktg2M7v0Ax/lZ9vh0+Hio5fCHyqW/xavqGRn1V9TrALacywlKinh/LTSlDcX3KwFnUey7QY -Ypqwpzmqm59m2I2mbJYV4+by+PGDYmy7Velhk6M99bFXi08jsJvllGov34zflVEpYKELKeRcVVi3 -qPyZ7iVNTA6z00yPhOgpD/0QVAKFyPnlw4vP5w8CAwEAAaOBhjCBgzAOBgNVHQ8BAf8EBAMCAYYw -HQYDVR0hBBYwFDASBgdghXQBUwIBBgdghXQBUwIBMBIGA1UdEwEB/wQIMAYBAf8CAQcwHQYDVR0O -BBYEFE0mICKJS9PVpAqhb97iEoHF8TwuMB8GA1UdIwQYMBaAFE0mICKJS9PVpAqhb97iEoHF8Twu -MA0GCSqGSIb3DQEBCwUAA4ICAQAyCrKkG8t9voJXiblqf/P0wS4RfbgZPnm3qKhyN2abGu2sEzsO -v2LwnN+ee6FTSA5BesogpxcbtnjsQJHzQq0Qw1zv/2BZf82Fo4s9SBwlAjxnffUy6S8w5X2lejjQ -82YqZh6NM4OKb3xuqFp1mrjX2lhIREeoTPpMSQpKwhI3qEAMw8jh0FcNlzKVxzqfl9NX+Ave5XLz -o9v/tdhZsnPdTSpxsrpJ9csc1fV5yJmz/MFMdOO0vSk3FQQoHt5FRnDsr7p4DooqzgB53MBfGWcs -a0vvaGgLQ+OswWIJ76bdZWGgr4RVSJFSHMYlkSrQwSIjYVmvRRGFHQEkNI/Ps/8XciATwoCqISxx -OQ7Qj1zB09GOInJGTB2Wrk9xseEFKZZZ9LuedT3PDTcNYtsmjGOpI99nBjx8Oto0QuFmtEYE3saW -mA9LSHokMnWRn6z3aOkquVVlzl1h0ydw2Df+n7mvoC5Wt6NlUe07qxS/TFED6F+KBZvuim6c779o -+sjaC+NCydAXFJy3SuCvkychVSa1ZC+N8f+mQAWFBVzKBxlcCxMoTFh/wqXvRdpg065lYZ1Tg3TC -rvJcwhbtkj6EPnNgiLx29CzP0H1907he0ZESEOnN3col49XtmS++dYFLJPlFRpTJKSFTnCZFqhMX -5OfNeOI5wSsSnqaeG8XmDtkx2Q== ------END CERTIFICATE----- - -Swisscom Root EV CA 2 -===================== ------BEGIN CERTIFICATE----- -MIIF4DCCA8igAwIBAgIRAPL6ZOJ0Y9ON/RAdBB92ylgwDQYJKoZIhvcNAQELBQAwZzELMAkGA1UE -BhMCY2gxETAPBgNVBAoTCFN3aXNzY29tMSUwIwYDVQQLExxEaWdpdGFsIENlcnRpZmljYXRlIFNl -cnZpY2VzMR4wHAYDVQQDExVTd2lzc2NvbSBSb290IEVWIENBIDIwHhcNMTEwNjI0MDk0NTA4WhcN -MzEwNjI1MDg0NTA4WjBnMQswCQYDVQQGEwJjaDERMA8GA1UEChMIU3dpc3Njb20xJTAjBgNVBAsT -HERpZ2l0YWwgQ2VydGlmaWNhdGUgU2VydmljZXMxHjAcBgNVBAMTFVN3aXNzY29tIFJvb3QgRVYg -Q0EgMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMT3HS9X6lds93BdY7BxUglgRCgz -o3pOCvrY6myLURYaVa5UJsTMRQdBTxB5f3HSek4/OE6zAMaVylvNwSqD1ycfMQ4jFrclyxy0uYAy -Xhqdk/HoPGAsp15XGVhRXrwsVgu42O+LgrQ8uMIkqBPHoCE2G3pXKSinLr9xJZDzRINpUKTk4Rti -GZQJo/PDvO/0vezbE53PnUgJUmfANykRHvvSEaeFGHR55E+FFOtSN+KxRdjMDUN/rhPSays/p8Li -qG12W0OfvrSdsyaGOx9/5fLoZigWJdBLlzin5M8J0TbDC77aO0RYjb7xnglrPvMyxyuHxuxenPaH -Za0zKcQvidm5y8kDnftslFGXEBuGCxobP/YCfnvUxVFkKJ3106yDgYjTdLRZncHrYTNaRdHLOdAG -alNgHa/2+2m8atwBz735j9m9W8E6X47aD0upm50qKGsaCnw8qyIL5XctcfaCNYGu+HuB5ur+rPQa -m3Rc6I8k9l2dRsQs0h4rIWqDJ2dVSqTjyDKXZpBy2uPUZC5f46Fq9mDU5zXNysRojddxyNMkM3Ox -bPlq4SjbX8Y96L5V5jcb7STZDxmPX2MYWFCBUWVv8p9+agTnNCRxunZLWB4ZvRVgRaoMEkABnRDi -xzgHcgplwLa7JSnaFp6LNYth7eVxV4O1PHGf40+/fh6Bn0GXAgMBAAGjgYYwgYMwDgYDVR0PAQH/ -BAQDAgGGMB0GA1UdIQQWMBQwEgYHYIV0AVMCAgYHYIV0AVMCAjASBgNVHRMBAf8ECDAGAQH/AgED -MB0GA1UdDgQWBBRF2aWBbj2ITY1x0kbBbkUe88SAnTAfBgNVHSMEGDAWgBRF2aWBbj2ITY1x0kbB -bkUe88SAnTANBgkqhkiG9w0BAQsFAAOCAgEAlDpzBp9SSzBc1P6xXCX5145v9Ydkn+0UjrgEjihL -j6p7jjm02Vj2e6E1CqGdivdj5eu9OYLU43otb98TPLr+flaYC/NUn81ETm484T4VvwYmneTwkLbU -wp4wLh/vx3rEUMfqe9pQy3omywC0Wqu1kx+AiYQElY2NfwmTv9SoqORjbdlk5LgpWgi/UOGED1V7 -XwgiG/W9mR4U9s70WBCCswo9GcG/W6uqmdjyMb3lOGbcWAXH7WMaLgqXfIeTK7KK4/HsGOV1timH -59yLGn602MnTihdsfSlEvoqq9X46Lmgxk7lq2prg2+kupYTNHAq4Sgj5nPFhJpiTt3tm7JFe3VE/ -23MPrQRYCd0EApUKPtN236YQHoA96M2kZNEzx5LH4k5E4wnJTsJdhw4Snr8PyQUQ3nqjsTzyP6Wq -J3mtMX0f/fwZacXduT98zca0wjAefm6S139hdlqP65VNvBFuIXxZN5nQBrz5Bm0yFqXZaajh3DyA -HmBR3NdUIR7KYndP+tiPsys6DXhyyWhBWkdKwqPrGtcKqzwyVcgKEZzfdNbwQBUdyLmPtTbFr/gi -uMod89a2GQ+fYWVq6nTIfI/DT11lgh/ZDYnadXL77/FHZxOzyNEZiCcmmpl5fx7kLD977vHeTYuW -l8PVP3wbI+2ksx0WckNLIOFZfsLorSa/ovc= ------END CERTIFICATE----- - -CA Disig Root R1 -================ ------BEGIN CERTIFICATE----- -MIIFaTCCA1GgAwIBAgIJAMMDmu5QkG4oMA0GCSqGSIb3DQEBBQUAMFIxCzAJBgNVBAYTAlNLMRMw -EQYDVQQHEwpCcmF0aXNsYXZhMRMwEQYDVQQKEwpEaXNpZyBhLnMuMRkwFwYDVQQDExBDQSBEaXNp -ZyBSb290IFIxMB4XDTEyMDcxOTA5MDY1NloXDTQyMDcxOTA5MDY1NlowUjELMAkGA1UEBhMCU0sx -EzARBgNVBAcTCkJyYXRpc2xhdmExEzARBgNVBAoTCkRpc2lnIGEucy4xGTAXBgNVBAMTEENBIERp -c2lnIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCqw3j33Jijp1pedxiy -3QRkD2P9m5YJgNXoqqXinCaUOuiZc4yd39ffg/N4T0Dhf9Kn0uXKE5Pn7cZ3Xza1lK/oOI7bm+V8 -u8yN63Vz4STN5qctGS7Y1oprFOsIYgrY3LMATcMjfF9DCCMyEtztDK3AfQ+lekLZWnDZv6fXARz2 -m6uOt0qGeKAeVjGu74IKgEH3G8muqzIm1Cxr7X1r5OJeIgpFy4QxTaz+29FHuvlglzmxZcfe+5nk -CiKxLU3lSCZpq+Kq8/v8kiky6bM+TR8noc2OuRf7JT7JbvN32g0S9l3HuzYQ1VTW8+DiR0jm3hTa -YVKvJrT1cU/J19IG32PK/yHoWQbgCNWEFVP3Q+V8xaCJmGtzxmjOZd69fwX3se72V6FglcXM6pM6 -vpmumwKjrckWtc7dXpl4fho5frLABaTAgqWjR56M6ly2vGfb5ipN0gTco65F97yLnByn1tUD3AjL -LhbKXEAz6GfDLuemROoRRRw1ZS0eRWEkG4IupZ0zXWX4Qfkuy5Q/H6MMMSRE7cderVC6xkGbrPAX -ZcD4XW9boAo0PO7X6oifmPmvTiT6l7Jkdtqr9O3jw2Dv1fkCyC2fg69naQanMVXVz0tv/wQFx1is -XxYb5dKj6zHbHzMVTdDypVP1y+E9Tmgt2BLdqvLmTZtJ5cUoobqwWsagtQIDAQABo0IwQDAPBgNV -HRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUiQq0OJMa5qvum5EY+fU8PjXQ -04IwDQYJKoZIhvcNAQEFBQADggIBADKL9p1Kyb4U5YysOMo6CdQbzoaz3evUuii+Eq5FLAR0rBNR -xVgYZk2C2tXck8An4b58n1KeElb21Zyp9HWc+jcSjxyT7Ff+Bw+r1RL3D65hXlaASfX8MPWbTx9B -LxyE04nH4toCdu0Jz2zBuByDHBb6lM19oMgY0sidbvW9adRtPTXoHqJPYNcHKfyyo6SdbhWSVhlM -CrDpfNIZTUJG7L399ldb3Zh+pE3McgODWF3vkzpBemOqfDqo9ayk0d2iLbYq/J8BjuIQscTK5Gfb -VSUZP/3oNn6z4eGBrxEWi1CXYBmCAMBrTXO40RMHPuq2MU/wQppt4hF05ZSsjYSVPCGvxdpHyN85 -YmLLW1AL14FABZyb7bq2ix4Eb5YgOe2kfSnbSM6C3NQCjR0EMVrHS/BsYVLXtFHCgWzN4funodKS -ds+xDzdYpPJScWc/DIh4gInByLUfkmO+p3qKViwaqKactV2zY9ATIKHrkWzQjX2v3wvkF7mGnjix -lAxYjOBVqjtjbZqJYLhkKpLGN/R+Q0O3c+gB53+XD9fyexn9GtePyfqFa3qdnom2piiZk4hA9z7N -UaPK6u95RyG1/jLix8NRb76AdPCkwzryT+lf3xkK8jsTQ6wxpLPn6/wY1gGp8yqPNg7rtLG8t0zJ -a7+h89n07eLw4+1knj0vllJPgFOL ------END CERTIFICATE----- - -CA Disig Root R2 -================ ------BEGIN CERTIFICATE----- -MIIFaTCCA1GgAwIBAgIJAJK4iNuwisFjMA0GCSqGSIb3DQEBCwUAMFIxCzAJBgNVBAYTAlNLMRMw -EQYDVQQHEwpCcmF0aXNsYXZhMRMwEQYDVQQKEwpEaXNpZyBhLnMuMRkwFwYDVQQDExBDQSBEaXNp -ZyBSb290IFIyMB4XDTEyMDcxOTA5MTUzMFoXDTQyMDcxOTA5MTUzMFowUjELMAkGA1UEBhMCU0sx -EzARBgNVBAcTCkJyYXRpc2xhdmExEzARBgNVBAoTCkRpc2lnIGEucy4xGTAXBgNVBAMTEENBIERp -c2lnIFJvb3QgUjIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCio8QACdaFXS1tFPbC -w3OeNcJxVX6B+6tGUODBfEl45qt5WDza/3wcn9iXAng+a0EE6UG9vgMsRfYvZNSrXaNHPWSb6Wia -xswbP7q+sos0Ai6YVRn8jG+qX9pMzk0DIaPY0jSTVpbLTAwAFjxfGs3Ix2ymrdMxp7zo5eFm1tL7 -A7RBZckQrg4FY8aAamkw/dLukO8NJ9+flXP04SXabBbeQTg06ov80egEFGEtQX6sx3dOy1FU+16S -GBsEWmjGycT6txOgmLcRK7fWV8x8nhfRyyX+hk4kLlYMeE2eARKmK6cBZW58Yh2EhN/qwGu1pSqV -g8NTEQxzHQuyRpDRQjrOQG6Vrf/GlK1ul4SOfW+eioANSW1z4nuSHsPzwfPrLgVv2RvPN3YEyLRa -5Beny912H9AZdugsBbPWnDTYltxhh5EF5EQIM8HauQhl1K6yNg3ruji6DOWbnuuNZt2Zz9aJQfYE -koopKW1rOhzndX0CcQ7zwOe9yxndnWCywmZgtrEE7snmhrmaZkCo5xHtgUUDi/ZnWejBBhG93c+A -Ak9lQHhcR1DIm+YfgXvkRKhbhZri3lrVx/k6RGZL5DJUfORsnLMOPReisjQS1n6yqEm70XooQL6i -Fh/f5DcfEXP7kAplQ6INfPgGAVUzfbANuPT1rqVCV3w2EYx7XsQDnYx5nQIDAQABo0IwQDAPBgNV -HRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUtZn4r7CU9eMg1gqtzk5WpC5u -Qu0wDQYJKoZIhvcNAQELBQADggIBACYGXnDnZTPIgm7ZnBc6G3pmsgH2eDtpXi/q/075KMOYKmFM -tCQSin1tERT3nLXK5ryeJ45MGcipvXrA1zYObYVybqjGom32+nNjf7xueQgcnYqfGopTpti72TVV -sRHFqQOzVju5hJMiXn7B9hJSi+osZ7z+Nkz1uM/Rs0mSO9MpDpkblvdhuDvEK7Z4bLQjb/D907Je -dR+Zlais9trhxTF7+9FGs9K8Z7RiVLoJ92Owk6Ka+elSLotgEqv89WBW7xBci8QaQtyDW2QOy7W8 -1k/BfDxujRNt+3vrMNDcTa/F1balTFtxyegxvug4BkihGuLq0t4SOVga/4AOgnXmt8kHbA7v/zjx -mHHEt38OFdAlab0inSvtBfZGR6ztwPDUO+Ls7pZbkBNOHlY667DvlruWIxG68kOGdGSVyCh13x01 -utI3gzhTODY7z2zp+WsO0PsE6E9312UBeIYMej4hYvF/Y3EMyZ9E26gnonW+boE+18DrG5gPcFw0 -sorMwIUY6256s/daoQe/qUKS82Ail+QUoQebTnbAjn39pCXHR+3/H3OszMOl6W8KjptlwlCFtaOg -UxLMVYdh84GuEEZhvUQhuMI9dM9+JDX6HAcOmz0iyu8xL4ysEr3vQCj8KWefshNPZiTEUxnpHikV -7+ZtsH8tZ/3zbBt1RqPlShfppNcL ------END CERTIFICATE----- - -ACCVRAIZ1 -========= ------BEGIN CERTIFICATE----- -MIIH0zCCBbugAwIBAgIIXsO3pkN/pOAwDQYJKoZIhvcNAQEFBQAwQjESMBAGA1UEAwwJQUNDVlJB -SVoxMRAwDgYDVQQLDAdQS0lBQ0NWMQ0wCwYDVQQKDARBQ0NWMQswCQYDVQQGEwJFUzAeFw0xMTA1 -MDUwOTM3MzdaFw0zMDEyMzEwOTM3MzdaMEIxEjAQBgNVBAMMCUFDQ1ZSQUlaMTEQMA4GA1UECwwH -UEtJQUNDVjENMAsGA1UECgwEQUNDVjELMAkGA1UEBhMCRVMwggIiMA0GCSqGSIb3DQEBAQUAA4IC -DwAwggIKAoICAQCbqau/YUqXry+XZpp0X9DZlv3P4uRm7x8fRzPCRKPfmt4ftVTdFXxpNRFvu8gM -jmoYHtiP2Ra8EEg2XPBjs5BaXCQ316PWywlxufEBcoSwfdtNgM3802/J+Nq2DoLSRYWoG2ioPej0 -RGy9ocLLA76MPhMAhN9KSMDjIgro6TenGEyxCQ0jVn8ETdkXhBilyNpAlHPrzg5XPAOBOp0KoVdD -aaxXbXmQeOW1tDvYvEyNKKGno6e6Ak4l0Squ7a4DIrhrIA8wKFSVf+DuzgpmndFALW4ir50awQUZ -0m/A8p/4e7MCQvtQqR0tkw8jq8bBD5L/0KIV9VMJcRz/RROE5iZe+OCIHAr8Fraocwa48GOEAqDG -WuzndN9wrqODJerWx5eHk6fGioozl2A3ED6XPm4pFdahD9GILBKfb6qkxkLrQaLjlUPTAYVtjrs7 -8yM2x/474KElB0iryYl0/wiPgL/AlmXz7uxLaL2diMMxs0Dx6M/2OLuc5NF/1OVYm3z61PMOm3WR -5LpSLhl+0fXNWhn8ugb2+1KoS5kE3fj5tItQo05iifCHJPqDQsGH+tUtKSpacXpkatcnYGMN285J -9Y0fkIkyF/hzQ7jSWpOGYdbhdQrqeWZ2iE9x6wQl1gpaepPluUsXQA+xtrn13k/c4LOsOxFwYIRK -Q26ZIMApcQrAZQIDAQABo4ICyzCCAscwfQYIKwYBBQUHAQEEcTBvMEwGCCsGAQUFBzAChkBodHRw -Oi8vd3d3LmFjY3YuZXMvZmlsZWFkbWluL0FyY2hpdm9zL2NlcnRpZmljYWRvcy9yYWl6YWNjdjEu -Y3J0MB8GCCsGAQUFBzABhhNodHRwOi8vb2NzcC5hY2N2LmVzMB0GA1UdDgQWBBTSh7Tj3zcnk1X2 -VuqB5TbMjB4/vTAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFNKHtOPfNyeTVfZW6oHlNsyM -Hj+9MIIBcwYDVR0gBIIBajCCAWYwggFiBgRVHSAAMIIBWDCCASIGCCsGAQUFBwICMIIBFB6CARAA -QQB1AHQAbwByAGkAZABhAGQAIABkAGUAIABDAGUAcgB0AGkAZgBpAGMAYQBjAGkA8wBuACAAUgBh -AO0AegAgAGQAZQAgAGwAYQAgAEEAQwBDAFYAIAAoAEEAZwBlAG4AYwBpAGEAIABkAGUAIABUAGUA -YwBuAG8AbABvAGcA7QBhACAAeQAgAEMAZQByAHQAaQBmAGkAYwBhAGMAaQDzAG4AIABFAGwAZQBj -AHQAcgDzAG4AaQBjAGEALAAgAEMASQBGACAAUQA0ADYAMAAxADEANQA2AEUAKQAuACAAQwBQAFMA -IABlAG4AIABoAHQAdABwADoALwAvAHcAdwB3AC4AYQBjAGMAdgAuAGUAczAwBggrBgEFBQcCARYk -aHR0cDovL3d3dy5hY2N2LmVzL2xlZ2lzbGFjaW9uX2MuaHRtMFUGA1UdHwROMEwwSqBIoEaGRGh0 -dHA6Ly93d3cuYWNjdi5lcy9maWxlYWRtaW4vQXJjaGl2b3MvY2VydGlmaWNhZG9zL3JhaXphY2N2 -MV9kZXIuY3JsMA4GA1UdDwEB/wQEAwIBBjAXBgNVHREEEDAOgQxhY2N2QGFjY3YuZXMwDQYJKoZI -hvcNAQEFBQADggIBAJcxAp/n/UNnSEQU5CmH7UwoZtCPNdpNYbdKl02125DgBS4OxnnQ8pdpD70E -R9m+27Up2pvZrqmZ1dM8MJP1jaGo/AaNRPTKFpV8M9xii6g3+CfYCS0b78gUJyCpZET/LtZ1qmxN -YEAZSUNUY9rizLpm5U9EelvZaoErQNV/+QEnWCzI7UiRfD+mAM/EKXMRNt6GGT6d7hmKG9Ww7Y49 -nCrADdg9ZuM8Db3VlFzi4qc1GwQA9j9ajepDvV+JHanBsMyZ4k0ACtrJJ1vnE5Bc5PUzolVt3OAJ -TS+xJlsndQAJxGJ3KQhfnlmstn6tn1QwIgPBHnFk/vk4CpYY3QIUrCPLBhwepH2NDd4nQeit2hW3 -sCPdK6jT2iWH7ehVRE2I9DZ+hJp4rPcOVkkO1jMl1oRQQmwgEh0q1b688nCBpHBgvgW1m54ERL5h -I6zppSSMEYCUWqKiuUnSwdzRp+0xESyeGabu4VXhwOrPDYTkF7eifKXeVSUG7szAh1xA2syVP1Xg -Nce4hL60Xc16gwFy7ofmXx2utYXGJt/mwZrpHgJHnyqobalbz+xFd3+YJ5oyXSrjhO7FmGYvliAd -3djDJ9ew+f7Zfc3Qn48LFFhRny+Lwzgt3uiP1o2HpPVWQxaZLPSkVrQ0uGE3ycJYgBugl6H8WY3p -EfbRD0tVNEYqi4Y7 ------END CERTIFICATE----- - -TWCA Global Root CA -=================== ------BEGIN CERTIFICATE----- -MIIFQTCCAymgAwIBAgICDL4wDQYJKoZIhvcNAQELBQAwUTELMAkGA1UEBhMCVFcxEjAQBgNVBAoT -CVRBSVdBTi1DQTEQMA4GA1UECxMHUm9vdCBDQTEcMBoGA1UEAxMTVFdDQSBHbG9iYWwgUm9vdCBD -QTAeFw0xMjA2MjcwNjI4MzNaFw0zMDEyMzExNTU5NTlaMFExCzAJBgNVBAYTAlRXMRIwEAYDVQQK -EwlUQUlXQU4tQ0ExEDAOBgNVBAsTB1Jvb3QgQ0ExHDAaBgNVBAMTE1RXQ0EgR2xvYmFsIFJvb3Qg -Q0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCwBdvI64zEbooh745NnHEKH1Jw7W2C -nJfF10xORUnLQEK1EjRsGcJ0pDFfhQKX7EMzClPSnIyOt7h52yvVavKOZsTuKwEHktSz0ALfUPZV -r2YOy+BHYC8rMjk1Ujoog/h7FsYYuGLWRyWRzvAZEk2tY/XTP3VfKfChMBwqoJimFb3u/Rk28OKR -Q4/6ytYQJ0lM793B8YVwm8rqqFpD/G2Gb3PpN0Wp8DbHzIh1HrtsBv+baz4X7GGqcXzGHaL3SekV -tTzWoWH1EfcFbx39Eb7QMAfCKbAJTibc46KokWofwpFFiFzlmLhxpRUZyXx1EcxwdE8tmx2RRP1W -KKD+u4ZqyPpcC1jcxkt2yKsi2XMPpfRaAok/T54igu6idFMqPVMnaR1sjjIsZAAmY2E2TqNGtz99 -sy2sbZCilaLOz9qC5wc0GZbpuCGqKX6mOL6OKUohZnkfs8O1CWfe1tQHRvMq2uYiN2DLgbYPoA/p -yJV/v1WRBXrPPRXAb94JlAGD1zQbzECl8LibZ9WYkTunhHiVJqRaCPgrdLQABDzfuBSO6N+pjWxn -kjMdwLfS7JLIvgm/LCkFbwJrnu+8vyq8W8BQj0FwcYeyTbcEqYSjMq+u7msXi7Kx/mzhkIyIqJdI -zshNy/MGz19qCkKxHh53L46g5pIOBvwFItIm4TFRfTLcDwIDAQABoyMwITAOBgNVHQ8BAf8EBAMC -AQYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAXzSBdu+WHdXltdkCY4QWwa6g -cFGn90xHNcgL1yg9iXHZqjNB6hQbbCEAwGxCGX6faVsgQt+i0trEfJdLjbDorMjupWkEmQqSpqsn -LhpNgb+E1HAerUf+/UqdM+DyucRFCCEK2mlpc3INvjT+lIutwx4116KD7+U4x6WFH6vPNOw/KP4M -8VeGTslV9xzU2KV9Bnpv1d8Q34FOIWWxtuEXeZVFBs5fzNxGiWNoRI2T9GRwoD2dKAXDOXC4Ynsg -/eTb6QihuJ49CcdP+yz4k3ZB3lLg4VfSnQO8d57+nile98FRYB/e2guyLXW3Q0iT5/Z5xoRdgFlg -lPx4mI88k1HtQJAH32RjJMtOcQWh15QaiDLxInQirqWm2BJpTGCjAu4r7NRjkgtevi92a6O2JryP -A9gK8kxkRr05YuWW6zRjESjMlfGt7+/cgFhI6Uu46mWs6fyAtbXIRfmswZ/ZuepiiI7E8UuDEq3m -i4TWnsLrgxifarsbJGAzcMzs9zLzXNl5fe+epP7JI8Mk7hWSsT2RTyaGvWZzJBPqpK5jwa19hAM8 -EHiGG3njxPPyBJUgriOCxLM6AGK/5jYk4Ve6xx6QddVfP5VhK8E7zeWzaGHQRiapIVJpLesux+t3 -zqY6tQMzT3bR51xUAV3LePTJDL/PEo4XLSNolOer/qmyKwbQBM0= ------END CERTIFICATE----- - -TeliaSonera Root CA v1 -====================== ------BEGIN CERTIFICATE----- -MIIFODCCAyCgAwIBAgIRAJW+FqD3LkbxezmCcvqLzZYwDQYJKoZIhvcNAQEFBQAwNzEUMBIGA1UE -CgwLVGVsaWFTb25lcmExHzAdBgNVBAMMFlRlbGlhU29uZXJhIFJvb3QgQ0EgdjEwHhcNMDcxMDE4 -MTIwMDUwWhcNMzIxMDE4MTIwMDUwWjA3MRQwEgYDVQQKDAtUZWxpYVNvbmVyYTEfMB0GA1UEAwwW -VGVsaWFTb25lcmEgUm9vdCBDQSB2MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMK+ -6yfwIaPzaSZVfp3FVRaRXP3vIb9TgHot0pGMYzHw7CTww6XScnwQbfQ3t+XmfHnqjLWCi65ItqwA -3GV17CpNX8GH9SBlK4GoRz6JI5UwFpB/6FcHSOcZrr9FZ7E3GwYq/t75rH2D+1665I+XZ75Ljo1k -B1c4VWk0Nj0TSO9P4tNmHqTPGrdeNjPUtAa9GAH9d4RQAEX1jF3oI7x+/jXh7VB7qTCNGdMJjmhn -Xb88lxhTuylixcpecsHHltTbLaC0H2kD7OriUPEMPPCs81Mt8Bz17Ww5OXOAFshSsCPN4D7c3TxH -oLs1iuKYaIu+5b9y7tL6pe0S7fyYGKkmdtwoSxAgHNN/Fnct7W+A90m7UwW7XWjH1Mh1Fj+JWov3 -F0fUTPHSiXk+TT2YqGHeOh7S+F4D4MHJHIzTjU3TlTazN19jY5szFPAtJmtTfImMMsJu7D0hADnJ -oWjiUIMusDor8zagrC/kb2HCUQk5PotTubtn2txTuXZZNp1D5SDgPTJghSJRt8czu90VL6R4pgd7 -gUY2BIbdeTXHlSw7sKMXNeVzH7RcWe/a6hBle3rQf5+ztCo3O3CLm1u5K7fsslESl1MpWtTwEhDc -TwK7EpIvYtQ/aUN8Ddb8WHUBiJ1YFkveupD/RwGJBmr2X7KQarMCpgKIv7NHfirZ1fpoeDVNAgMB -AAGjPzA9MA8GA1UdEwEB/wQFMAMBAf8wCwYDVR0PBAQDAgEGMB0GA1UdDgQWBBTwj1k4ALP1j5qW -DNXr+nuqF+gTEjANBgkqhkiG9w0BAQUFAAOCAgEAvuRcYk4k9AwI//DTDGjkk0kiP0Qnb7tt3oNm -zqjMDfz1mgbldxSR651Be5kqhOX//CHBXfDkH1e3damhXwIm/9fH907eT/j3HEbAek9ALCI18Bmx -0GtnLLCo4MBANzX2hFxc469CeP6nyQ1Q6g2EdvZR74NTxnr/DlZJLo961gzmJ1TjTQpgcmLNkQfW -pb/ImWvtxBnmq0wROMVvMeJuScg/doAmAyYp4Db29iBT4xdwNBedY2gea+zDTYa4EzAvXUYNR0PV -G6pZDrlcjQZIrXSHX8f8MVRBE+LHIQ6e4B4N4cB7Q4WQxYpYxmUKeFfyxiMPAdkgS94P+5KFdSpc -c41teyWRyu5FrgZLAMzTsVlQ2jqIOylDRl6XK1TOU2+NSueW+r9xDkKLfP0ooNBIytrEgUy7onOT -JsjrDNYmiLbAJM+7vVvrdX3pCI6GMyx5dwlppYn8s3CQh3aP0yK7Qs69cwsgJirQmz1wHiRszYd2 -qReWt88NkvuOGKmYSdGe/mBEciG5Ge3C9THxOUiIkCR1VBatzvT4aRRkOfujuLpwQMcnHL/EVlP6 -Y2XQ8xwOFvVrhlhNGNTkDY6lnVuR3HYkUD/GKvvZt5y11ubQ2egZixVxSK236thZiNSQvxaz2ems -WWFUyBy6ysHK4bkgTI86k4mloMy/0/Z1pHWWbVY= ------END CERTIFICATE----- - -E-Tugra Certification Authority -=============================== ------BEGIN CERTIFICATE----- -MIIGSzCCBDOgAwIBAgIIamg+nFGby1MwDQYJKoZIhvcNAQELBQAwgbIxCzAJBgNVBAYTAlRSMQ8w -DQYDVQQHDAZBbmthcmExQDA+BgNVBAoMN0UtVHXEn3JhIEVCRyBCaWxpxZ9pbSBUZWtub2xvamls -ZXJpIHZlIEhpem1ldGxlcmkgQS7Fni4xJjAkBgNVBAsMHUUtVHVncmEgU2VydGlmaWthc3lvbiBN -ZXJrZXppMSgwJgYDVQQDDB9FLVR1Z3JhIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MB4XDTEzMDMw -NTEyMDk0OFoXDTIzMDMwMzEyMDk0OFowgbIxCzAJBgNVBAYTAlRSMQ8wDQYDVQQHDAZBbmthcmEx -QDA+BgNVBAoMN0UtVHXEn3JhIEVCRyBCaWxpxZ9pbSBUZWtub2xvamlsZXJpIHZlIEhpem1ldGxl -cmkgQS7Fni4xJjAkBgNVBAsMHUUtVHVncmEgU2VydGlmaWthc3lvbiBNZXJrZXppMSgwJgYDVQQD -DB9FLVR1Z3JhIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MIICIjANBgkqhkiG9w0BAQEFAAOCAg8A -MIICCgKCAgEA4vU/kwVRHoViVF56C/UYB4Oufq9899SKa6VjQzm5S/fDxmSJPZQuVIBSOTkHS0vd -hQd2h8y/L5VMzH2nPbxHD5hw+IyFHnSOkm0bQNGZDbt1bsipa5rAhDGvykPL6ys06I+XawGb1Q5K -CKpbknSFQ9OArqGIW66z6l7LFpp3RMih9lRozt6Plyu6W0ACDGQXwLWTzeHxE2bODHnv0ZEoq1+g -ElIwcxmOj+GMB6LDu0rw6h8VqO4lzKRG+Bsi77MOQ7osJLjFLFzUHPhdZL3Dk14opz8n8Y4e0ypQ -BaNV2cvnOVPAmJ6MVGKLJrD3fY185MaeZkJVgkfnsliNZvcHfC425lAcP9tDJMW/hkd5s3kc91r0 -E+xs+D/iWR+V7kI+ua2oMoVJl0b+SzGPWsutdEcf6ZG33ygEIqDUD13ieU/qbIWGvaimzuT6w+Gz -rt48Ue7LE3wBf4QOXVGUnhMMti6lTPk5cDZvlsouDERVxcr6XQKj39ZkjFqzAQqptQpHF//vkUAq -jqFGOjGY5RH8zLtJVor8udBhmm9lbObDyz51Sf6Pp+KJxWfXnUYTTjF2OySznhFlhqt/7x3U+Lzn -rFpct1pHXFXOVbQicVtbC/DP3KBhZOqp12gKY6fgDT+gr9Oq0n7vUaDmUStVkhUXU8u3Zg5mTPj5 -dUyQ5xJwx0UCAwEAAaNjMGEwHQYDVR0OBBYEFC7j27JJ0JxUeVz6Jyr+zE7S6E5UMA8GA1UdEwEB -/wQFMAMBAf8wHwYDVR0jBBgwFoAULuPbsknQnFR5XPonKv7MTtLoTlQwDgYDVR0PAQH/BAQDAgEG -MA0GCSqGSIb3DQEBCwUAA4ICAQAFNzr0TbdF4kV1JI+2d1LoHNgQk2Xz8lkGpD4eKexd0dCrfOAK -kEh47U6YA5n+KGCRHTAduGN8qOY1tfrTYXbm1gdLymmasoR6d5NFFxWfJNCYExL/u6Au/U5Mh/jO -XKqYGwXgAEZKgoClM4so3O0409/lPun++1ndYYRP0lSWE2ETPo+Aab6TR7U1Q9Jauz1c77NCR807 -VRMGsAnb/WP2OogKmW9+4c4bU2pEZiNRCHu8W1Ki/QY3OEBhj0qWuJA3+GbHeJAAFS6LrVE1Uweo -a2iu+U48BybNCAVwzDk/dr2l02cmAYamU9JgO3xDf1WKvJUawSg5TB9D0pH0clmKuVb8P7Sd2nCc -dlqMQ1DujjByTd//SffGqWfZbawCEeI6FiWnWAjLb1NBnEg4R2gz0dfHj9R0IdTDBZB6/86WiLEV -KV0jq9BgoRJP3vQXzTLlyb/IQ639Lo7xr+L0mPoSHyDYwKcMhcWQ9DstliaxLL5Mq+ux0orJ23gT -Dx4JnW2PAJ8C2sH6H3p6CcRK5ogql5+Ji/03X186zjhZhkuvcQu02PJwT58yE+Owp1fl2tpDy4Q0 -8ijE6m30Ku/Ba3ba+367hTzSU8JNvnHhRdH9I2cNE3X7z2VnIp2usAnRCf8dNL/+I5c30jn6PQ0G -C7TbO6Orb1wdtn7os4I07QZcJA== ------END CERTIFICATE----- - -T-TeleSec GlobalRoot Class 2 -============================ ------BEGIN CERTIFICATE----- -MIIDwzCCAqugAwIBAgIBATANBgkqhkiG9w0BAQsFADCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoM -IlQtU3lzdGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBU -cnVzdCBDZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDIwHhcNMDgx -MDAxMTA0MDE0WhcNMzMxMDAxMjM1OTU5WjCBgjELMAkGA1UEBhMCREUxKzApBgNVBAoMIlQtU3lz -dGVtcyBFbnRlcnByaXNlIFNlcnZpY2VzIEdtYkgxHzAdBgNVBAsMFlQtU3lzdGVtcyBUcnVzdCBD -ZW50ZXIxJTAjBgNVBAMMHFQtVGVsZVNlYyBHbG9iYWxSb290IENsYXNzIDIwggEiMA0GCSqGSIb3 -DQEBAQUAA4IBDwAwggEKAoIBAQCqX9obX+hzkeXaXPSi5kfl82hVYAUdAqSzm1nzHoqvNK38DcLZ -SBnuaY/JIPwhqgcZ7bBcrGXHX+0CfHt8LRvWurmAwhiCFoT6ZrAIxlQjgeTNuUk/9k9uN0goOA/F -vudocP05l03Sx5iRUKrERLMjfTlH6VJi1hKTXrcxlkIF+3anHqP1wvzpesVsqXFP6st4vGCvx970 -2cu+fjOlbpSD8DT6IavqjnKgP6TeMFvvhk1qlVtDRKgQFRzlAVfFmPHmBiiRqiDFt1MmUUOyCxGV -WOHAD3bZwI18gfNycJ5v/hqO2V81xrJvNHy+SE/iWjnX2J14np+GPgNeGYtEotXHAgMBAAGjQjBA -MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBS/WSA2AHmgoCJrjNXy -YdK4LMuCSjANBgkqhkiG9w0BAQsFAAOCAQEAMQOiYQsfdOhyNsZt+U2e+iKo4YFWz827n+qrkRk4 -r6p8FU3ztqONpfSO9kSpp+ghla0+AGIWiPACuvxhI+YzmzB6azZie60EI4RYZeLbK4rnJVM3YlNf -vNoBYimipidx5joifsFvHZVwIEoHNN/q/xWA5brXethbdXwFeilHfkCoMRN3zUA7tFFHei4R40cR -3p1m0IvVVGb6g1XqfMIpiRvpb7PO4gWEyS8+eIVibslfwXhjdFjASBgMmTnrpMwatXlajRWc2BQN -9noHV8cigwUtPJslJj0Ys6lDfMjIq2SPDqO/nBudMNva0Bkuqjzx+zOAduTNrRlPBSeOE6Fuwg== ------END CERTIFICATE----- - -Atos TrustedRoot 2011 -===================== ------BEGIN CERTIFICATE----- -MIIDdzCCAl+gAwIBAgIIXDPLYixfszIwDQYJKoZIhvcNAQELBQAwPDEeMBwGA1UEAwwVQXRvcyBU -cnVzdGVkUm9vdCAyMDExMQ0wCwYDVQQKDARBdG9zMQswCQYDVQQGEwJERTAeFw0xMTA3MDcxNDU4 -MzBaFw0zMDEyMzEyMzU5NTlaMDwxHjAcBgNVBAMMFUF0b3MgVHJ1c3RlZFJvb3QgMjAxMTENMAsG -A1UECgwEQXRvczELMAkGA1UEBhMCREUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCV -hTuXbyo7LjvPpvMpNb7PGKw+qtn4TaA+Gke5vJrf8v7MPkfoepbCJI419KkM/IL9bcFyYie96mvr -54rMVD6QUM+A1JX76LWC1BTFtqlVJVfbsVD2sGBkWXppzwO3bw2+yj5vdHLqqjAqc2K+SZFhyBH+ -DgMq92og3AIVDV4VavzjgsG1xZ1kCWyjWZgHJ8cblithdHFsQ/H3NYkQ4J7sVaE3IqKHBAUsR320 -HLliKWYoyrfhk/WklAOZuXCFteZI6o1Q/NnezG8HDt0Lcp2AMBYHlT8oDv3FdU9T1nSatCQujgKR -z3bFmx5VdJx4IbHwLfELn8LVlhgf8FQieowHAgMBAAGjfTB7MB0GA1UdDgQWBBSnpQaxLKYJYO7R -l+lwrrw7GWzbITAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFKelBrEspglg7tGX6XCuvDsZ -bNshMBgGA1UdIAQRMA8wDQYLKwYBBAGwLQMEAQEwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB -CwUAA4IBAQAmdzTblEiGKkGdLD4GkGDEjKwLVLgfuXvTBznk+j57sj1O7Z8jvZfza1zv7v1Apt+h -k6EKhqzvINB5Ab149xnYJDE0BAGmuhWawyfc2E8PzBhj/5kPDpFrdRbhIfzYJsdHt6bPWHJxfrrh -TZVHO8mvbaG0weyJ9rQPOLXiZNwlz6bb65pcmaHFCN795trV1lpFDMS3wrUU77QR/w4VtfX128a9 -61qn8FYiqTxlVMYVqL2Gns2Dlmh6cYGJ4Qvh6hEbaAjMaZ7snkGeRDImeuKHCnE96+RapNLbxc3G -3mB/ufNPRJLvKrcYPqcZ2Qt9sTdBQrC6YB3y/gkRsPCHe6ed ------END CERTIFICATE----- - -QuoVadis Root CA 1 G3 -===================== ------BEGIN CERTIFICATE----- -MIIFYDCCA0igAwIBAgIUeFhfLq0sGUvjNwc1NBMotZbUZZMwDQYJKoZIhvcNAQELBQAwSDELMAkG -A1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAcBgNVBAMTFVF1b1ZhZGlzIFJv -b3QgQ0EgMSBHMzAeFw0xMjAxMTIxNzI3NDRaFw00MjAxMTIxNzI3NDRaMEgxCzAJBgNVBAYTAkJN -MRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDEg -RzMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCgvlAQjunybEC0BJyFuTHK3C3kEakE -PBtVwedYMB0ktMPvhd6MLOHBPd+C5k+tR4ds7FtJwUrVu4/sh6x/gpqG7D0DmVIB0jWerNrwU8lm -PNSsAgHaJNM7qAJGr6Qc4/hzWHa39g6QDbXwz8z6+cZM5cOGMAqNF34168Xfuw6cwI2H44g4hWf6 -Pser4BOcBRiYz5P1sZK0/CPTz9XEJ0ngnjybCKOLXSoh4Pw5qlPafX7PGglTvF0FBM+hSo+LdoIN -ofjSxxR3W5A2B4GbPgb6Ul5jxaYA/qXpUhtStZI5cgMJYr2wYBZupt0lwgNm3fME0UDiTouG9G/l -g6AnhF4EwfWQvTA9xO+oabw4m6SkltFi2mnAAZauy8RRNOoMqv8hjlmPSlzkYZqn0ukqeI1RPToV -7qJZjqlc3sX5kCLliEVx3ZGZbHqfPT2YfF72vhZooF6uCyP8Wg+qInYtyaEQHeTTRCOQiJ/GKubX -9ZqzWB4vMIkIG1SitZgj7Ah3HJVdYdHLiZxfokqRmu8hqkkWCKi9YSgxyXSthfbZxbGL0eUQMk1f -iyA6PEkfM4VZDdvLCXVDaXP7a3F98N/ETH3Goy7IlXnLc6KOTk0k+17kBL5yG6YnLUlamXrXXAkg -t3+UuU/xDRxeiEIbEbfnkduebPRq34wGmAOtzCjvpUfzUwIDAQABo0IwQDAPBgNVHRMBAf8EBTAD -AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUo5fW816iEOGrRZ88F2Q87gFwnMwwDQYJKoZI -hvcNAQELBQADggIBABj6W3X8PnrHX3fHyt/PX8MSxEBd1DKquGrX1RUVRpgjpeaQWxiZTOOtQqOC -MTaIzen7xASWSIsBx40Bz1szBpZGZnQdT+3Btrm0DWHMY37XLneMlhwqI2hrhVd2cDMT/uFPpiN3 -GPoajOi9ZcnPP/TJF9zrx7zABC4tRi9pZsMbj/7sPtPKlL92CiUNqXsCHKnQO18LwIE6PWThv6ct -Tr1NxNgpxiIY0MWscgKCP6o6ojoilzHdCGPDdRS5YCgtW2jgFqlmgiNR9etT2DGbe+m3nUvriBbP -+V04ikkwj+3x6xn0dxoxGE1nVGwvb2X52z3sIexe9PSLymBlVNFxZPT5pqOBMzYzcfCkeF9OrYMh -3jRJjehZrJ3ydlo28hP0r+AJx2EqbPfgna67hkooby7utHnNkDPDs3b69fBsnQGQ+p6Q9pxyz0fa -wx/kNSBT8lTR32GDpgLiJTjehTItXnOQUl1CxM49S+H5GYQd1aJQzEH7QRTDvdbJWqNjZgKAvQU6 -O0ec7AAmTPWIUb+oI38YB7AL7YsmoWTTYUrrXJ/es69nA7Mf3W1daWhpq1467HxpvMc7hU6eFbm0 -FU/DlXpY18ls6Wy58yljXrQs8C097Vpl4KlbQMJImYFtnh8GKjwStIsPm6Ik8KaN1nrgS7ZklmOV -hMJKzRwuJIczYOXD ------END CERTIFICATE----- - -QuoVadis Root CA 2 G3 -===================== ------BEGIN CERTIFICATE----- -MIIFYDCCA0igAwIBAgIURFc0JFuBiZs18s64KztbpybwdSgwDQYJKoZIhvcNAQELBQAwSDELMAkG -A1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAcBgNVBAMTFVF1b1ZhZGlzIFJv -b3QgQ0EgMiBHMzAeFw0xMjAxMTIxODU5MzJaFw00MjAxMTIxODU5MzJaMEgxCzAJBgNVBAYTAkJN -MRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDIg -RzMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQChriWyARjcV4g/Ruv5r+LrI3HimtFh -ZiFfqq8nUeVuGxbULX1QsFN3vXg6YOJkApt8hpvWGo6t/x8Vf9WVHhLL5hSEBMHfNrMWn4rjyduY -NM7YMxcoRvynyfDStNVNCXJJ+fKH46nafaF9a7I6JaltUkSs+L5u+9ymc5GQYaYDFCDy54ejiK2t -oIz/pgslUiXnFgHVy7g1gQyjO/Dh4fxaXc6AcW34Sas+O7q414AB+6XrW7PFXmAqMaCvN+ggOp+o -MiwMzAkd056OXbxMmO7FGmh77FOm6RQ1o9/NgJ8MSPsc9PG/Srj61YxxSscfrf5BmrODXfKEVu+l -V0POKa2Mq1W/xPtbAd0jIaFYAI7D0GoT7RPjEiuA3GfmlbLNHiJuKvhB1PLKFAeNilUSxmn1uIZo -L1NesNKqIcGY5jDjZ1XHm26sGahVpkUG0CM62+tlXSoREfA7T8pt9DTEceT/AFr2XK4jYIVz8eQQ -sSWu1ZK7E8EM4DnatDlXtas1qnIhO4M15zHfeiFuuDIIfR0ykRVKYnLP43ehvNURG3YBZwjgQQvD -6xVu+KQZ2aKrr+InUlYrAoosFCT5v0ICvybIxo/gbjh9Uy3l7ZizlWNof/k19N+IxWA1ksB8aRxh -lRbQ694Lrz4EEEVlWFA4r0jyWbYW8jwNkALGcC4BrTwV1wIDAQABo0IwQDAPBgNVHRMBAf8EBTAD -AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQU7edvdlq/YOxJW8ald7tyFnGbxD0wDQYJKoZI -hvcNAQELBQADggIBAJHfgD9DCX5xwvfrs4iP4VGyvD11+ShdyLyZm3tdquXK4Qr36LLTn91nMX66 -AarHakE7kNQIXLJgapDwyM4DYvmL7ftuKtwGTTwpD4kWilhMSA/ohGHqPHKmd+RCroijQ1h5fq7K -pVMNqT1wvSAZYaRsOPxDMuHBR//47PERIjKWnML2W2mWeyAMQ0GaW/ZZGYjeVYg3UQt4XAoeo0L9 -x52ID8DyeAIkVJOviYeIyUqAHerQbj5hLja7NQ4nlv1mNDthcnPxFlxHBlRJAHpYErAK74X9sbgz -dWqTHBLmYF5vHX/JHyPLhGGfHoJE+V+tYlUkmlKY7VHnoX6XOuYvHxHaU4AshZ6rNRDbIl9qxV6X -U/IyAgkwo1jwDQHVcsaxfGl7w/U2Rcxhbl5MlMVerugOXou/983g7aEOGzPuVBj+D77vfoRrQ+Nw -mNtddbINWQeFFSM51vHfqSYP1kjHs6Yi9TM3WpVHn3u6GBVv/9YUZINJ0gpnIdsPNWNgKCLjsZWD -zYWm3S8P52dSbrsvhXz1SnPnxT7AvSESBT/8twNJAlvIJebiVDj1eYeMHVOyToV7BjjHLPj4sHKN -JeV3UvQDHEimUF+IIDBu8oJDqz2XhOdT+yHBTw8imoa4WSr2Rz0ZiC3oheGe7IUIarFsNMkd7Egr -O3jtZsSOeWmD3n+M ------END CERTIFICATE----- - -QuoVadis Root CA 3 G3 -===================== ------BEGIN CERTIFICATE----- -MIIFYDCCA0igAwIBAgIULvWbAiin23r/1aOp7r0DoM8Sah0wDQYJKoZIhvcNAQELBQAwSDELMAkG -A1UEBhMCQk0xGTAXBgNVBAoTEFF1b1ZhZGlzIExpbWl0ZWQxHjAcBgNVBAMTFVF1b1ZhZGlzIFJv -b3QgQ0EgMyBHMzAeFw0xMjAxMTIyMDI2MzJaFw00MjAxMTIyMDI2MzJaMEgxCzAJBgNVBAYTAkJN -MRkwFwYDVQQKExBRdW9WYWRpcyBMaW1pdGVkMR4wHAYDVQQDExVRdW9WYWRpcyBSb290IENBIDMg -RzMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCzyw4QZ47qFJenMioKVjZ/aEzHs286 -IxSR/xl/pcqs7rN2nXrpixurazHb+gtTTK/FpRp5PIpM/6zfJd5O2YIyC0TeytuMrKNuFoM7pmRL -Mon7FhY4futD4tN0SsJiCnMK3UmzV9KwCoWdcTzeo8vAMvMBOSBDGzXRU7Ox7sWTaYI+FrUoRqHe -6okJ7UO4BUaKhvVZR74bbwEhELn9qdIoyhA5CcoTNs+cra1AdHkrAj80//ogaX3T7mH1urPnMNA3 -I4ZyYUUpSFlob3emLoG+B01vr87ERRORFHAGjx+f+IdpsQ7vw4kZ6+ocYfx6bIrc1gMLnia6Et3U -VDmrJqMz6nWB2i3ND0/kA9HvFZcba5DFApCTZgIhsUfei5pKgLlVj7WiL8DWM2fafsSntARE60f7 -5li59wzweyuxwHApw0BiLTtIadwjPEjrewl5qW3aqDCYz4ByA4imW0aucnl8CAMhZa634RylsSqi -Md5mBPfAdOhx3v89WcyWJhKLhZVXGqtrdQtEPREoPHtht+KPZ0/l7DxMYIBpVzgeAVuNVejH38DM -dyM0SXV89pgR6y3e7UEuFAUCf+D+IOs15xGsIs5XPd7JMG0QA4XN8f+MFrXBsj6IbGB/kE+V9/Yt -rQE5BwT6dYB9v0lQ7e/JxHwc64B+27bQ3RP+ydOc17KXqQIDAQABo0IwQDAPBgNVHRMBAf8EBTAD -AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUxhfQvKjqAkPyGwaZXSuQILnXnOQwDQYJKoZI -hvcNAQELBQADggIBADRh2Va1EodVTd2jNTFGu6QHcrxfYWLopfsLN7E8trP6KZ1/AvWkyaiTt3px -KGmPc+FSkNrVvjrlt3ZqVoAh313m6Tqe5T72omnHKgqwGEfcIHB9UqM+WXzBusnIFUBhynLWcKzS -t/Ac5IYp8M7vaGPQtSCKFWGafoaYtMnCdvvMujAWzKNhxnQT5WvvoxXqA/4Ti2Tk08HS6IT7SdEQ -TXlm66r99I0xHnAUrdzeZxNMgRVhvLfZkXdxGYFgu/BYpbWcC/ePIlUnwEsBbTuZDdQdm2NnL9Du -DcpmvJRPpq3t/O5jrFc/ZSXPsoaP0Aj/uHYUbt7lJ+yreLVTubY/6CD50qi+YUbKh4yE8/nxoGib -Ih6BJpsQBJFxwAYf3KDTuVan45gtf4Od34wrnDKOMpTwATwiKp9Dwi7DmDkHOHv8XgBCH/MyJnmD -hPbl8MFREsALHgQjDFSlTC9JxUrRtm5gDWv8a4uFJGS3iQ6rJUdbPM9+Sb3H6QrG2vd+DhcI00iX -0HGS8A85PjRqHH3Y8iKuu2n0M7SmSFXRDw4m6Oy2Cy2nhTXN/VnIn9HNPlopNLk9hM6xZdRZkZFW -dSHBd575euFgndOtBBj0fOtek49TSiIp+EgrPk2GrFt/ywaZWWDYWGWVjUTR939+J399roD1B0y2 -PpxxVJkES/1Y+Zj0 ------END CERTIFICATE----- - -DigiCert Assured ID Root G2 -=========================== ------BEGIN CERTIFICATE----- -MIIDljCCAn6gAwIBAgIQC5McOtY5Z+pnI7/Dr5r0SzANBgkqhkiG9w0BAQsFADBlMQswCQYDVQQG -EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQw -IgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzIwHhcNMTMwODAxMTIwMDAwWhcNMzgw -MTE1MTIwMDAwWjBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQL -ExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzIw -ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDZ5ygvUj82ckmIkzTz+GoeMVSAn61UQbVH -35ao1K+ALbkKz3X9iaV9JPrjIgwrvJUXCzO/GU1BBpAAvQxNEP4HteccbiJVMWWXvdMX0h5i89vq -bFCMP4QMls+3ywPgym2hFEwbid3tALBSfK+RbLE4E9HpEgjAALAcKxHad3A2m67OeYfcgnDmCXRw -VWmvo2ifv922ebPynXApVfSr/5Vh88lAbx3RvpO704gqu52/clpWcTs/1PPRCv4o76Pu2ZmvA9OP -YLfykqGxvYmJHzDNw6YuYjOuFgJ3RFrngQo8p0Quebg/BLxcoIfhG69Rjs3sLPr4/m3wOnyqi+Rn -lTGNAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTO -w0q5mVXyuNtgv6l+vVa1lzan1jANBgkqhkiG9w0BAQsFAAOCAQEAyqVVjOPIQW5pJ6d1Ee88hjZv -0p3GeDgdaZaikmkuOGybfQTUiaWxMTeKySHMq2zNixya1r9I0jJmwYrA8y8678Dj1JGG0VDjA9tz -d29KOVPt3ibHtX2vK0LRdWLjSisCx1BL4GnilmwORGYQRI+tBev4eaymG+g3NJ1TyWGqolKvSnAW -hsI6yLETcDbYz+70CjTVW0z9B5yiutkBclzzTcHdDrEcDcRjvq30FPuJ7KJBDkzMyFdA0G4Dqs0M -jomZmWzwPDCvON9vvKO+KSAnq3T/EyJ43pdSVR6DtVQgA+6uwE9W3jfMw3+qBCe703e4YtsXfJwo -IhNzbM8m9Yop5w== ------END CERTIFICATE----- - -DigiCert Assured ID Root G3 -=========================== ------BEGIN CERTIFICATE----- -MIICRjCCAc2gAwIBAgIQC6Fa+h3foLVJRK/NJKBs7DAKBggqhkjOPQQDAzBlMQswCQYDVQQGEwJV -UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYD -VQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzMwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1 -MTIwMDAwWjBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 -d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgRzMwdjAQ -BgcqhkjOPQIBBgUrgQQAIgNiAAQZ57ysRGXtzbg/WPuNsVepRC0FFfLvC/8QdJ+1YlJfZn4f5dwb -RXkLzMZTCp2NXQLZqVneAlr2lSoOjThKiknGvMYDOAdfVdp+CW7if17QRSAPWXYQ1qAk8C3eNvJs -KTmjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTL0L2p4ZgF -UaFNN6KDec6NHSrkhDAKBggqhkjOPQQDAwNnADBkAjAlpIFFAmsSS3V0T8gj43DydXLefInwz5Fy -YZ5eEJJZVrmDxxDnOOlYJjZ91eQ0hjkCMHw2U/Aw5WJjOpnitqM7mzT6HtoQknFekROn3aRukswy -1vUhZscv6pZjamVFkpUBtA== ------END CERTIFICATE----- - -DigiCert Global Root G2 -======================= ------BEGIN CERTIFICATE----- -MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBhMQswCQYDVQQG -EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAw -HgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUx -MjAwMDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3 -dy5kaWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkq -hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI2/Ou8jqJ -kTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx1x7e/dfgy5SDN67sH0NO -3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQq2EGnI/yuum06ZIya7XzV+hdG82MHauV -BJVJ8zUtluNJbd134/tJS7SsVQepj5WztCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyM -UNGPHgm+F6HmIcr9g+UQvIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQAB -o0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV5uNu -5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY1Yl9PMWLSn/pvtsr -F9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4NeF22d+mQrvHRAiGfzZ0JFrabA0U -WTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NGFdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBH -QRFXGU7Aj64GxJUTFy8bJZ918rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/ -iyK5S9kJRaTepLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl -MrY= ------END CERTIFICATE----- - -DigiCert Global Root G3 -======================= ------BEGIN CERTIFICATE----- -MIICPzCCAcWgAwIBAgIQBVVWvPJepDU1w6QP1atFcjAKBggqhkjOPQQDAzBhMQswCQYDVQQGEwJV -UzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSAwHgYD -VQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBHMzAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAw -MDBaMGExCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5k -aWdpY2VydC5jb20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEczMHYwEAYHKoZIzj0C -AQYFK4EEACIDYgAE3afZu4q4C/sLfyHS8L6+c/MzXRq8NOrexpu80JX28MzQC7phW1FGfp4tn+6O -YwwX7Adw9c+ELkCDnOg/QW07rdOkFFk2eJ0DQ+4QE2xy3q6Ip6FrtUPOZ9wj/wMco+I+o0IwQDAP -BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUs9tIpPmhxdiuNkHMEWNp -Yim8S8YwCgYIKoZIzj0EAwMDaAAwZQIxAK288mw/EkrRLTnDCgmXc/SINoyIJ7vmiI1Qhadj+Z4y -3maTD/HMsQmP3Wyr+mt/oAIwOWZbwmSNuJ5Q3KjVSaLtx9zRSX8XAbjIho9OjIgrqJqpisXRAL34 -VOKa5Vt8sycX ------END CERTIFICATE----- - -DigiCert Trusted Root G4 -======================== ------BEGIN CERTIFICATE----- -MIIFkDCCA3igAwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBiMQswCQYDVQQG -EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEw -HwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMTMwODAxMTIwMDAwWhcNMzgwMTE1 -MTIwMDAwWjBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 -d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0G -CSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3yithZwuEp -pz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1Ifxp4VpX6+n6lXFllVcq9o -k3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDVySAdYyktzuxeTsiT+CFhmzTrBcZe7Fsa -vOvJz82sNEBfsXpm7nfISKhmV1efVFiODCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGY -QJB5w3jHtrHEtWoYOAMQjdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6 -MUSaM0C/CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCiEhtm -mnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADMfRyVw4/3IbKyEbe7 -f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QYuKZ3AeEPlAwhHbJUKSWJbOUOUlFH -dL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXKchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8 -oR7FwI+isX4KJpn15GkvmB0t9dmpsh3lGwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1Ud -DwEB/wQEAwIBhjAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQAD -ggIBALth2X2pbL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2SV1EY+CtnJYY -ZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd+SeuMIW59mdNOj6PWTkiU0Tr -yF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWcfFqK1qI4mfN4i/RN0iAL3gTujJtHgXINwBQy -7zBZLq7gcfJW5GqXb5JQbZaNaHqasjYUegbyJLkJEVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iah -ixTXTBmyUEFxPT9NcCOGDErcgdLMMpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN -5r5N0XWs0Mr7QbhDparTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie4u1Ki7wb -/UdKDd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mIr/OSmbaz5mEP0oUA51Aa -5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1/YldvIViHTLSoCtU7ZpXwdv6EM8Zt4tK -G48BtieVU+i2iW1bvGjUI+iLUaJW+fCmgKDWHrO8Dw9TdSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP -82Z+ ------END CERTIFICATE----- - -WoSign -====== ------BEGIN CERTIFICATE----- -MIIFdjCCA16gAwIBAgIQXmjWEXGUY1BWAGjzPsnFkTANBgkqhkiG9w0BAQUFADBVMQswCQYDVQQG -EwJDTjEaMBgGA1UEChMRV29TaWduIENBIExpbWl0ZWQxKjAoBgNVBAMTIUNlcnRpZmljYXRpb24g -QXV0aG9yaXR5IG9mIFdvU2lnbjAeFw0wOTA4MDgwMTAwMDFaFw0zOTA4MDgwMTAwMDFaMFUxCzAJ -BgNVBAYTAkNOMRowGAYDVQQKExFXb1NpZ24gQ0EgTGltaXRlZDEqMCgGA1UEAxMhQ2VydGlmaWNh -dGlvbiBBdXRob3JpdHkgb2YgV29TaWduMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA -vcqNrLiRFVaXe2tcesLea9mhsMMQI/qnobLMMfo+2aYpbxY94Gv4uEBf2zmoAHqLoE1UfcIiePyO -CbiohdfMlZdLdNiefvAA5A6JrkkoRBoQmTIPJYhTpA2zDxIIFgsDcSccf+Hb0v1naMQFXQoOXXDX -2JegvFNBmpGN9J42Znp+VsGQX+axaCA2pIwkLCxHC1l2ZjC1vt7tj/id07sBMOby8w7gLJKA84X5 -KIq0VC6a7fd2/BVoFutKbOsuEo/Uz/4Mx1wdC34FMr5esAkqQtXJTpCzWQ27en7N1QhatH/YHGkR -+ScPewavVIMYe+HdVHpRaG53/Ma/UkpmRqGyZxq7o093oL5d//xWC0Nyd5DKnvnyOfUNqfTq1+ez -EC8wQjchzDBwyYaYD8xYTYO7feUapTeNtqwylwA6Y3EkHp43xP901DfA4v6IRmAR3Qg/UDaruHqk -lWJqbrDKaiFaafPz+x1wOZXzp26mgYmhiMU7ccqjUu6Du/2gd/Tkb+dC221KmYo0SLwX3OSACCK2 -8jHAPwQ+658geda4BmRkAjHXqc1S+4RFaQkAKtxVi8QGRkvASh0JWzko/amrzgD5LkhLJuYwTKVY -yrREgk/nkR4zw7CT/xH8gdLKH3Ep3XZPkiWvHYG3Dy+MwwbMLyejSuQOmbp8HkUff6oZRZb9/D0C -AwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFOFmzw7R -8bNLtwYgFP6HEtX2/vs+MA0GCSqGSIb3DQEBBQUAA4ICAQCoy3JAsnbBfnv8rWTjMnvMPLZdRtP1 -LOJwXcgu2AZ9mNELIaCJWSQBnfmvCX0KI4I01fx8cpm5o9dU9OpScA7F9dY74ToJMuYhOZO9sxXq -T2r09Ys/L3yNWC7F4TmgPsc9SnOeQHrAK2GpZ8nzJLmzbVUsWh2eJXLOC62qx1ViC777Y7NhRCOj -y+EaDveaBk3e1CNOIZZbOVtXHS9dCF4Jef98l7VNg64N1uajeeAz0JmWAjCnPv/So0M/BVoG6kQC -2nz4SNAzqfkHx5Xh9T71XXG68pWpdIhhWeO/yloTunK0jF02h+mmxTwTv97QRCbut+wucPrXnbes -5cVAWubXbHssw1abR80LzvobtCHXt2a49CUwi1wNuepnsvRtrtWhnk/Yn+knArAdBtaP4/tIEp9/ -EaEQPkxROpaw0RPxx9gmrjrKkcRpnd8BKWRRb2jaFOwIQZeQjdCygPLPwj2/kWjFgGcexGATVdVh -mVd8upUPYUk6ynW8yQqTP2cOEvIo4jEbwFcW3wh8GcF+Dx+FHgo2fFt+J7x6v+Db9NpSvd4MVHAx -kUOVyLzwPt0JfjBkUO1/AaQzZ01oT74V77D2AhGiGxMlOtzCWfHjXEa7ZywCRuoeSKbmW9m1vFGi -kpbbqsY3Iqb+zCB0oy2pLmvLwIIRIbWTee5Ehr7XHuQe+w== ------END CERTIFICATE----- - -WoSign China -============ ------BEGIN CERTIFICATE----- -MIIFWDCCA0CgAwIBAgIQUHBrzdgT/BtOOzNy0hFIjTANBgkqhkiG9w0BAQsFADBGMQswCQYDVQQG -EwJDTjEaMBgGA1UEChMRV29TaWduIENBIExpbWl0ZWQxGzAZBgNVBAMMEkNBIOayg+mAmuagueiv -geS5pjAeFw0wOTA4MDgwMTAwMDFaFw0zOTA4MDgwMTAwMDFaMEYxCzAJBgNVBAYTAkNOMRowGAYD -VQQKExFXb1NpZ24gQ0EgTGltaXRlZDEbMBkGA1UEAwwSQ0Eg5rKD6YCa5qC56K+B5LmmMIICIjAN -BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0EkhHiX8h8EqwqzbdoYGTufQdDTc7WU1/FDWiD+k -8H/rD195L4mx/bxjWDeTmzj4t1up+thxx7S8gJeNbEvxUNUqKaqoGXqW5pWOdO2XCld19AXbbQs5 -uQF/qvbW2mzmBeCkTVL829B0txGMe41P/4eDrv8FAxNXUDf+jJZSEExfv5RxadmWPgxDT74wwJ85 -dE8GRV2j1lY5aAfMh09Qd5Nx2UQIsYo06Yms25tO4dnkUkWMLhQfkWsZHWgpLFbE4h4TV2TwYeO5 -Ed+w4VegG63XX9Gv2ystP9Bojg/qnw+LNVgbExz03jWhCl3W6t8Sb8D7aQdGctyB9gQjF+BNdeFy -b7Ao65vh4YOhn0pdr8yb+gIgthhid5E7o9Vlrdx8kHccREGkSovrlXLp9glk3Kgtn3R46MGiCWOc -76DbT52VqyBPt7D3h1ymoOQ3OMdc4zUPLK2jgKLsLl3Az+2LBcLmc272idX10kaO6m1jGx6KyX2m -+Jzr5dVjhU1zZmkR/sgO9MHHZklTfuQZa/HpelmjbX7FF+Ynxu8b22/8DU0GAbQOXDBGVWCvOGU6 -yke6rCzMRh+yRpY/8+0mBe53oWprfi1tWFxK1I5nuPHa1UaKJ/kR8slC/k7e3x9cxKSGhxYzoacX -GKUN5AXlK8IrC6KVkLn9YDxOiT7nnO4fuwECAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1Ud -EwEB/wQFMAMBAf8wHQYDVR0OBBYEFOBNv9ybQV0T6GTwp+kVpOGBwboxMA0GCSqGSIb3DQEBCwUA -A4ICAQBqinA4WbbaixjIvirTthnVZil6Xc1bL3McJk6jfW+rtylNpumlEYOnOXOvEESS5iVdT2H6 -yAa+Tkvv/vMx/sZ8cApBWNromUuWyXi8mHwCKe0JgOYKOoICKuLJL8hWGSbueBwj/feTZU7n85iY -r83d2Z5AiDEoOqsuC7CsDCT6eiaY8xJhEPRdF/d+4niXVOKM6Cm6jBAyvd0zaziGfjk9DgNyp115 -j0WKWa5bIW4xRtVZjc8VX90xJc/bYNaBRHIpAlf2ltTW/+op2znFuCyKGo3Oy+dCMYYFaA6eFN0A -kLppRQjbbpCBhqcqBT/mhDn4t/lXX0ykeVoQDF7Va/81XwVRHmyjdanPUIPTfPRm94KNPQx96N97 -qA4bLJyuQHCH2u2nFoJavjVsIE4iYdm8UXrNemHcSxH5/mc0zy4EZmFcV5cjjPOGG0jfKq+nwf/Y -jj4Du9gqsPoUJbJRa4ZDhS4HIxaAjUz7tGM7zMN07RujHv41D198HRaG9Q7DlfEvr10lO1Hm13ZB -ONFLAzkopR6RctR9q5czxNM+4Gm2KHmgCY0c0f9BckgG/Jou5yD5m6Leie2uPAmvylezkolwQOQv -T8Jwg0DXJCxr5wkf09XHwQj02w47HAcLQxGEIYbpgNR12KvxAmLBsX5VYc8T1yaw15zLKYs4SgsO -kI26oQ== ------END CERTIFICATE----- - -COMODO RSA Certification Authority -================================== ------BEGIN CERTIFICATE----- -MIIF2DCCA8CgAwIBAgIQTKr5yttjb+Af907YWwOGnTANBgkqhkiG9w0BAQwFADCBhTELMAkGA1UE -BhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgG -A1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlv -biBBdXRob3JpdHkwHhcNMTAwMTE5MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMC -R0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UE -ChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlvbiBB -dXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCR6FSS0gpWsawNJN3Fz0Rn -dJkrN6N9I3AAcbxT38T6KhKPS38QVr2fcHK3YX/JSw8Xpz3jsARh7v8Rl8f0hj4K+j5c+ZPmNHrZ -FGvnnLOFoIJ6dq9xkNfs/Q36nGz637CC9BR++b7Epi9Pf5l/tfxnQ3K9DADWietrLNPtj5gcFKt+ -5eNu/Nio5JIk2kNrYrhV/erBvGy2i/MOjZrkm2xpmfh4SDBF1a3hDTxFYPwyllEnvGfDyi62a+pG -x8cgoLEfZd5ICLqkTqnyg0Y3hOvozIFIQ2dOciqbXL1MGyiKXCJ7tKuY2e7gUYPDCUZObT6Z+pUX -2nwzV0E8jVHtC7ZcryxjGt9XyD+86V3Em69FmeKjWiS0uqlWPc9vqv9JWL7wqP/0uK3pN/u6uPQL -OvnoQ0IeidiEyxPx2bvhiWC4jChWrBQdnArncevPDt09qZahSL0896+1DSJMwBGB7FY79tOi4lu3 -sgQiUpWAk2nojkxl8ZEDLXB0AuqLZxUpaVICu9ffUGpVRr+goyhhf3DQw6KqLCGqR84onAZFdr+C -GCe01a60y1Dma/RMhnEw6abfFobg2P9A3fvQQoh/ozM6LlweQRGBY84YcWsr7KaKtzFcOmpH4MN5 -WdYgGq/yapiqcrxXStJLnbsQ/LBMQeXtHT1eKJ2czL+zUdqnR+WEUwIDAQABo0IwQDAdBgNVHQ4E -FgQUu69+Aj36pvE8hI6t7jiY7NkyMtQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8w -DQYJKoZIhvcNAQEMBQADggIBAArx1UaEt65Ru2yyTUEUAJNMnMvlwFTPoCWOAvn9sKIN9SCYPBMt -rFaisNZ+EZLpLrqeLppysb0ZRGxhNaKatBYSaVqM4dc+pBroLwP0rmEdEBsqpIt6xf4FpuHA1sj+ -nq6PK7o9mfjYcwlYRm6mnPTXJ9OV2jeDchzTc+CiR5kDOF3VSXkAKRzH7JsgHAckaVd4sjn8OoSg -tZx8jb8uk2IntznaFxiuvTwJaP+EmzzV1gsD41eeFPfR60/IvYcjt7ZJQ3mFXLrrkguhxuhoqEwW -sRqZCuhTLJK7oQkYdQxlqHvLI7cawiiFwxv/0Cti76R7CZGYZ4wUAc1oBmpjIXUDgIiKboHGhfKp -pC3n9KUkEEeDys30jXlYsQab5xoq2Z0B15R97QNKyvDb6KkBPvVWmckejkk9u+UJueBPSZI9FoJA -zMxZxuY67RIuaTxslbH9qh17f4a+Hg4yRvv7E491f0yLS0Zj/gA0QHDBw7mh3aZw4gSzQbzpgJHq -ZJx64SIDqZxubw5lT2yHh17zbqD5daWbQOhTsiedSrnAdyGN/4fy3ryM7xfft0kL0fJuMAsaDk52 -7RH89elWsn2/x20Kk4yl0MC2Hb46TpSi125sC8KKfPog88Tk5c0NqMuRkrF8hey1FGlmDoLnzc7I -LaZRfyHBNVOFBkpdn627G190 ------END CERTIFICATE----- - -USERTrust RSA Certification Authority -===================================== ------BEGIN CERTIFICATE----- -MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCBiDELMAkGA1UE -BhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQK -ExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNh -dGlvbiBBdXRob3JpdHkwHhcNMTAwMjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UE -BhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQK -ExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNh -dGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCAEmUXNg7D2wiz -0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B3PHTsdZ7NygRK0faOca8Ohm0X6a9fZ2j -Y0K2dvKpOyuR+OJv0OwWIJAJPuLodMkYtJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFn -RghRy4YUVD+8M/5+bJz/Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O -+T23LLb2VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT79uq -/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6c0Plfg6lZrEpfDKE -Y1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmTYo61Zs8liM2EuLE/pDkP2QKe6xJM -lXzzawWpXhaDzLhn4ugTncxbgtNMs+1b/97lc6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8 -yexDJtC/QV9AqURE9JnnV4eeUB9XVKg+/XRjL7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+ -eLf8ZxXhyVeEHg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+CDwIDAQABo0IwQDAd -BgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF -MAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPFUp/L+M+ZBn8b2kMVn54CVVeW -FPFSPCeHlCjtHzoBN6J2/FNQwISbxmtOuowhT6KOVWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ -7l8wXEskEVX/JJpuXior7gtNn3/3ATiUFJVDBwn7YKnuHKsSjKCaXqeYalltiz8I+8jRRa8YFWSQ -Eg9zKC7F4iRO/Fjs8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM -8WcRiQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYzeSf7dNXGi -FSeUHM9h4ya7b6NnJSFd5t0dCy5oGzuCr+yDZ4XUmFF0sbmZgIn/f3gZXHlKYC6SQK5MNyosycdi -yA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/qS3fuQL39ZeatTXaw2ewh0qpKJ4jjv9c -J2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRBVXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGw -sAvgnEzDHNb842m1R0aBL6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gx -Q+6IHdfGjjxDah2nGN59PRbxYvnKkKj9 ------END CERTIFICATE----- - -USERTrust ECC Certification Authority -===================================== ------BEGIN CERTIFICATE----- -MIICjzCCAhWgAwIBAgIQXIuZxVqUxdJxVt7NiYDMJjAKBggqhkjOPQQDAzCBiDELMAkGA1UEBhMC -VVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU -aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlv -biBBdXRob3JpdHkwHhcNMTAwMjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMC -VVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU -aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBFQ0MgQ2VydGlmaWNhdGlv -biBBdXRob3JpdHkwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQarFRaqfloI+d61SRvU8Za2EurxtW2 -0eZzca7dnNYMYf3boIkDuAUU7FfO7l0/4iGzzvfUinngo4N+LZfQYcTxmdwlkWOrfzCjtHDix6Ez -nPO/LlxTsV+zfTJ/ijTjeXmjQjBAMB0GA1UdDgQWBBQ64QmG1M8ZwpZ2dEl23OA1xmNjmjAOBgNV -HQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjA2Z6EWCNzklwBB -HU6+4WMBzzuqQhFkoJ2UOQIReVx7Hfpkue4WQrO/isIJxOzksU0CMQDpKmFHjFJKS04YcPbWRNZu -9YO6bVi9JNlWSOrvxKJGgYhqOkbRqZtNyWHa0V1Xahg= ------END CERTIFICATE----- - -GlobalSign ECC Root CA - R4 -=========================== ------BEGIN CERTIFICATE----- -MIIB4TCCAYegAwIBAgIRKjikHJYKBN5CsiilC+g0mAIwCgYIKoZIzj0EAwIwUDEkMCIGA1UECxMb -R2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI0MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQD -EwpHbG9iYWxTaWduMB4XDTEyMTExMzAwMDAwMFoXDTM4MDExOTAzMTQwN1owUDEkMCIGA1UECxMb -R2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI0MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQD -EwpHbG9iYWxTaWduMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEuMZ5049sJQ6fLjkZHAOkrprl -OQcJFspjsbmG+IpXwVfOQvpzofdlQv8ewQCybnMO/8ch5RikqtlxP6jUuc6MHaNCMEAwDgYDVR0P -AQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFFSwe61FuOJAf/sKbvu+M8k8o4TV -MAoGCCqGSM49BAMCA0gAMEUCIQDckqGgE6bPA7DmxCGXkPoUVy0D7O48027KqGx2vKLeuwIgJ6iF -JzWbVsaj8kfSt24bAgAXqmemFZHe+pTsewv4n4Q= ------END CERTIFICATE----- - -GlobalSign ECC Root CA - R5 -=========================== ------BEGIN CERTIFICATE----- -MIICHjCCAaSgAwIBAgIRYFlJ4CYuu1X5CneKcflK2GwwCgYIKoZIzj0EAwMwUDEkMCIGA1UECxMb -R2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI1MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQD -EwpHbG9iYWxTaWduMB4XDTEyMTExMzAwMDAwMFoXDTM4MDExOTAzMTQwN1owUDEkMCIGA1UECxMb -R2xvYmFsU2lnbiBFQ0MgUm9vdCBDQSAtIFI1MRMwEQYDVQQKEwpHbG9iYWxTaWduMRMwEQYDVQQD -EwpHbG9iYWxTaWduMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAER0UOlvt9Xb/pOdEh+J8LttV7HpI6 -SFkc8GIxLcB6KP4ap1yztsyX50XUWPrRd21DosCHZTQKH3rd6zwzocWdTaRvQZU4f8kehOvRnkmS -h5SHDDqFSmafnVmTTZdhBoZKo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAd -BgNVHQ4EFgQUPeYpSJvqB8ohREom3m7e0oPQn1kwCgYIKoZIzj0EAwMDaAAwZQIxAOVpEslu28Yx -uglB4Zf4+/2a4n0Sye18ZNPLBSWLVtmg515dTguDnFt2KaAJJiFqYgIwcdK1j1zqO+F4CYWodZI7 -yFz9SO8NdCKoCOJuxUnOxwy8p2Fp8fc74SrL+SvzZpA3 ------END CERTIFICATE----- - -Staat der Nederlanden Root CA - G3 -================================== ------BEGIN CERTIFICATE----- -MIIFdDCCA1ygAwIBAgIEAJiiOTANBgkqhkiG9w0BAQsFADBaMQswCQYDVQQGEwJOTDEeMBwGA1UE -CgwVU3RhYXQgZGVyIE5lZGVybGFuZGVuMSswKQYDVQQDDCJTdGFhdCBkZXIgTmVkZXJsYW5kZW4g -Um9vdCBDQSAtIEczMB4XDTEzMTExNDExMjg0MloXDTI4MTExMzIzMDAwMFowWjELMAkGA1UEBhMC -TkwxHjAcBgNVBAoMFVN0YWF0IGRlciBOZWRlcmxhbmRlbjErMCkGA1UEAwwiU3RhYXQgZGVyIE5l -ZGVybGFuZGVuIFJvb3QgQ0EgLSBHMzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAL4y -olQPcPssXFnrbMSkUeiFKrPMSjTysF/zDsccPVMeiAho2G89rcKezIJnByeHaHE6n3WWIkYFsO2t -x1ueKt6c/DrGlaf1F2cY5y9JCAxcz+bMNO14+1Cx3Gsy8KL+tjzk7FqXxz8ecAgwoNzFs21v0IJy -EavSgWhZghe3eJJg+szeP4TrjTgzkApyI/o1zCZxMdFyKJLZWyNtZrVtB0LrpjPOktvA9mxjeM3K -Tj215VKb8b475lRgsGYeCasH/lSJEULR9yS6YHgamPfJEf0WwTUaVHXvQ9Plrk7O53vDxk5hUUur -mkVLoR9BvUhTFXFkC4az5S6+zqQbwSmEorXLCCN2QyIkHxcE1G6cxvx/K2Ya7Irl1s9N9WMJtxU5 -1nus6+N86U78dULI7ViVDAZCopz35HCz33JvWjdAidiFpNfxC95DGdRKWCyMijmev4SH8RY7Ngzp -07TKbBlBUgmhHbBqv4LvcFEhMtwFdozL92TkA1CvjJFnq8Xy7ljY3r735zHPbMk7ccHViLVlvMDo -FxcHErVc0qsgk7TmgoNwNsXNo42ti+yjwUOH5kPiNL6VizXtBznaqB16nzaeErAMZRKQFWDZJkBE -41ZgpRDUajz9QdwOWke275dhdU/Z/seyHdTtXUmzqWrLZoQT1Vyg3N9udwbRcXXIV2+vD3dbAgMB -AAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBRUrfrHkleu -yjWcLhL75LpdINyUVzANBgkqhkiG9w0BAQsFAAOCAgEAMJmdBTLIXg47mAE6iqTnB/d6+Oea31BD -U5cqPco8R5gu4RV78ZLzYdqQJRZlwJ9UXQ4DO1t3ApyEtg2YXzTdO2PCwyiBwpwpLiniyMMB8jPq -KqrMCQj3ZWfGzd/TtiunvczRDnBfuCPRy5FOCvTIeuXZYzbB1N/8Ipf3YF3qKS9Ysr1YvY2WTxB1 -v0h7PVGHoTx0IsL8B3+A3MSs/mrBcDCw6Y5p4ixpgZQJut3+TcCDjJRYwEYgr5wfAvg1VUkvRtTA -8KCWAg8zxXHzniN9lLf9OtMJgwYh/WA9rjLA0u6NpvDntIJ8CsxwyXmA+P5M9zWEGYox+wrZ13+b -8KKaa8MFSu1BYBQw0aoRQm7TIwIEC8Zl3d1Sd9qBa7Ko+gE4uZbqKmxnl4mUnrzhVNXkanjvSr0r -mj1AfsbAddJu+2gw7OyLnflJNZoaLNmzlTnVHpL3prllL+U9bTpITAjc5CgSKL59NVzq4BZ+Extq -1z7XnvwtdbLBFNUjA9tbbws+eC8N3jONFrdI54OagQ97wUNNVQQXOEpR1VmiiXTTn74eS9fGbbeI -JG9gkaSChVtWQbzQRKtqE77RLFi3EjNYsjdj3BP1lB0/QFH1T/U67cjF68IeHRaVesd+QnGTbksV -tzDfqu1XhUisHWrdOWnk4Xl4vs4Fv6EM94B7IWcnMFk= ------END CERTIFICATE----- - -Staat der Nederlanden EV Root CA -================================ ------BEGIN CERTIFICATE----- -MIIFcDCCA1igAwIBAgIEAJiWjTANBgkqhkiG9w0BAQsFADBYMQswCQYDVQQGEwJOTDEeMBwGA1UE -CgwVU3RhYXQgZGVyIE5lZGVybGFuZGVuMSkwJwYDVQQDDCBTdGFhdCBkZXIgTmVkZXJsYW5kZW4g -RVYgUm9vdCBDQTAeFw0xMDEyMDgxMTE5MjlaFw0yMjEyMDgxMTEwMjhaMFgxCzAJBgNVBAYTAk5M -MR4wHAYDVQQKDBVTdGFhdCBkZXIgTmVkZXJsYW5kZW4xKTAnBgNVBAMMIFN0YWF0IGRlciBOZWRl -cmxhbmRlbiBFViBSb290IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA48d+ifkk -SzrSM4M1LGns3Amk41GoJSt5uAg94JG6hIXGhaTK5skuU6TJJB79VWZxXSzFYGgEt9nCUiY4iKTW -O0Cmws0/zZiTs1QUWJZV1VD+hq2kY39ch/aO5ieSZxeSAgMs3NZmdO3dZ//BYY1jTw+bbRcwJu+r -0h8QoPnFfxZpgQNH7R5ojXKhTbImxrpsX23Wr9GxE46prfNeaXUmGD5BKyF/7otdBwadQ8QpCiv8 -Kj6GyzyDOvnJDdrFmeK8eEEzduG/L13lpJhQDBXd4Pqcfzho0LKmeqfRMb1+ilgnQ7O6M5HTp5gV -XJrm0w912fxBmJc+qiXbj5IusHsMX/FjqTf5m3VpTCgmJdrV8hJwRVXj33NeN/UhbJCONVrJ0yPr -08C+eKxCKFhmpUZtcALXEPlLVPxdhkqHz3/KRawRWrUgUY0viEeXOcDPusBCAUCZSCELa6fS/ZbV -0b5GnUngC6agIk440ME8MLxwjyx1zNDFjFE7PZQIZCZhfbnDZY8UnCHQqv0XcgOPvZuM5l5Tnrmd -74K74bzickFbIZTTRTeU0d8JOV3nI6qaHcptqAqGhYqCvkIH1vI4gnPah1vlPNOePqc7nvQDs/nx -fRN0Av+7oeX6AHkcpmZBiFxgV6YuCcS6/ZrPpx9Aw7vMWgpVSzs4dlG4Y4uElBbmVvMCAwEAAaNC -MEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFP6rAJCYniT8qcwa -ivsnuL8wbqg7MA0GCSqGSIb3DQEBCwUAA4ICAQDPdyxuVr5Os7aEAJSrR8kN0nbHhp8dB9O2tLsI -eK9p0gtJ3jPFrK3CiAJ9Brc1AsFgyb/E6JTe1NOpEyVa/m6irn0F3H3zbPB+po3u2dfOWBfoqSmu -c0iH55vKbimhZF8ZE/euBhD/UcabTVUlT5OZEAFTdfETzsemQUHSv4ilf0X8rLiltTMMgsT7B/Zq -5SWEXwbKwYY5EdtYzXc7LMJMD16a4/CrPmEbUCTCwPTxGfARKbalGAKb12NMcIxHowNDXLldRqAN -b/9Zjr7dn3LDWyvfjFvO5QxGbJKyCqNMVEIYFRIYvdr8unRu/8G2oGTYqV9Vrp9canaW2HNnh/tN -f1zuacpzEPuKqf2evTY4SUmH9A4U8OmHuD+nT3pajnnUk+S7aFKErGzp85hwVXIy+TSrK0m1zSBi -5Dp6Z2Orltxtrpfs/J92VoguZs9btsmksNcFuuEnL5O7Jiqik7Ab846+HUCjuTaPPoIaGl6I6lD4 -WeKDRikL40Rc4ZW2aZCaFG+XroHPaO+Zmr615+F/+PoTRxZMzG0IQOeLeG9QgkRQP2YGiqtDhFZK -DyAthg710tvSeopLzaXoTvFeJiUBWSOgftL2fiFX1ye8FVdMpEbB4IMeDExNH08GGeL5qPQ6gqGy -eUN51q1veieQA6TqJIc/2b3Z6fJfUEkc7uzXLg== ------END CERTIFICATE----- - -IdenTrust Commercial Root CA 1 -============================== ------BEGIN CERTIFICATE----- -MIIFYDCCA0igAwIBAgIQCgFCgAAAAUUjyES1AAAAAjANBgkqhkiG9w0BAQsFADBKMQswCQYDVQQG -EwJVUzESMBAGA1UEChMJSWRlblRydXN0MScwJQYDVQQDEx5JZGVuVHJ1c3QgQ29tbWVyY2lhbCBS -b290IENBIDEwHhcNMTQwMTE2MTgxMjIzWhcNMzQwMTE2MTgxMjIzWjBKMQswCQYDVQQGEwJVUzES -MBAGA1UEChMJSWRlblRydXN0MScwJQYDVQQDEx5JZGVuVHJ1c3QgQ29tbWVyY2lhbCBSb290IENB -IDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCnUBneP5k91DNG8W9RYYKyqU+PZ4ld -hNlT3Qwo2dfw/66VQ3KZ+bVdfIrBQuExUHTRgQ18zZshq0PirK1ehm7zCYofWjK9ouuU+ehcCuz/ -mNKvcbO0U59Oh++SvL3sTzIwiEsXXlfEU8L2ApeN2WIrvyQfYo3fw7gpS0l4PJNgiCL8mdo2yMKi -1CxUAGc1bnO/AljwpN3lsKImesrgNqUZFvX9t++uP0D1bVoE/c40yiTcdCMbXTMTEl3EASX2MN0C -XZ/g1Ue9tOsbobtJSdifWwLziuQkkORiT0/Br4sOdBeo0XKIanoBScy0RnnGF7HamB4HWfp1IYVl -3ZBWzvurpWCdxJ35UrCLvYf5jysjCiN2O/cz4ckA82n5S6LgTrx+kzmEB/dEcH7+B1rlsazRGMzy -NeVJSQjKVsk9+w8YfYs7wRPCTY/JTw436R+hDmrfYi7LNQZReSzIJTj0+kuniVyc0uMNOYZKdHzV -WYfCP04MXFL0PfdSgvHqo6z9STQaKPNBiDoT7uje/5kdX7rL6B7yuVBgwDHTc+XvvqDtMwt0viAg -xGds8AgDelWAf0ZOlqf0Hj7h9tgJ4TNkK2PXMl6f+cB7D3hvl7yTmvmcEpB4eoCHFddydJxVdHix -uuFucAS6T6C6aMN7/zHwcz09lCqxC0EOoP5NiGVreTO01wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMC -AQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU7UQZwNPwBovupHu+QucmVMiONnYwDQYJKoZI -hvcNAQELBQADggIBAA2ukDL2pkt8RHYZYR4nKM1eVO8lvOMIkPkp165oCOGUAFjvLi5+U1KMtlwH -6oi6mYtQlNeCgN9hCQCTrQ0U5s7B8jeUeLBfnLOic7iPBZM4zY0+sLj7wM+x8uwtLRvM7Kqas6pg -ghstO8OEPVeKlh6cdbjTMM1gCIOQ045U8U1mwF10A0Cj7oV+wh93nAbowacYXVKV7cndJZ5t+qnt -ozo00Fl72u1Q8zW/7esUTTHHYPTa8Yec4kjixsU3+wYQ+nVZZjFHKdp2mhzpgq7vmrlR94gjmmmV -YjzlVYA211QC//G5Xc7UI2/YRYRKW2XviQzdFKcgyxilJbQN+QHwotL0AMh0jqEqSI5l2xPE4iUX -feu+h1sXIFRRk0pTAwvsXcoz7WL9RccvW9xYoIA55vrX/hMUpu09lEpCdNTDd1lzzY9GvlU47/ro -kTLql1gEIt44w8y8bckzOmoKaT+gyOpyj4xjhiO9bTyWnpXgSUyqorkqG5w2gXjtw+hG4iZZRHUe -2XWJUc0QhJ1hYMtd+ZciTY6Y5uN/9lu7rs3KSoFrXgvzUeF0K+l+J6fZmUlO+KWA2yUPHGNiiskz -Z2s8EIPGrd6ozRaOjfAHN3Gf8qv8QfXBi+wAN10J5U6A7/qxXDgGpRtK4dw4LTzcqx+QGtVKnO7R -cGzM7vRX+Bi6hG6H ------END CERTIFICATE----- - -IdenTrust Public Sector Root CA 1 -================================= ------BEGIN CERTIFICATE----- -MIIFZjCCA06gAwIBAgIQCgFCgAAAAUUjz0Z8AAAAAjANBgkqhkiG9w0BAQsFADBNMQswCQYDVQQG -EwJVUzESMBAGA1UEChMJSWRlblRydXN0MSowKAYDVQQDEyFJZGVuVHJ1c3QgUHVibGljIFNlY3Rv -ciBSb290IENBIDEwHhcNMTQwMTE2MTc1MzMyWhcNMzQwMTE2MTc1MzMyWjBNMQswCQYDVQQGEwJV -UzESMBAGA1UEChMJSWRlblRydXN0MSowKAYDVQQDEyFJZGVuVHJ1c3QgUHVibGljIFNlY3RvciBS -b290IENBIDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2IpT8pEiv6EdrCvsnduTy -P4o7ekosMSqMjbCpwzFrqHd2hCa2rIFCDQjrVVi7evi8ZX3yoG2LqEfpYnYeEe4IFNGyRBb06tD6 -Hi9e28tzQa68ALBKK0CyrOE7S8ItneShm+waOh7wCLPQ5CQ1B5+ctMlSbdsHyo+1W/CD80/HLaXI -rcuVIKQxKFdYWuSNG5qrng0M8gozOSI5Cpcu81N3uURF/YTLNiCBWS2ab21ISGHKTN9T0a9SvESf -qy9rg3LvdYDaBjMbXcjaY8ZNzaxmMc3R3j6HEDbhuaR672BQssvKplbgN6+rNBM5Jeg5ZuSYeqoS -mJxZZoY+rfGwyj4GD3vwEUs3oERte8uojHH01bWRNszwFcYr3lEXsZdMUD2xlVl8BX0tIdUAvwFn -ol57plzy9yLxkA2T26pEUWbMfXYD62qoKjgZl3YNa4ph+bz27nb9cCvdKTz4Ch5bQhyLVi9VGxyh -LrXHFub4qjySjmm2AcG1hp2JDws4lFTo6tyePSW8Uybt1as5qsVATFSrsrTZ2fjXctscvG29ZV/v -iDUqZi/u9rNl8DONfJhBaUYPQxxp+pu10GFqzcpL2UyQRqsVWaFHVCkugyhfHMKiq3IXAAaOReyL -4jM9f9oZRORicsPfIsbyVtTdX5Vy7W1f90gDW/3FKqD2cyOEEBsB5wIDAQABo0IwQDAOBgNVHQ8B -Af8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU43HgntinQtnbcZFrlJPrw6PRFKMw -DQYJKoZIhvcNAQELBQADggIBAEf63QqwEZE4rU1d9+UOl1QZgkiHVIyqZJnYWv6IAcVYpZmxI1Qj -t2odIFflAWJBF9MJ23XLblSQdf4an4EKwt3X9wnQW3IV5B4Jaj0z8yGa5hV+rVHVDRDtfULAj+7A -mgjVQdZcDiFpboBhDhXAuM/FSRJSzL46zNQuOAXeNf0fb7iAaJg9TaDKQGXSc3z1i9kKlT/YPyNt -GtEqJBnZhbMX73huqVjRI9PHE+1yJX9dsXNw0H8GlwmEKYBhHfpe/3OsoOOJuBxxFcbeMX8S3OFt -m6/n6J91eEyrRjuazr8FGF1NFTwWmhlQBJqymm9li1JfPFgEKCXAZmExfrngdbkaqIHWchezxQMx -NRF4eKLg6TCMf4DfWN88uieW4oA0beOY02QnrEh+KHdcxiVhJfiFDGX6xDIvpZgF5PgLZxYWxoK4 -Mhn5+bl53B/N66+rDt0b20XkeucC4pVd/GnwU2lhlXV5C15V5jgclKlZM57IcXR5f1GJtshquDDI -ajjDbp7hNxbqBWJMWxJH7ae0s1hWx0nzfxJoCTFx8G34Tkf71oXuxVhAGaQdp/lLQzfcaFpPz+vC -ZHTetBXZ9FRUGi8c15dxVJCO2SCdUyt/q4/i6jC8UDfv8Ue1fXwsBOxonbRJRBD0ckscZOf85muQ -3Wl9af0AVqW3rLatt8o+Ae+c ------END CERTIFICATE----- - -Entrust Root Certification Authority - G2 -========================================= ------BEGIN CERTIFICATE----- -MIIEPjCCAyagAwIBAgIESlOMKDANBgkqhkiG9w0BAQsFADCBvjELMAkGA1UEBhMCVVMxFjAUBgNV -BAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwtdGVy -bXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ug -b25seTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIw -HhcNMDkwNzA3MTcyNTU0WhcNMzAxMjA3MTc1NTU0WjCBvjELMAkGA1UEBhMCVVMxFjAUBgNVBAoT -DUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwtdGVybXMx -OTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ugb25s -eTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIwggEi -MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6hLZy254Ma+KZ6TABp3bqMriVQRrJ2mFOWHLP -/vaCeb9zYQYKpSfYs1/TRU4cctZOMvJyig/3gxnQaoCAAEUesMfnmr8SVycco2gvCoe9amsOXmXz -HHfV1IWNcCG0szLni6LVhjkCsbjSR87kyUnEO6fe+1R9V77w6G7CebI6C1XiUJgWMhNcL3hWwcKU -s/Ja5CeanyTXxuzQmyWC48zCxEXFjJd6BmsqEZ+pCm5IO2/b1BEZQvePB7/1U1+cPvQXLOZprE4y -TGJ36rfo5bs0vBmLrpxR57d+tVOxMyLlbc9wPBr64ptntoP0jaWvYkxN4FisZDQSA/i2jZRjJKRx -AgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqciZ6 -0B7vfec7aVHUbI2fkBJmqzANBgkqhkiG9w0BAQsFAAOCAQEAeZ8dlsa2eT8ijYfThwMEYGprmi5Z -iXMRrEPR9RP/jTkrwPK9T3CMqS/qF8QLVJ7UG5aYMzyorWKiAHarWWluBh1+xLlEjZivEtRh2woZ -Rkfz6/djwUAFQKXSt/S1mja/qYh2iARVBCuch38aNzx+LaUa2NSJXsq9rD1s2G2v1fN2D807iDgi -nWyTmsQ9v4IbZT+mD12q/OWyFcq1rca8PdCE6OoGcrBNOTJ4vz4RnAuknZoh8/CbCzB428Hch0P+ -vGOaysXCHMnHjf87ElgI5rY97HosTvuDls4MPGmHVHOkc8KT/1EQrBVUAdj8BbGJoX90g5pJ19xO -e4pIb4tF9g== ------END CERTIFICATE----- - -Entrust Root Certification Authority - EC1 -========================================== ------BEGIN CERTIFICATE----- -MIIC+TCCAoCgAwIBAgINAKaLeSkAAAAAUNCR+TAKBggqhkjOPQQDAzCBvzELMAkGA1UEBhMCVVMx -FjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVn -YWwtdGVybXMxOTA3BgNVBAsTMChjKSAyMDEyIEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0aG9yaXpl -ZCB1c2Ugb25seTEzMDEGA1UEAxMqRW50cnVzdCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5 -IC0gRUMxMB4XDTEyMTIxODE1MjUzNloXDTM3MTIxODE1NTUzNlowgb8xCzAJBgNVBAYTAlVTMRYw -FAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQLEx9TZWUgd3d3LmVudHJ1c3QubmV0L2xlZ2Fs -LXRlcm1zMTkwNwYDVQQLEzAoYykgMjAxMiBFbnRydXN0LCBJbmMuIC0gZm9yIGF1dGhvcml6ZWQg -dXNlIG9ubHkxMzAxBgNVBAMTKkVudHJ1c3QgUm9vdCBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eSAt -IEVDMTB2MBAGByqGSM49AgEGBSuBBAAiA2IABIQTydC6bUF74mzQ61VfZgIaJPRbiWlH47jCffHy -AsWfoPZb1YsGGYZPUxBtByQnoaD41UcZYUx9ypMn6nQM72+WCf5j7HBdNq1nd67JnXxVRDqiY1Ef -9eNi1KlHBz7MIKNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE -FLdj5xrdjekIplWDpOBqUEFlEUJJMAoGCCqGSM49BAMDA2cAMGQCMGF52OVCR98crlOZF7ZvHH3h -vxGU0QOIdeSNiaSKd0bebWHvAvX7td/M/k7//qnmpwIwW5nXhTcGtXsI/esni0qU+eH6p44mCOh8 -kmhtc9hvJqwhAriZtyZBWyVgrtBIGu4G ------END CERTIFICATE----- - -CFCA EV ROOT -============ ------BEGIN CERTIFICATE----- -MIIFjTCCA3WgAwIBAgIEGErM1jANBgkqhkiG9w0BAQsFADBWMQswCQYDVQQGEwJDTjEwMC4GA1UE -CgwnQ2hpbmEgRmluYW5jaWFsIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRUwEwYDVQQDDAxDRkNB -IEVWIFJPT1QwHhcNMTIwODA4MDMwNzAxWhcNMjkxMjMxMDMwNzAxWjBWMQswCQYDVQQGEwJDTjEw -MC4GA1UECgwnQ2hpbmEgRmluYW5jaWFsIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRUwEwYDVQQD -DAxDRkNBIEVWIFJPT1QwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDXXWvNED8fBVnV -BU03sQ7smCuOFR36k0sXgiFxEFLXUWRwFsJVaU2OFW2fvwwbwuCjZ9YMrM8irq93VCpLTIpTUnrD -7i7es3ElweldPe6hL6P3KjzJIx1qqx2hp/Hz7KDVRM8Vz3IvHWOX6Jn5/ZOkVIBMUtRSqy5J35DN -uF++P96hyk0g1CXohClTt7GIH//62pCfCqktQT+x8Rgp7hZZLDRJGqgG16iI0gNyejLi6mhNbiyW -ZXvKWfry4t3uMCz7zEasxGPrb382KzRzEpR/38wmnvFyXVBlWY9ps4deMm/DGIq1lY+wejfeWkU7 -xzbh72fROdOXW3NiGUgthxwG+3SYIElz8AXSG7Ggo7cbcNOIabla1jj0Ytwli3i/+Oh+uFzJlU9f -py25IGvPa931DfSCt/SyZi4QKPaXWnuWFo8BGS1sbn85WAZkgwGDg8NNkt0yxoekN+kWzqotaK8K -gWU6cMGbrU1tVMoqLUuFG7OA5nBFDWteNfB/O7ic5ARwiRIlk9oKmSJgamNgTnYGmE69g60dWIol -hdLHZR4tjsbftsbhf4oEIRUpdPA+nJCdDC7xij5aqgwJHsfVPKPtl8MeNPo4+QgO48BdK4PRVmrJ -tqhUUy54Mmc9gn900PvhtgVguXDbjgv5E1hvcWAQUhC5wUEJ73IfZzF4/5YFjQIDAQABo2MwYTAf -BgNVHSMEGDAWgBTj/i39KNALtbq2osS/BqoFjJP7LzAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB -/wQEAwIBBjAdBgNVHQ4EFgQU4/4t/SjQC7W6tqLEvwaqBYyT+y8wDQYJKoZIhvcNAQELBQADggIB -ACXGumvrh8vegjmWPfBEp2uEcwPenStPuiB/vHiyz5ewG5zz13ku9Ui20vsXiObTej/tUxPQ4i9q -ecsAIyjmHjdXNYmEwnZPNDatZ8POQQaIxffu2Bq41gt/UP+TqhdLjOztUmCypAbqTuv0axn96/Ua -4CUqmtzHQTb3yHQFhDmVOdYLO6Qn+gjYXB74BGBSESgoA//vU2YApUo0FmZ8/Qmkrp5nGm9BC2sG -E5uPhnEFtC+NiWYzKXZUmhH4J/qyP5Hgzg0b8zAarb8iXRvTvyUFTeGSGn+ZnzxEk8rUQElsgIfX -BDrDMlI1Dlb4pd19xIsNER9Tyx6yF7Zod1rg1MvIB671Oi6ON7fQAUtDKXeMOZePglr4UeWJoBjn -aH9dCi77o0cOPaYjesYBx4/IXr9tgFa+iiS6M+qf4TIRnvHST4D2G0CvOJ4RUHlzEhLN5mydLIhy -PDCBBpEi6lmt2hkuIsKNuYyH4Ga8cyNfIWRjgEj1oDwYPZTISEEdQLpe/v5WOaHIz16eGWRGENoX -kbcFgKyLmZJ956LYBws2J+dIeWCKw9cTXPhyQN9Ky8+ZAAoACxGV2lZFA4gKn2fQ1XmxqI1AbQ3C -ekD6819kR5LLU7m7Wc5P/dAVUwHY3+vZ5nbv0CO7O6l5s9UCKc2Jo5YPSjXnTkLAdc0Hz+Ys63su ------END CERTIFICATE----- - -T?RKTRUST Elektronik Sertifika Hizmet Sa?lay?c?s? H5 -==================================================== ------BEGIN CERTIFICATE----- -MIIEJzCCAw+gAwIBAgIHAI4X/iQggTANBgkqhkiG9w0BAQsFADCBsTELMAkGA1UEBhMCVFIxDzAN -BgNVBAcMBkFua2FyYTFNMEsGA1UECgxEVMOcUktUUlVTVCBCaWxnaSDEsGxldGnFn2ltIHZlIEJp -bGnFn2ltIEfDvHZlbmxpxJ9pIEhpem1ldGxlcmkgQS7Fni4xQjBABgNVBAMMOVTDnFJLVFJVU1Qg -RWxla3Ryb25payBTZXJ0aWZpa2EgSGl6bWV0IFNhxJ9sYXnEsWPEsXPEsSBINTAeFw0xMzA0MzAw -ODA3MDFaFw0yMzA0MjgwODA3MDFaMIGxMQswCQYDVQQGEwJUUjEPMA0GA1UEBwwGQW5rYXJhMU0w -SwYDVQQKDERUw5xSS1RSVVNUIEJpbGdpIMSwbGV0acWfaW0gdmUgQmlsacWfaW0gR8O8dmVubGnE -n2kgSGl6bWV0bGVyaSBBLsWeLjFCMEAGA1UEAww5VMOcUktUUlVTVCBFbGVrdHJvbmlrIFNlcnRp -ZmlrYSBIaXptZXQgU2HEn2xhecSxY8Sxc8SxIEg1MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB -CgKCAQEApCUZ4WWe60ghUEoI5RHwWrom/4NZzkQqL/7hzmAD/I0Dpe3/a6i6zDQGn1k19uwsu537 -jVJp45wnEFPzpALFp/kRGml1bsMdi9GYjZOHp3GXDSHHmflS0yxjXVW86B8BSLlg/kJK9siArs1m -ep5Fimh34khon6La8eHBEJ/rPCmBp+EyCNSgBbGM+42WAA4+Jd9ThiI7/PS98wl+d+yG6w8z5UNP -9FR1bSmZLmZaQ9/LXMrI5Tjxfjs1nQ/0xVqhzPMggCTTV+wVunUlm+hkS7M0hO8EuPbJbKoCPrZV -4jI3X/xml1/N1p7HIL9Nxqw/dV8c7TKcfGkAaZHjIxhT6QIDAQABo0IwQDAdBgNVHQ4EFgQUVpkH -HtOsDGlktAxQR95DLL4gwPswDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI -hvcNAQELBQADggEBAJ5FdnsXSDLyOIspve6WSk6BGLFRRyDN0GSxDsnZAdkJzsiZ3GglE9Rc8qPo -BP5yCccLqh0lVX6Wmle3usURehnmp349hQ71+S4pL+f5bFgWV1Al9j4uPqrtd3GqqpmWRgqujuwq -URawXs3qZwQcWDD1YIq9pr1N5Za0/EKJAWv2cMhQOQwt1WbZyNKzMrcbGW3LM/nfpeYVhDfwwvJl -lpKQd/Ct9JDpEXjXk4nAPQu6KfTomZ1yju2dL+6SfaHx/126M2CFYv4HAqGEVka+lgqaE9chTLd8 -B59OTj+RdPsnnRHM3eaxynFNExc5JsUpISuTKWqW+qtB4Uu2NQvAmxU= ------END CERTIFICATE----- - -Certinomis - Root CA -==================== ------BEGIN CERTIFICATE----- -MIIFkjCCA3qgAwIBAgIBATANBgkqhkiG9w0BAQsFADBaMQswCQYDVQQGEwJGUjETMBEGA1UEChMK -Q2VydGlub21pczEXMBUGA1UECxMOMDAwMiA0MzM5OTg5MDMxHTAbBgNVBAMTFENlcnRpbm9taXMg -LSBSb290IENBMB4XDTEzMTAyMTA5MTcxOFoXDTMzMTAyMTA5MTcxOFowWjELMAkGA1UEBhMCRlIx -EzARBgNVBAoTCkNlcnRpbm9taXMxFzAVBgNVBAsTDjAwMDIgNDMzOTk4OTAzMR0wGwYDVQQDExRD -ZXJ0aW5vbWlzIC0gUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANTMCQos -P5L2fxSeC5yaah1AMGT9qt8OHgZbn1CF6s2Nq0Nn3rD6foCWnoR4kkjW4znuzuRZWJflLieY6pOo -d5tK8O90gC3rMB+12ceAnGInkYjwSond3IjmFPnVAy//ldu9n+ws+hQVWZUKxkd8aRi5pwP5ynap -z8dvtF4F/u7BUrJ1Mofs7SlmO/NKFoL21prbcpjp3vDFTKWrteoB4owuZH9kb/2jJZOLyKIOSY00 -8B/sWEUuNKqEUL3nskoTuLAPrjhdsKkb5nPJWqHZZkCqqU2mNAKthH6yI8H7KsZn9DS2sJVqM09x -RLWtwHkziOC/7aOgFLScCbAK42C++PhmiM1b8XcF4LVzbsF9Ri6OSyemzTUK/eVNfaoqoynHWmgE -6OXWk6RiwsXm9E/G+Z8ajYJJGYrKWUM66A0ywfRMEwNvbqY/kXPLynNvEiCL7sCCeN5LLsJJwx3t -FvYk9CcbXFcx3FXuqB5vbKziRcxXV4p1VxngtViZSTYxPDMBbRZKzbgqg4SGm/lg0h9tkQPTYKbV -PZrdd5A9NaSfD171UkRpucC63M9933zZxKyGIjK8e2uR73r4F2iw4lNVYC2vPsKD2NkJK/DAZNuH -i5HMkesE/Xa0lZrmFAYb1TQdvtj/dBxThZngWVJKYe2InmtJiUZ+IFrZ50rlau7SZRFDAgMBAAGj -YzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTvkUz1pcMw6C8I -6tNxIqSSaHh02TAfBgNVHSMEGDAWgBTvkUz1pcMw6C8I6tNxIqSSaHh02TANBgkqhkiG9w0BAQsF -AAOCAgEAfj1U2iJdGlg+O1QnurrMyOMaauo++RLrVl89UM7g6kgmJs95Vn6RHJk/0KGRHCwPT5iV -WVO90CLYiF2cN/z7ZMF4jIuaYAnq1fohX9B0ZedQxb8uuQsLrbWwF6YSjNRieOpWauwK0kDDPAUw -Pk2Ut59KA9N9J0u2/kTO+hkzGm2kQtHdzMjI1xZSg081lLMSVX3l4kLr5JyTCcBMWwerx20RoFAX -lCOotQqSD7J6wWAsOMwaplv/8gzjqh8c3LigkyfeY+N/IZ865Z764BNqdeuWXGKRlI5nU7aJ+BIJ -y29SWwNyhlCVCNSNh4YVH5Uk2KRvms6knZtt0rJ2BobGVgjF6wnaNsIbW0G+YSrjcOa4pvi2WsS9 -Iff/ql+hbHY5ZtbqTFXhADObE5hjyW/QASAJN1LnDE8+zbz1X5YnpyACleAu6AdBBR8Vbtaw5Bng -DwKTACdyxYvRVB9dSsNAl35VpnzBMwQUAR1JIGkLGZOdblgi90AMRgwjY/M50n92Uaf0yKHxDHYi -I0ZSKS3io0EHVmmY0gUJvGnHWmHNj4FgFU2A3ZDifcRQ8ow7bkrHxuaAKzyBvBGAFhAn1/DNP3nM -cyrDflOR1m749fPH0FFNjkulW+YZFzvWgQncItzujrnEj1PhZ7szuIgVRs/taTX/dQ1G885x4cVr -hkIGuUE= ------END CERTIFICATE----- - -OISTE WISeKey Global Root GB CA -=============================== ------BEGIN CERTIFICATE----- -MIIDtTCCAp2gAwIBAgIQdrEgUnTwhYdGs/gjGvbCwDANBgkqhkiG9w0BAQsFADBtMQswCQYDVQQG -EwJDSDEQMA4GA1UEChMHV0lTZUtleTEiMCAGA1UECxMZT0lTVEUgRm91bmRhdGlvbiBFbmRvcnNl -ZDEoMCYGA1UEAxMfT0lTVEUgV0lTZUtleSBHbG9iYWwgUm9vdCBHQiBDQTAeFw0xNDEyMDExNTAw -MzJaFw0zOTEyMDExNTEwMzFaMG0xCzAJBgNVBAYTAkNIMRAwDgYDVQQKEwdXSVNlS2V5MSIwIAYD -VQQLExlPSVNURSBGb3VuZGF0aW9uIEVuZG9yc2VkMSgwJgYDVQQDEx9PSVNURSBXSVNlS2V5IEds -b2JhbCBSb290IEdCIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2Be3HEokKtaX -scriHvt9OO+Y9bI5mE4nuBFde9IllIiCFSZqGzG7qFshISvYD06fWvGxWuR51jIjK+FTzJlFXHtP -rby/h0oLS5daqPZI7H17Dc0hBt+eFf1Biki3IPShehtX1F1Q/7pn2COZH8g/497/b1t3sWtuuMlk -9+HKQUYOKXHQuSP8yYFfTvdv37+ErXNku7dCjmn21HYdfp2nuFeKUWdy19SouJVUQHMD9ur06/4o -Qnc/nSMbsrY9gBQHTC5P99UKFg29ZkM3fiNDecNAhvVMKdqOmq0NpQSHiB6F4+lT1ZvIiwNjeOvg -GUpuuy9rM2RYk61pv48b74JIxwIDAQABo1EwTzALBgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB -/zAdBgNVHQ4EFgQUNQ/INmNe4qPs+TtmFc5RUuORmj0wEAYJKwYBBAGCNxUBBAMCAQAwDQYJKoZI -hvcNAQELBQADggEBAEBM+4eymYGQfp3FsLAmzYh7KzKNbrghcViXfa43FK8+5/ea4n32cZiZBKpD -dHij40lhPnOMTZTg+XHEthYOU3gf1qKHLwI5gSk8rxWYITD+KJAAjNHhy/peyP34EEY7onhCkRd0 -VQreUGdNZtGn//3ZwLWoo4rOZvUPQ82nK1d7Y0Zqqi5S2PTt4W2tKZB4SLrhI6qjiey1q5bAtEui -HZeeevJuQHHfaPFlTc58Bd9TZaml8LGXBHAVRgOY1NK/VLSgWH1Sb9pWJmLU2NuJMW8c8CLC02Ic -Nc1MaRVUGpCY3useX8p3x8uOPUNpnJpY0CQ73xtAln41rYHHTnG6iBM= ------END CERTIFICATE----- - -Certification Authority of WoSign G2 -==================================== ------BEGIN CERTIFICATE----- -MIIDfDCCAmSgAwIBAgIQayXaioidfLwPBbOxemFFRDANBgkqhkiG9w0BAQsFADBYMQswCQYDVQQG -EwJDTjEaMBgGA1UEChMRV29TaWduIENBIExpbWl0ZWQxLTArBgNVBAMTJENlcnRpZmljYXRpb24g -QXV0aG9yaXR5IG9mIFdvU2lnbiBHMjAeFw0xNDExMDgwMDU4NThaFw00NDExMDgwMDU4NThaMFgx -CzAJBgNVBAYTAkNOMRowGAYDVQQKExFXb1NpZ24gQ0EgTGltaXRlZDEtMCsGA1UEAxMkQ2VydGlm -aWNhdGlvbiBBdXRob3JpdHkgb2YgV29TaWduIEcyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB -CgKCAQEAvsXEoCKASU+/2YcRxlPhuw+9YH+v9oIOH9ywjj2X4FA8jzrvZjtFB5sg+OPXJYY1kBai -XW8wGQiHC38Gsp1ij96vkqVg1CuAmlI/9ZqD6TRay9nVYlzmDuDfBpgOgHzKtB0TiGsOqCR3A9Du -W/PKaZE1OVbFbeP3PU9ekzgkyhjpJMuSA93MHD0JcOQg5PGurLtzaaNjOg9FD6FKmsLRY6zLEPg9 -5k4ot+vElbGs/V6r+kHLXZ1L3PR8du9nfwB6jdKgGlxNIuG12t12s9R23164i5jIFFTMaxeSt+BK -v0mUYQs4kI9dJGwlezt52eJ+na2fmKEG/HgUYFf47oB3sQIDAQABo0IwQDAOBgNVHQ8BAf8EBAMC -AQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU+mCp62XF3RYUCE4MD42b4Pdkr2cwDQYJKoZI -hvcNAQELBQADggEBAFfDejaCnI2Y4qtAqkePx6db7XznPWZaOzG73/MWM5H8fHulwqZm46qwtyeY -P0nXYGdnPzZPSsvxFPpahygc7Y9BMsaV+X3avXtbwrAh449G3CE4Q3RM+zD4F3LBMvzIkRfEzFg3 -TgvMWvchNSiDbGAtROtSjFA9tWwS1/oJu2yySrHFieT801LYYRf+epSEj3m2M1m6D8QL4nCgS3gu -+sif/a+RZQp4OBXllxcU3fngLDT4ONCEIgDAFFEYKwLcMFrw6AF8NTojrwjkr6qOKEJJLvD1mTS+ -7Q9LGOHSJDy7XUe3IfKN0QqZjuNuPq1w4I+5ysxugTH2e5x6eeRncRg= ------END CERTIFICATE----- - -CA WoSign ECC Root -================== ------BEGIN CERTIFICATE----- -MIICCTCCAY+gAwIBAgIQaEpYcIBr8I8C+vbe6LCQkDAKBggqhkjOPQQDAzBGMQswCQYDVQQGEwJD -TjEaMBgGA1UEChMRV29TaWduIENBIExpbWl0ZWQxGzAZBgNVBAMTEkNBIFdvU2lnbiBFQ0MgUm9v -dDAeFw0xNDExMDgwMDU4NThaFw00NDExMDgwMDU4NThaMEYxCzAJBgNVBAYTAkNOMRowGAYDVQQK -ExFXb1NpZ24gQ0EgTGltaXRlZDEbMBkGA1UEAxMSQ0EgV29TaWduIEVDQyBSb290MHYwEAYHKoZI -zj0CAQYFK4EEACIDYgAE4f2OuEMkq5Z7hcK6C62N4DrjJLnSsb6IOsq/Srj57ywvr1FQPEd1bPiU -t5v8KB7FVMxjnRZLU8HnIKvNrCXSf4/CwVqCXjCLelTOA7WRf6qU0NGKSMyCBSah1VES1ns2o0Iw -QDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUqv3VWqP2h4syhf3R -MluARZPzA7gwCgYIKoZIzj0EAwMDaAAwZQIxAOSkhLCB1T2wdKyUpOgOPQB0TKGXa/kNUTyh2Tv0 -Daupn75OcsqF1NnstTJFGG+rrQIwfcf3aWMvoeGY7xMQ0Xk/0f7qO3/eVvSQsRUR2LIiFdAvwyYu -a/GRspBl9JrmkO5K ------END CERTIFICATE----- - -SZAFIR ROOT CA2 -=============== ------BEGIN CERTIFICATE----- -MIIDcjCCAlqgAwIBAgIUPopdB+xV0jLVt+O2XwHrLdzk1uQwDQYJKoZIhvcNAQELBQAwUTELMAkG -A1UEBhMCUEwxKDAmBgNVBAoMH0tyYWpvd2EgSXpiYSBSb3psaWN6ZW5pb3dhIFMuQS4xGDAWBgNV -BAMMD1NaQUZJUiBST09UIENBMjAeFw0xNTEwMTkwNzQzMzBaFw0zNTEwMTkwNzQzMzBaMFExCzAJ -BgNVBAYTAlBMMSgwJgYDVQQKDB9LcmFqb3dhIEl6YmEgUm96bGljemVuaW93YSBTLkEuMRgwFgYD -VQQDDA9TWkFGSVIgUk9PVCBDQTIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC3vD5Q -qEvNQLXOYeeWyrSh2gwisPq1e3YAd4wLz32ohswmUeQgPYUM1ljj5/QqGJ3a0a4m7utT3PSQ1hNK -DJA8w/Ta0o4NkjrcsbH/ON7Dui1fgLkCvUqdGw+0w8LBZwPd3BucPbOw3gAeqDRHu5rr/gsUvTaE -2g0gv/pby6kWIK05YO4vdbbnl5z5Pv1+TW9NL++IDWr63fE9biCloBK0TXC5ztdyO4mTp4CEHCdJ -ckm1/zuVnsHMyAHs6A6KCpbns6aH5db5BSsNl0BwPLqsdVqc1U2dAgrSS5tmS0YHF2Wtn2yIANwi -ieDhZNRnvDF5YTy7ykHNXGoAyDw4jlivAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P -AQH/BAQDAgEGMB0GA1UdDgQWBBQuFqlKGLXLzPVvUPMjX/hd56zwyDANBgkqhkiG9w0BAQsFAAOC -AQEAtXP4A9xZWx126aMqe5Aosk3AM0+qmrHUuOQn/6mWmc5G4G18TKI4pAZw8PRBEew/R40/cof5 -O/2kbytTAOD/OblqBw7rHRz2onKQy4I9EYKL0rufKq8h5mOGnXkZ7/e7DDWQw4rtTw/1zBLZpD67 -oPwglV9PJi8RI4NOdQcPv5vRtB3pEAT+ymCPoky4rc/hkA/NrgrHXXu3UNLUYfrVFdvXn4dRVOul -4+vJhaAlIDf7js4MNIThPIGyd05DpYhfhmehPea0XGG2Ptv+tyjFogeutcrKjSoS75ftwjCkySp6 -+/NNIxuZMzSgLvWpCz/UXeHPhJ/iGcJfitYgHuNztw== ------END CERTIFICATE----- - -Certum Trusted Network CA 2 -=========================== ------BEGIN CERTIFICATE----- -MIIF0jCCA7qgAwIBAgIQIdbQSk8lD8kyN/yqXhKN6TANBgkqhkiG9w0BAQ0FADCBgDELMAkGA1UE -BhMCUEwxIjAgBgNVBAoTGVVuaXpldG8gVGVjaG5vbG9naWVzIFMuQS4xJzAlBgNVBAsTHkNlcnR1 -bSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEkMCIGA1UEAxMbQ2VydHVtIFRydXN0ZWQgTmV0d29y -ayBDQSAyMCIYDzIwMTExMDA2MDgzOTU2WhgPMjA0NjEwMDYwODM5NTZaMIGAMQswCQYDVQQGEwJQ -TDEiMCAGA1UEChMZVW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENl -cnRpZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3RlZCBOZXR3b3JrIENB -IDIwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC9+Xj45tWADGSdhhuWZGc/IjoedQF9 -7/tcZ4zJzFxrqZHmuULlIEub2pt7uZld2ZuAS9eEQCsn0+i6MLs+CRqnSZXvK0AkwpfHp+6bJe+o -CgCXhVqqndwpyeI1B+twTUrWwbNWuKFBOJvR+zF/j+Bf4bE/D44WSWDXBo0Y+aomEKsq09DRZ40b -Rr5HMNUuctHFY9rnY3lEfktjJImGLjQ/KUxSiyqnwOKRKIm5wFv5HdnnJ63/mgKXwcZQkpsCLL2p -uTRZCr+ESv/f/rOf69me4Jgj7KZrdxYq28ytOxykh9xGc14ZYmhFV+SQgkK7QtbwYeDBoz1mo130 -GO6IyY0XRSmZMnUCMe4pJshrAua1YkV/NxVaI2iJ1D7eTiew8EAMvE0Xy02isx7QBlrd9pPPV3WZ -9fqGGmd4s7+W/jTcvedSVuWz5XV710GRBdxdaeOVDUO5/IOWOZV7bIBaTxNyxtd9KXpEulKkKtVB -Rgkg/iKgtlswjbyJDNXXcPiHUv3a76xRLgezTv7QCdpw75j6VuZt27VXS9zlLCUVyJ4ueE742pye -hizKV/Ma5ciSixqClnrDvFASadgOWkaLOusm+iPJtrCBvkIApPjW/jAux9JG9uWOdf3yzLnQh1vM -BhBgu4M1t15n3kfsmUjxpKEV/q2MYo45VU85FrmxY53/twIDAQABo0IwQDAPBgNVHRMBAf8EBTAD -AQH/MB0GA1UdDgQWBBS2oVQ5AsOgP46KvPrU+Bym0ToO/TAOBgNVHQ8BAf8EBAMCAQYwDQYJKoZI -hvcNAQENBQADggIBAHGlDs7k6b8/ONWJWsQCYftMxRQXLYtPU2sQF/xlhMcQSZDe28cmk4gmb3DW -Al45oPePq5a1pRNcgRRtDoGCERuKTsZPpd1iHkTfCVn0W3cLN+mLIMb4Ck4uWBzrM9DPhmDJ2vuA -L55MYIR4PSFk1vtBHxgP58l1cb29XN40hz5BsA72udY/CROWFC/emh1auVbONTqwX3BNXuMp8SMo -clm2q8KMZiYcdywmdjWLKKdpoPk79SPdhRB0yZADVpHnr7pH1BKXESLjokmUbOe3lEu6LaTaM4tM -pkT/WjzGHWTYtTHkpjx6qFcL2+1hGsvxznN3Y6SHb0xRONbkX8eftoEq5IVIeVheO/jbAoJnwTnb -w3RLPTYe+SmTiGhbqEQZIfCn6IENLOiTNrQ3ssqwGyZ6miUfmpqAnksqP/ujmv5zMnHCnsZy4Ypo -J/HkD7TETKVhk/iXEAcqMCWpuchxuO9ozC1+9eB+D4Kob7a6bINDd82Kkhehnlt4Fj1F4jNy3eFm -ypnTycUm/Q1oBEauttmbjL4ZvrHG8hnjXALKLNhvSgfZyTXaQHXyxKcZb55CEJh15pWLYLztxRLX -is7VmFxWlgPF7ncGNf/P5O4/E2Hu29othfDNrp2yGAlFw5Khchf8R7agCyzxxN5DaAhqXzvwdmP7 -zAYspsbiDrW5viSP ------END CERTIFICATE----- - -Hellenic Academic and Research Institutions RootCA 2015 -======================================================= ------BEGIN CERTIFICATE----- -MIIGCzCCA/OgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBpjELMAkGA1UEBhMCR1IxDzANBgNVBAcT -BkF0aGVuczFEMEIGA1UEChM7SGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0 -aW9ucyBDZXJ0LiBBdXRob3JpdHkxQDA+BgNVBAMTN0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNl -YXJjaCBJbnN0aXR1dGlvbnMgUm9vdENBIDIwMTUwHhcNMTUwNzA3MTAxMTIxWhcNNDAwNjMwMTAx -MTIxWjCBpjELMAkGA1UEBhMCR1IxDzANBgNVBAcTBkF0aGVuczFEMEIGA1UEChM7SGVsbGVuaWMg -QWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9ucyBDZXJ0LiBBdXRob3JpdHkxQDA+BgNV -BAMTN0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgUm9vdENBIDIw -MTUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDC+Kk/G4n8PDwEXT2QNrCROnk8Zlrv -bTkBSRq0t89/TSNTt5AA4xMqKKYx8ZEA4yjsriFBzh/a/X0SWwGDD7mwX5nh8hKDgE0GPt+sr+eh -iGsxr/CL0BgzuNtFajT0AoAkKAoCFZVedioNmToUW/bLy1O8E00BiDeUJRtCvCLYjqOWXjrZMts+ -6PAQZe104S+nfK8nNLspfZu2zwnI5dMK/IhlZXQK3HMcXM1AsRzUtoSMTFDPaI6oWa7CJ06CojXd -FPQf/7J31Ycvqm59JCfnxssm5uX+Zwdj2EUN3TpZZTlYepKZcj2chF6IIbjV9Cz82XBST3i4vTwr -i5WY9bPRaM8gFH5MXF/ni+X1NYEZN9cRCLdmvtNKzoNXADrDgfgXy5I2XdGj2HUb4Ysn6npIQf1F -GQatJ5lOwXBH3bWfgVMS5bGMSF0xQxfjjMZ6Y5ZLKTBOhE5iGV48zpeQpX8B653g+IuJ3SWYPZK2 -fu/Z8VFRfS0myGlZYeCsargqNhEEelC9MoS+L9xy1dcdFkfkR2YgP/SWxa+OAXqlD3pk9Q0Yh9mu -iNX6hME6wGkoLfINaFGq46V3xqSQDqE3izEjR8EJCOtu93ib14L8hCCZSRm2Ekax+0VVFqmjZayc -Bw/qa9wfLgZy7IaIEuQt218FL+TwA9MmM+eAws1CoRc0CwIDAQABo0IwQDAPBgNVHRMBAf8EBTAD -AQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUcRVnyMjJvXVdctA4GGqd83EkVAswDQYJKoZI -hvcNAQELBQADggIBAHW7bVRLqhBYRjTyYtcWNl0IXtVsyIe9tC5G8jH4fOpCtZMWVdyhDBKg2mF+ -D1hYc2Ryx+hFjtyp8iY/xnmMsVMIM4GwVhO+5lFc2JsKT0ucVlMC6U/2DWDqTUJV6HwbISHTGzrM -d/K4kPFox/la/vot9L/J9UUbzjgQKjeKeaO04wlshYaT/4mWJ3iBj2fjRnRUjtkNaeJK9E10A/+y -d+2VZ5fkscWrv2oj6NSU4kQoYsRL4vDY4ilrGnB+JGGTe08DMiUNRSQrlrRGar9KC/eaj8GsGsVn -82800vpzY4zvFrCopEYq+OsS7HK07/grfoxSwIuEVPkvPuNVqNxmsdnhX9izjFk0WaSrT2y7Hxjb -davYy5LNlDhhDgcGH0tGEPEVvo2FXDtKK4F5D7Rpn0lQl033DlZdwJVqwjbDG2jJ9SrcR5q+ss7F -Jej6A7na+RZukYT1HCjI/CbM1xyQVqdfbzoEvM14iQuODy+jqk+iGxI9FghAD/FGTNeqewjBCvVt -J94Cj8rDtSvK6evIIVM4pcw72Hc3MKJP2W/R8kCtQXoXxdZKNYm3QdV8hn9VTYNKpXMgwDqvkPGa -JI7ZjnHKe7iG2rKPmT4dEw0SEe7Uq/DpFXYC5ODfqiAeW2GFZECpkJcNrVPSWh2HagCXZWK0vm9q -p/UsQu0yrbYhnr68 ------END CERTIFICATE----- - -Hellenic Academic and Research Institutions ECC RootCA 2015 -=========================================================== ------BEGIN CERTIFICATE----- -MIICwzCCAkqgAwIBAgIBADAKBggqhkjOPQQDAjCBqjELMAkGA1UEBhMCR1IxDzANBgNVBAcTBkF0 -aGVuczFEMEIGA1UEChM7SGVsbGVuaWMgQWNhZGVtaWMgYW5kIFJlc2VhcmNoIEluc3RpdHV0aW9u -cyBDZXJ0LiBBdXRob3JpdHkxRDBCBgNVBAMTO0hlbGxlbmljIEFjYWRlbWljIGFuZCBSZXNlYXJj -aCBJbnN0aXR1dGlvbnMgRUNDIFJvb3RDQSAyMDE1MB4XDTE1MDcwNzEwMzcxMloXDTQwMDYzMDEw -MzcxMlowgaoxCzAJBgNVBAYTAkdSMQ8wDQYDVQQHEwZBdGhlbnMxRDBCBgNVBAoTO0hlbGxlbmlj -IEFjYWRlbWljIGFuZCBSZXNlYXJjaCBJbnN0aXR1dGlvbnMgQ2VydC4gQXV0aG9yaXR5MUQwQgYD -VQQDEztIZWxsZW5pYyBBY2FkZW1pYyBhbmQgUmVzZWFyY2ggSW5zdGl0dXRpb25zIEVDQyBSb290 -Q0EgMjAxNTB2MBAGByqGSM49AgEGBSuBBAAiA2IABJKgQehLgoRc4vgxEZmGZE4JJS+dQS8KrjVP -dJWyUWRrjWvmP3CV8AVER6ZyOFB2lQJajq4onvktTpnvLEhvTCUp6NFxW98dwXU3tNf6e3pCnGoK -Vlp8aQuqgAkkbH7BRqNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0O -BBYEFLQiC4KZJAEOnLvkDv2/+5cgk5kqMAoGCCqGSM49BAMCA2cAMGQCMGfOFmI4oqxiRaeplSTA -GiecMjvAwNW6qef4BENThe5SId6d9SWDPp5YSy/XZxMOIQIwBeF1Ad5o7SofTUwJCA3sS61kFyjn -dc5FZXIhF8siQQ6ME5g4mlRtm8rifOoCWCKR ------END CERTIFICATE----- - -Certplus Root CA G1 -=================== ------BEGIN CERTIFICATE----- -MIIFazCCA1OgAwIBAgISESBVg+QtPlRWhS2DN7cs3EYRMA0GCSqGSIb3DQEBDQUAMD4xCzAJBgNV -BAYTAkZSMREwDwYDVQQKDAhDZXJ0cGx1czEcMBoGA1UEAwwTQ2VydHBsdXMgUm9vdCBDQSBHMTAe -Fw0xNDA1MjYwMDAwMDBaFw0zODAxMTUwMDAwMDBaMD4xCzAJBgNVBAYTAkZSMREwDwYDVQQKDAhD -ZXJ0cGx1czEcMBoGA1UEAwwTQ2VydHBsdXMgUm9vdCBDQSBHMTCCAiIwDQYJKoZIhvcNAQEBBQAD -ggIPADCCAgoCggIBANpQh7bauKk+nWT6VjOaVj0W5QOVsjQcmm1iBdTYj+eJZJ+622SLZOZ5KmHN -r49aiZFluVj8tANfkT8tEBXgfs+8/H9DZ6itXjYj2JizTfNDnjl8KvzsiNWI7nC9hRYt6kuJPKNx -Qv4c/dMcLRC4hlTqQ7jbxofaqK6AJc96Jh2qkbBIb6613p7Y1/oA/caP0FG7Yn2ksYyy/yARujVj -BYZHYEMzkPZHogNPlk2dT8Hq6pyi/jQu3rfKG3akt62f6ajUeD94/vI4CTYd0hYCyOwqaK/1jpTv -LRN6HkJKHRUxrgwEV/xhc/MxVoYxgKDEEW4wduOU8F8ExKyHcomYxZ3MVwia9Az8fXoFOvpHgDm2 -z4QTd28n6v+WZxcIbekN1iNQMLAVdBM+5S//Ds3EC0pd8NgAM0lm66EYfFkuPSi5YXHLtaW6uOrc -4nBvCGrch2c0798wct3zyT8j/zXhviEpIDCB5BmlIOklynMxdCm+4kLV87ImZsdo/Rmz5yCTmehd -4F6H50boJZwKKSTUzViGUkAksnsPmBIgJPaQbEfIDbsYIC7Z/fyL8inqh3SV4EJQeIQEQWGw9CEj -jy3LKCHyamz0GqbFFLQ3ZU+V/YDI+HLlJWvEYLF7bY5KinPOWftwenMGE9nTdDckQQoRb5fc5+R+ -ob0V8rqHDz1oihYHAgMBAAGjYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0G -A1UdDgQWBBSowcCbkahDFXxdBie0KlHYlwuBsTAfBgNVHSMEGDAWgBSowcCbkahDFXxdBie0KlHY -lwuBsTANBgkqhkiG9w0BAQ0FAAOCAgEAnFZvAX7RvUz1isbwJh/k4DgYzDLDKTudQSk0YcbX8ACh -66Ryj5QXvBMsdbRX7gp8CXrc1cqh0DQT+Hern+X+2B50ioUHj3/MeXrKls3N/U/7/SMNkPX0XtPG -YX2eEeAC7gkE2Qfdpoq3DIMku4NQkv5gdRE+2J2winq14J2by5BSS7CTKtQ+FjPlnsZlFT5kOwQ/ -2wyPX1wdaR+v8+khjPPvl/aatxm2hHSco1S1cE5j2FddUyGbQJJD+tZ3VTNPZNX70Cxqjm0lpu+F -6ALEUz65noe8zDUa3qHpimOHZR4RKttjd5cUvpoUmRGywO6wT/gUITJDT5+rosuoD6o7BlXGEilX -CNQ314cnrUlZp5GrRHpejXDbl85IULFzk/bwg2D5zfHhMf1bfHEhYxQUqq/F3pN+aLHsIqKqkHWe -tUNy6mSjhEv9DKgma3GX7lZjZuhCVPnHHd/Qj1vfyDBviP4NxDMcU6ij/UgQ8uQKTuEVV/xuZDDC -VRHc6qnNSlSsKWNEz0pAoNZoWRsz+e86i9sgktxChL8Bq4fA1SCC28a5g4VCXA9DO2pJNdWY9BW/ -+mGBDAkgGNLQFwzLSABQ6XaCjGTXOqAHVcweMcDvOrRl++O/QmueD6i9a5jc2NvLi6Td11n0bt3+ -qsOR0C5CB8AMTVPNJLFMWx5R9N/pkvo= ------END CERTIFICATE----- - -Certplus Root CA G2 -=================== ------BEGIN CERTIFICATE----- -MIICHDCCAaKgAwIBAgISESDZkc6uo+jF5//pAq/Pc7xVMAoGCCqGSM49BAMDMD4xCzAJBgNVBAYT -AkZSMREwDwYDVQQKDAhDZXJ0cGx1czEcMBoGA1UEAwwTQ2VydHBsdXMgUm9vdCBDQSBHMjAeFw0x -NDA1MjYwMDAwMDBaFw0zODAxMTUwMDAwMDBaMD4xCzAJBgNVBAYTAkZSMREwDwYDVQQKDAhDZXJ0 -cGx1czEcMBoGA1UEAwwTQ2VydHBsdXMgUm9vdCBDQSBHMjB2MBAGByqGSM49AgEGBSuBBAAiA2IA -BM0PW1aC3/BFGtat93nwHcmsltaeTpwftEIRyoa/bfuFo8XlGVzX7qY/aWfYeOKmycTbLXku54uN -Am8xIk0G42ByRZ0OQneezs/lf4WbGOT8zC5y0xaTTsqZY1yhBSpsBqNjMGEwDgYDVR0PAQH/BAQD -AgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFNqDYwJ5jtpMxjwjFNiPwyCrKGBZMB8GA1Ud -IwQYMBaAFNqDYwJ5jtpMxjwjFNiPwyCrKGBZMAoGCCqGSM49BAMDA2gAMGUCMHD+sAvZ94OX7PNV -HdTcswYO/jOYnYs5kGuUIe22113WTNchp+e/IQ8rzfcq3IUHnQIxAIYUFuXcsGXCwI4Un78kFmjl -vPl5adytRSv3tjFzzAalU5ORGpOucGpnutee5WEaXw== ------END CERTIFICATE----- - -OpenTrust Root CA G1 -==================== ------BEGIN CERTIFICATE----- -MIIFbzCCA1egAwIBAgISESCzkFU5fX82bWTCp59rY45nMA0GCSqGSIb3DQEBCwUAMEAxCzAJBgNV -BAYTAkZSMRIwEAYDVQQKDAlPcGVuVHJ1c3QxHTAbBgNVBAMMFE9wZW5UcnVzdCBSb290IENBIEcx -MB4XDTE0MDUyNjA4NDU1MFoXDTM4MDExNTAwMDAwMFowQDELMAkGA1UEBhMCRlIxEjAQBgNVBAoM -CU9wZW5UcnVzdDEdMBsGA1UEAwwUT3BlblRydXN0IFJvb3QgQ0EgRzEwggIiMA0GCSqGSIb3DQEB -AQUAA4ICDwAwggIKAoICAQD4eUbalsUwXopxAy1wpLuwxQjczeY1wICkES3d5oeuXT2R0odsN7fa -Yp6bwiTXj/HbpqbfRm9RpnHLPhsxZ2L3EVs0J9V5ToybWL0iEA1cJwzdMOWo010hOHQX/uMftk87 -ay3bfWAfjH1MBcLrARYVmBSO0ZB3Ij/swjm4eTrwSSTilZHcYTSSjFR077F9jAHiOH3BX2pfJLKO -YheteSCtqx234LSWSE9mQxAGFiQD4eCcjsZGT44ameGPuY4zbGneWK2gDqdkVBFpRGZPTBKnjix9 -xNRbxQA0MMHZmf4yzgeEtE7NCv82TWLxp2NX5Ntqp66/K7nJ5rInieV+mhxNaMbBGN4zK1FGSxyO -9z0M+Yo0FMT7MzUj8czxKselu7Cizv5Ta01BG2Yospb6p64KTrk5M0ScdMGTHPjgniQlQ/GbI4Kq -3ywgsNw2TgOzfALU5nsaqocTvz6hdLubDuHAk5/XpGbKuxs74zD0M1mKB3IDVedzagMxbm+WG+Oi -n6+Sx+31QrclTDsTBM8clq8cIqPQqwWyTBIjUtz9GVsnnB47ev1CI9sjgBPwvFEVVJSmdz7QdFG9 -URQIOTfLHzSpMJ1ShC5VkLG631UAC9hWLbFJSXKAqWLXwPYYEQRVzXR7z2FwefR7LFxckvzluFqr -TJOVoSfupb7PcSNCupt2LQIDAQABo2MwYTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB -/zAdBgNVHQ4EFgQUl0YhVyE12jZVx/PxN3DlCPaTKbYwHwYDVR0jBBgwFoAUl0YhVyE12jZVx/Px -N3DlCPaTKbYwDQYJKoZIhvcNAQELBQADggIBAB3dAmB84DWn5ph76kTOZ0BP8pNuZtQ5iSas000E -PLuHIT839HEl2ku6q5aCgZG27dmxpGWX4m9kWaSW7mDKHyP7Rbr/jyTwyqkxf3kfgLMtMrpkZ2Cv -uVnN35pJ06iCsfmYlIrM4LvgBBuZYLFGZdwIorJGnkSI6pN+VxbSFXJfLkur1J1juONI5f6ELlgK -n0Md/rcYkoZDSw6cMoYsYPXpSOqV7XAp8dUv/TW0V8/bhUiZucJvbI/NeJWsZCj9VrDDb8O+WVLh -X4SPgPL0DTatdrOjteFkdjpY3H1PXlZs5VVZV6Xf8YpmMIzUUmI4d7S+KNfKNsSbBfD4Fdvb8e80 -nR14SohWZ25g/4/Ii+GOvUKpMwpZQhISKvqxnUOOBZuZ2mKtVzazHbYNeS2WuOvyDEsMpZTGMKcm -GS3tTAZQMPH9WD25SxdfGbRqhFS0OE85og2WaMMolP3tLR9Ka0OWLpABEPs4poEL0L9109S5zvE/ -bw4cHjdx5RiHdRk/ULlepEU0rbDK5uUTdg8xFKmOLZTW1YVNcxVPS/KyPu1svf0OnWZzsD2097+o -4BGkxK51CUpjAEggpsadCwmKtODmzj7HPiY46SvepghJAwSQiumPv+i2tCqjI40cHLI5kqiPAlxA -OXXUc0ECd97N4EOH1uS6SsNsEn/+KuYj1oxx ------END CERTIFICATE----- - -OpenTrust Root CA G2 -==================== ------BEGIN CERTIFICATE----- -MIIFbzCCA1egAwIBAgISESChaRu/vbm9UpaPI+hIvyYRMA0GCSqGSIb3DQEBDQUAMEAxCzAJBgNV -BAYTAkZSMRIwEAYDVQQKDAlPcGVuVHJ1c3QxHTAbBgNVBAMMFE9wZW5UcnVzdCBSb290IENBIEcy -MB4XDTE0MDUyNjAwMDAwMFoXDTM4MDExNTAwMDAwMFowQDELMAkGA1UEBhMCRlIxEjAQBgNVBAoM -CU9wZW5UcnVzdDEdMBsGA1UEAwwUT3BlblRydXN0IFJvb3QgQ0EgRzIwggIiMA0GCSqGSIb3DQEB -AQUAA4ICDwAwggIKAoICAQDMtlelM5QQgTJT32F+D3Y5z1zCU3UdSXqWON2ic2rxb95eolq5cSG+ -Ntmh/LzubKh8NBpxGuga2F8ORAbtp+Dz0mEL4DKiltE48MLaARf85KxP6O6JHnSrT78eCbY2albz -4e6WiWYkBuTNQjpK3eCasMSCRbP+yatcfD7J6xcvDH1urqWPyKwlCm/61UWY0jUJ9gNDlP7ZvyCV -eYCYitmJNbtRG6Q3ffyZO6v/v6wNj0OxmXsWEH4db0fEFY8ElggGQgT4hNYdvJGmQr5J1WqIP7wt -UdGejeBSzFfdNTVY27SPJIjki9/ca1TSgSuyzpJLHB9G+h3Ykst2Z7UJmQnlrBcUVXDGPKBWCgOz -3GIZ38i1MH/1PCZ1Eb3XG7OHngevZXHloM8apwkQHZOJZlvoPGIytbU6bumFAYueQ4xncyhZW+vj -3CzMpSZyYhK05pyDRPZRpOLAeiRXyg6lPzq1O4vldu5w5pLeFlwoW5cZJ5L+epJUzpM5ChaHvGOz -9bGTXOBut9Dq+WIyiET7vycotjCVXRIouZW+j1MY5aIYFuJWpLIsEPUdN6b4t/bQWVyJ98LVtZR0 -0dX+G7bw5tYee9I8y6jj9RjzIR9u701oBnstXW5DiabA+aC/gh7PU3+06yzbXfZqfUAkBXKJOAGT -y3HCOV0GEfZvePg3DTmEJwIDAQABo2MwYTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB -/zAdBgNVHQ4EFgQUajn6QiL35okATV59M4PLuG53hq8wHwYDVR0jBBgwFoAUajn6QiL35okATV59 -M4PLuG53hq8wDQYJKoZIhvcNAQENBQADggIBAJjLq0A85TMCl38th6aP1F5Kr7ge57tx+4BkJamz -Gj5oXScmp7oq4fBXgwpkTx4idBvpkF/wrM//T2h6OKQQbA2xx6R3gBi2oihEdqc0nXGEL8pZ0keI -mUEiyTCYYW49qKgFbdEfwFFEVn8nNQLdXpgKQuswv42hm1GqO+qTRmTFAHneIWv2V6CG1wZy7HBG -S4tz3aAhdT7cHcCP009zHIXZ/n9iyJVvttN7jLpTwm+bREx50B1ws9efAvSyB7DH5fitIw6mVskp -EndI2S9G/Tvw/HRwkqWOOAgfZDC2t0v7NqwQjqBSM2OdAzVWxWm9xiNaJ5T2pBL4LTM8oValX9YZ -6e18CL13zSdkzJTaTkZQh+D5wVOAHrut+0dSixv9ovneDiK3PTNZbNTe9ZUGMg1RGUFcPk8G97kr -gCf2o6p6fAbhQ8MTOWIaNr3gKC6UAuQpLmBVrkA9sHSSXvAgZJY/X0VdiLWK2gKgW0VU3jg9CcCo -SmVGFvyqv1ROTVu+OEO3KMqLM6oaJbolXCkvW0pujOotnCr2BXbgd5eAiN1nE28daCSLT7d0geX0 -YJ96Vdc+N9oWaz53rK4YcJUIeSkDiv7BO7M/Gg+kO14fWKGVyasvc0rQLW6aWQ9VGHgtPFGml4vm -u7JwqkwR3v98KzfUetF3NI/n+UL3PIEMS1IK ------END CERTIFICATE----- - -OpenTrust Root CA G3 -==================== ------BEGIN CERTIFICATE----- -MIICITCCAaagAwIBAgISESDm+Ez8JLC+BUCs2oMbNGA/MAoGCCqGSM49BAMDMEAxCzAJBgNVBAYT -AkZSMRIwEAYDVQQKDAlPcGVuVHJ1c3QxHTAbBgNVBAMMFE9wZW5UcnVzdCBSb290IENBIEczMB4X -DTE0MDUyNjAwMDAwMFoXDTM4MDExNTAwMDAwMFowQDELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCU9w -ZW5UcnVzdDEdMBsGA1UEAwwUT3BlblRydXN0IFJvb3QgQ0EgRzMwdjAQBgcqhkjOPQIBBgUrgQQA -IgNiAARK7liuTcpm3gY6oxH84Bjwbhy6LTAMidnW7ptzg6kjFYwvWYpa3RTqnVkrQ7cG7DK2uu5B -ta1doYXM6h0UZqNnfkbilPPntlahFVmhTzeXuSIevRHr9LIfXsMUmuXZl5mjYzBhMA4GA1UdDwEB -/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRHd8MUi2I5DMlv4VBN0BBY3JWIbTAf -BgNVHSMEGDAWgBRHd8MUi2I5DMlv4VBN0BBY3JWIbTAKBggqhkjOPQQDAwNpADBmAjEAj6jcnboM -BBf6Fek9LykBl7+BFjNAk2z8+e2AcG+qj9uEwov1NcoG3GRvaBbhj5G5AjEA2Euly8LQCGzpGPta -3U1fJAuwACEl74+nBCZx4nxp5V2a+EEfOzmTk51V6s2N8fvB ------END CERTIFICATE----- - -ISRG Root X1 -============ ------BEGIN CERTIFICATE----- -MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkGA1UE -BhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUwEwYDVQQD -EwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQG -EwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMT -DElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54r -Vygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+0TM8ukj1 -3Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6UA5/TR5d8mUgjU+g4rk8K -b4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sWT8KOEUt+zwvo/7V3LvSye0rgTBIlDHCN -Aymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ -4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf -1b0SHzUvKBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFu -hjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQH -usEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/r -OPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4G -A1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY -9umbbjANBgkqhkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL -ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV -0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwt -hDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJw -TdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nx -e5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZA -JzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq4RgqsahD -YVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPAmRGunUHBcnWEvgJBQl9n -JEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcYxn/eR44/KJ4EBs+lVDR3veyJ -m+kXQ99b21/+jh5Xos1AnX5iItreGCc= ------END CERTIFICATE----- - -AC RAIZ FNMT-RCM -================ ------BEGIN CERTIFICATE----- -MIIFgzCCA2ugAwIBAgIPXZONMGc2yAYdGsdUhGkHMA0GCSqGSIb3DQEBCwUAMDsxCzAJBgNVBAYT -AkVTMREwDwYDVQQKDAhGTk1ULVJDTTEZMBcGA1UECwwQQUMgUkFJWiBGTk1ULVJDTTAeFw0wODEw -MjkxNTU5NTZaFw0zMDAxMDEwMDAwMDBaMDsxCzAJBgNVBAYTAkVTMREwDwYDVQQKDAhGTk1ULVJD -TTEZMBcGA1UECwwQQUMgUkFJWiBGTk1ULVJDTTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC -ggIBALpxgHpMhm5/yBNtwMZ9HACXjywMI7sQmkCpGreHiPibVmr75nuOi5KOpyVdWRHbNi63URcf -qQgfBBckWKo3Shjf5TnUV/3XwSyRAZHiItQDwFj8d0fsjz50Q7qsNI1NOHZnjrDIbzAzWHFctPVr -btQBULgTfmxKo0nRIBnuvMApGGWn3v7v3QqQIecaZ5JCEJhfTzC8PhxFtBDXaEAUwED653cXeuYL -j2VbPNmaUtu1vZ5Gzz3rkQUCwJaydkxNEJY7kvqcfw+Z374jNUUeAlz+taibmSXaXvMiwzn15Cou -08YfxGyqxRxqAQVKL9LFwag0Jl1mpdICIfkYtwb1TplvqKtMUejPUBjFd8g5CSxJkjKZqLsXF3mw -WsXmo8RZZUc1g16p6DULmbvkzSDGm0oGObVo/CK67lWMK07q87Hj/LaZmtVC+nFNCM+HHmpxffnT -tOmlcYF7wk5HlqX2doWjKI/pgG6BU6VtX7hI+cL5NqYuSf+4lsKMB7ObiFj86xsc3i1w4peSMKGJ -47xVqCfWS+2QrYv6YyVZLag13cqXM7zlzced0ezvXg5KkAYmY6252TUtB7p2ZSysV4999AeU14EC -ll2jB0nVetBX+RvnU0Z1qrB5QstocQjpYL05ac70r8NWQMetUqIJ5G+GR4of6ygnXYMgrwTJbFaa -i0b1AgMBAAGjgYMwgYAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE -FPd9xf3E6Jobd2Sn9R2gzL+HYJptMD4GA1UdIAQ3MDUwMwYEVR0gADArMCkGCCsGAQUFBwIBFh1o -dHRwOi8vd3d3LmNlcnQuZm5tdC5lcy9kcGNzLzANBgkqhkiG9w0BAQsFAAOCAgEAB5BK3/MjTvDD -nFFlm5wioooMhfNzKWtN/gHiqQxjAb8EZ6WdmF/9ARP67Jpi6Yb+tmLSbkyU+8B1RXxlDPiyN8+s -D8+Nb/kZ94/sHvJwnvDKuO+3/3Y3dlv2bojzr2IyIpMNOmqOFGYMLVN0V2Ue1bLdI4E7pWYjJ2cJ -j+F3qkPNZVEI7VFY/uY5+ctHhKQV8Xa7pO6kO8Rf77IzlhEYt8llvhjho6Tc+hj507wTmzl6NLrT -Qfv6MooqtyuGC2mDOL7Nii4LcK2NJpLuHvUBKwrZ1pebbuCoGRw6IYsMHkCtA+fdZn71uSANA+iW -+YJF1DngoABd15jmfZ5nc8OaKveri6E6FO80vFIOiZiaBECEHX5FaZNXzuvO+FB8TxxuBEOb+dY7 -Ixjp6o7RTUaN8Tvkasq6+yO3m/qZASlaWFot4/nUbQ4mrcFuNLwy+AwF+mWj2zs3gyLp1txyM/1d -8iC9djwj2ij3+RvrWWTV3F9yfiD8zYm1kGdNYno/Tq0dwzn+evQoFt9B9kiABdcPUXmsEKvU7ANm -5mqwujGSQkBqvjrTcuFqN1W8rB2Vt2lh8kORdOag0wokRqEIr9baRRmW1FMdW4R58MD3R++Lj8UG -rp1MYp3/RgT408m2ECVAdf4WqslKYIYvuu8wd+RU4riEmViAqhOLUTpPSPaLtrM= ------END CERTIFICATE----- - -Amazon Root CA 1 -================ ------BEGIN CERTIFICATE----- -MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsFADA5MQswCQYD -VQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAxMB4XDTE1 -MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpv -bjEZMBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC -ggEBALJ4gHHKeNXjca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgH -FzZM9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qwIFAGbHrQ -gLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6VOujw5H5SNz/0egwLX0t -dHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L93FcXmn/6pUCyziKrlA4b9v7LWIbxcce -VOF34GfID5yHI9Y/QCB/IIDEgEw+OyQmjgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB -/zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3 -DQEBCwUAA4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDIU5PM -CCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUsN+gDS63pYaACbvXy -8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vvo/ufQJVtMVT8QtPHRh8jrdkPSHCa -2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2 -xJNDd2ZhwLnoQdeXeGADbkpyrqXRfboQnoZsG4q5WTP468SQvvG5 ------END CERTIFICATE----- - -Amazon Root CA 2 -================ ------BEGIN CERTIFICATE----- -MIIFQTCCAymgAwIBAgITBmyf0pY1hp8KD+WGePhbJruKNzANBgkqhkiG9w0BAQwFADA5MQswCQYD -VQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAyMB4XDTE1 -MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpv -bjEZMBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC -ggIBAK2Wny2cSkxKgXlRmeyKy2tgURO8TW0G/LAIjd0ZEGrHJgw12MBvIITplLGbhQPDW9tK6Mj4 -kHbZW0/jTOgGNk3Mmqw9DJArktQGGWCsN0R5hYGCrVo34A3MnaZMUnbqQ523BNFQ9lXg1dKmSYXp -N+nKfq5clU1Imj+uIFptiJXZNLhSGkOQsL9sBbm2eLfq0OQ6PBJTYv9K8nu+NQWpEjTj82R0Yiw9 -AElaKP4yRLuH3WUnAnE72kr3H9rN9yFVkE8P7K6C4Z9r2UXTu/Bfh+08LDmG2j/e7HJV63mjrdvd -fLC6HM783k81ds8P+HgfajZRRidhW+mez/CiVX18JYpvL7TFz4QuK/0NURBs+18bvBt+xa47mAEx -kv8LV/SasrlX6avvDXbR8O70zoan4G7ptGmh32n2M8ZpLpcTnqWHsFcQgTfJU7O7f/aS0ZzQGPSS -btqDT6ZjmUyl+17vIWR6IF9sZIUVyzfpYgwLKhbcAS4y2j5L9Z469hdAlO+ekQiG+r5jqFoz7Mt0 -Q5X5bGlSNscpb/xVA1wf+5+9R+vnSUeVC06JIglJ4PVhHvG/LopyboBZ/1c6+XUyo05f7O0oYtlN -c/LMgRdg7c3r3NunysV+Ar3yVAhU/bQtCSwXVEqY0VThUWcI0u1ufm8/0i2BWSlmy5A5lREedCf+ -3euvAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSw -DPBMMPQFWAJI/TPlUq9LhONmUjANBgkqhkiG9w0BAQwFAAOCAgEAqqiAjw54o+Ci1M3m9Zh6O+oA -A7CXDpO8Wqj2LIxyh6mx/H9z/WNxeKWHWc8w4Q0QshNabYL1auaAn6AFC2jkR2vHat+2/XcycuUY -+gn0oJMsXdKMdYV2ZZAMA3m3MSNjrXiDCYZohMr/+c8mmpJ5581LxedhpxfL86kSk5Nrp+gvU5LE -YFiwzAJRGFuFjWJZY7attN6a+yb3ACfAXVU3dJnJUH/jWS5E4ywl7uxMMne0nxrpS10gxdr9HIcW -xkPo1LsmmkVwXqkLN1PiRnsn/eBG8om3zEK2yygmbtmlyTrIQRNg91CMFa6ybRoVGld45pIq2WWQ -gj9sAq+uEjonljYE1x2igGOpm/HlurR8FLBOybEfdF849lHqm/osohHUqS0nGkWxr7JOcQ3AWEbW -aQbLU8uz/mtBzUF+fUwPfHJ5elnNXkoOrJupmHN5fLT0zLm4BwyydFy4x2+IoZCn9Kr5v2c69BoV -Yh63n749sSmvZ6ES8lgQGVMDMBu4Gon2nL2XA46jCfMdiyHxtN/kHNGfZQIG6lzWE7OE76KlXIx3 -KadowGuuQNKotOrN8I1LOJwZmhsoVLiJkO/KdYE+HvJkJMcYr07/R54H9jVlpNMKVv/1F2Rs76gi -JUmTtt8AF9pYfl3uxRuw0dFfIRDH+fO6AgonB8Xx1sfT4PsJYGw= ------END CERTIFICATE----- - -Amazon Root CA 3 -================ ------BEGIN CERTIFICATE----- -MIIBtjCCAVugAwIBAgITBmyf1XSXNmY/Owua2eiedgPySjAKBggqhkjOPQQDAjA5MQswCQYDVQQG -EwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSAzMB4XDTE1MDUy -NjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZ -MBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgMzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCmXp8ZB -f8ANm+gBG1bG8lKlui2yEujSLtf6ycXYqm0fc4E7O5hrOXwzpcVOho6AF2hiRVd9RFgdszflZwjr -Zt6jQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSrttvXBp43 -rDCGB5Fwx5zEGbF4wDAKBggqhkjOPQQDAgNJADBGAiEA4IWSoxe3jfkrBqWTrBqYaGFy+uGh0Psc -eGCmQ5nFuMQCIQCcAu/xlJyzlvnrxir4tiz+OpAUFteMYyRIHN8wfdVoOw== ------END CERTIFICATE----- - -Amazon Root CA 4 -================ ------BEGIN CERTIFICATE----- -MIIB8jCCAXigAwIBAgITBmyf18G7EEwpQ+Vxe3ssyBrBDjAKBggqhkjOPQQDAzA5MQswCQYDVQQG -EwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24gUm9vdCBDQSA0MB4XDTE1MDUy -NjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZ -MBcGA1UEAxMQQW1hem9uIFJvb3QgQ0EgNDB2MBAGByqGSM49AgEGBSuBBAAiA2IABNKrijdPo1MN -/sGKe0uoe0ZLY7Bi9i0b2whxIdIA6GO9mif78DluXeo9pcmBqqNbIJhFXRbb/egQbeOc4OO9X4Ri -83BkM6DLJC9wuoihKqB1+IGuYgbEgds5bimwHvouXKNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNV -HQ8BAf8EBAMCAYYwHQYDVR0OBBYEFNPsxzplbszh2naaVvuc84ZtV+WBMAoGCCqGSM49BAMDA2gA -MGUCMDqLIfG9fhGt0O9Yli/W651+kI0rz2ZVwyzjKKlwCkcO8DdZEv8tmZQoTipPNU0zWgIxAOp1 -AE47xDqUEpHJWEadIRNyp4iciuRMStuW1KyLa2tJElMzrdfkviT8tQp21KW8EA== ------END CERTIFICATE----- - -LuxTrust Global Root 2 -====================== ------BEGIN CERTIFICATE----- -MIIFwzCCA6ugAwIBAgIUCn6m30tEntpqJIWe5rgV0xZ/u7EwDQYJKoZIhvcNAQELBQAwRjELMAkG -A1UEBhMCTFUxFjAUBgNVBAoMDUx1eFRydXN0IFMuQS4xHzAdBgNVBAMMFkx1eFRydXN0IEdsb2Jh -bCBSb290IDIwHhcNMTUwMzA1MTMyMTU3WhcNMzUwMzA1MTMyMTU3WjBGMQswCQYDVQQGEwJMVTEW -MBQGA1UECgwNTHV4VHJ1c3QgUy5BLjEfMB0GA1UEAwwWTHV4VHJ1c3QgR2xvYmFsIFJvb3QgMjCC -AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANeFl78RmOnwYoNMPIf5U2o3C/IPPIfOb9wm -Kb3FibrJgz337spbxm1Jc7TJRqMbNBM/wYlFV/TZsfs2ZUv7COJIcRHIbjuend+JZTemhfY7RBi2 -xjcwYkSSl2l9QjAk5A0MiWtj3sXh306pFGxT4GHO9hcvHTy95iJMHZP1EMShduxq3sVs35a0VkBC -wGKSMKEtFZSg0iAGCW5qbeXrt77U8PEVfIvmTroTzEsnXpk8F12PgX8zPU/TPxvsXD/wPEx1bvKm -1Z3aLQdjAsZy6ZS8TEmVT4hSyNvoaYL4zDRbIvCGp4m9SAptZoFtyMhk+wHh9OHe2Z7d21vUKpkm -FRseTJIpgp7VkoGSQXAZ96Tlk0u8d2cx3Rz9MXANF5kM+Qw5GSoXtTBxVdUPrljhPS80m8+f9niF -wpN6cj5mj5wWEWCPnolvZ77gR1o7DJpni89Gxq44o/KnvObWhWszJHAiS8sIm7vI+AIpHb4gDEa/ -a4ebsypmQjVGbKq6rfmYe+lQVRQxv7HaLe2ArWgk+2mr2HETMOZns4dA/Yl+8kPREd8vZS9kzl8U -ubG/Mb2HeFpZZYiq/FkySIbWTLkpS5XTdvN3JW1CHDiDTf2jX5t/Lax5Gw5CMZdjpPuKadUiDTSQ -MC6otOBttpSsvItO13D8xTiOZCXhTTmQzsmHhFhxAgMBAAGjgagwgaUwDwYDVR0TAQH/BAUwAwEB -/zBCBgNVHSAEOzA5MDcGByuBKwEBAQowLDAqBggrBgEFBQcCARYeaHR0cHM6Ly9yZXBvc2l0b3J5 -Lmx1eHRydXN0Lmx1MA4GA1UdDwEB/wQEAwIBBjAfBgNVHSMEGDAWgBT/GCh2+UgFLKGu8SsbK7JT -+Et8szAdBgNVHQ4EFgQU/xgodvlIBSyhrvErGyuyU/hLfLMwDQYJKoZIhvcNAQELBQADggIBAGoZ -FO1uecEsh9QNcH7X9njJCwROxLHOk3D+sFTAMs2ZMGQXvw/l4jP9BzZAcg4atmpZ1gDlaCDdLnIN -H2pkMSCEfUmmWjfrRcmF9dTHF5kH5ptV5AzoqbTOjFu1EVzPig4N1qx3gf4ynCSecs5U89BvolbW -7MM3LGVYvlcAGvI1+ut7MV3CwRI9loGIlonBWVx65n9wNOeD4rHh4bhY79SV5GCc8JaXcozrhAIu -ZY+kt9J/Z93I055cqqmkoCUUBpvsT34tC38ddfEz2O3OuHVtPlu5mB0xDVbYQw8wkbIEa91WvpWA -VWe+2M2D2RjuLg+GLZKecBPs3lHJQ3gCpU3I+V/EkVhGFndadKpAvAefMLmx9xIX3eP/JEAdemrR -TxgKqpAd60Ae36EeRJIQmvKN4dFLRp7oRUKX6kWZ8+xm1QL68qZKJKrezrnK+T+Tb/mjuuqlPpmt -/f97mfVl7vBZKGfXkJWkE4SphMHozs51k2MavDzq1WQfLSoSOcbDWjLtR5EWDrw4wVDej8oqkDQc -7kGUnF4ZLvhFSZl0kbAEb+MEWrGrKqv+x9CWttrhSmQGbmBNvUJO/3jaJMobtNeWOWyu8Q6qp31I -iyBMz2TWuJdGsE7RKlY6oJO9r4Ak4Ap+58rVyuiFVdw2KuGUaJPHZnJED4AhMmwlxyOAgwrr ------END CERTIFICATE----- - -TUBITAK Kamu SM SSL Kok Sertifikasi - Surum 1 -============================================= ------BEGIN CERTIFICATE----- -MIIEYzCCA0ugAwIBAgIBATANBgkqhkiG9w0BAQsFADCB0jELMAkGA1UEBhMCVFIxGDAWBgNVBAcT -D0dlYnplIC0gS29jYWVsaTFCMEAGA1UEChM5VHVya2l5ZSBCaWxpbXNlbCB2ZSBUZWtub2xvamlr -IEFyYXN0aXJtYSBLdXJ1bXUgLSBUVUJJVEFLMS0wKwYDVQQLEyRLYW11IFNlcnRpZmlrYXN5b24g -TWVya2V6aSAtIEthbXUgU00xNjA0BgNVBAMTLVRVQklUQUsgS2FtdSBTTSBTU0wgS29rIFNlcnRp -ZmlrYXNpIC0gU3VydW0gMTAeFw0xMzExMjUwODI1NTVaFw00MzEwMjUwODI1NTVaMIHSMQswCQYD -VQQGEwJUUjEYMBYGA1UEBxMPR2ViemUgLSBLb2NhZWxpMUIwQAYDVQQKEzlUdXJraXllIEJpbGlt -c2VsIHZlIFRla25vbG9qaWsgQXJhc3Rpcm1hIEt1cnVtdSAtIFRVQklUQUsxLTArBgNVBAsTJEth -bXUgU2VydGlmaWthc3lvbiBNZXJrZXppIC0gS2FtdSBTTTE2MDQGA1UEAxMtVFVCSVRBSyBLYW11 -IFNNIFNTTCBLb2sgU2VydGlmaWthc2kgLSBTdXJ1bSAxMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A -MIIBCgKCAQEAr3UwM6q7a9OZLBI3hNmNe5eA027n/5tQlT6QlVZC1xl8JoSNkvoBHToP4mQ4t4y8 -6Ij5iySrLqP1N+RAjhgleYN1Hzv/bKjFxlb4tO2KRKOrbEz8HdDc72i9z+SqzvBV96I01INrN3wc -wv61A+xXzry0tcXtAA9TNypN9E8Mg/uGz8v+jE69h/mniyFXnHrfA2eJLJ2XYacQuFWQfw4tJzh0 -3+f92k4S400VIgLI4OD8D62K18lUUMw7D8oWgITQUVbDjlZ/iSIzL+aFCr2lqBs23tPcLG07xxO9 -WSMs5uWk99gL7eqQQESolbuT1dCANLZGeA4fAJNG4e7p+exPFwIDAQABo0IwQDAdBgNVHQ4EFgQU -ZT/HiobGPN08VFw1+DrtUgxHV8gwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJ -KoZIhvcNAQELBQADggEBACo/4fEyjq7hmFxLXs9rHmoJ0iKpEsdeV31zVmSAhHqT5Am5EM2fKifh -AHe+SMg1qIGf5LgsyX8OsNJLN13qudULXjS99HMpw+0mFZx+CFOKWI3QSyjfwbPfIPP54+M638yc -lNhOT8NrF7f3cuitZjO1JVOr4PhMqZ398g26rrnZqsZr+ZO7rqu4lzwDGrpDxpa5RXI4s6ehlj2R -e37AIVNMh+3yC1SVUZPVIqUNivGTDj5UDrDYyU7c8jEyVupk+eq1nRZmQnLzf9OxMUP8pI4X8W0j -q5Rm+K37DwhuJi1/FwcJsoz7UMCflo3Ptv0AnVoUmr8CRPXBwp8iXqIPoeM= ------END CERTIFICATE----- diff --git a/config/config.php b/config/config.php index a7b77b1ad32..4a989ffdb79 100644 --- a/config/config.php +++ b/config/config.php @@ -14,7 +14,8 @@ * @since 1.1.11 * @license https://opensource.org/licenses/mit-license.php MIT License */ -$versionFile = file(CORE_PATH . 'VERSION.txt'); +$versionFile = file(dirname(__DIR__) . '/VERSION.txt'); + return [ - 'Cake.version' => trim(array_pop($versionFile)) + 'Cake.version' => trim(array_pop($versionFile)), ]; diff --git a/contrib/git-filter-repo b/contrib/git-filter-repo new file mode 100644 index 00000000000..fb3de42e428 --- /dev/null +++ b/contrib/git-filter-repo @@ -0,0 +1,4989 @@ +#!/usr/bin/env python3 + +""" +git-filter-repo filters git repositories, similar to git filter-branch, BFG +repo cleaner, and others. The basic idea is that it works by running + git fast-export | filter | git fast-import +where this program not only launches the whole pipeline but also serves as +the 'filter' in the middle. It does a few additional things on top as well +in order to make it into a well-rounded filtering tool. + +git-filter-repo can also be used as a library for more involved filtering +operations; however: + ***** API BACKWARD COMPATIBILITY CAVEAT ***** + Programs using git-filter-repo as a library can reach pretty far into its + internals, but I am not prepared to guarantee backward compatibility of + all APIs. I suspect changes will be rare, but I reserve the right to + change any API. Since it is assumed that repository filtering is + something one would do very rarely, and in particular that it's a + one-shot operation, this should not be a problem in practice for anyone. + However, if you want to re-use a program you have written that uses + git-filter-repo as a library (or makes use of one of its --*-callback + arguments), you should either make sure you are using the same version of + git and git-filter-repo, or make sure to re-test it. + + If there are particular pieces of the API you are concerned about, and + there is not already a testcase for it in t9391-lib-usage.sh or + t9392-python-callback.sh, please contribute a testcase. That will not + prevent me from changing the API, but it will allow you to look at the + history of a testcase to see whether and how the API changed. + ***** END API BACKWARD COMPATIBILITY CAVEAT ***** +""" + +import argparse +import collections +import fnmatch +import gettext +import io +import os +import platform +import re +import shutil +import subprocess +import sys +import time +import textwrap + +from datetime import tzinfo, timedelta, datetime + +__all__ = ["Blob", "Reset", "FileChange", "Commit", "Tag", "Progress", + "Checkpoint", "FastExportParser", "ProgressWriter", + "string_to_date", "date_to_string", + "record_id_rename", "GitUtils", "FilteringOptions", "RepoFilter"] + +# The globals to make visible to callbacks. They will see all our imports for +# free, as well as our public API. +public_globals = ["__builtins__", "argparse", "collections", "fnmatch", + "gettext", "io", "os", "platform", "re", "shutil", + "subprocess", "sys", "time", "textwrap", "tzinfo", + "timedelta", "datetime"] + __all__ + +deleted_hash = b'0'*40 +write_marks = True +date_format_permissive = True + +def gettext_poison(msg): + if "GIT_TEST_GETTEXT_POISON" in os.environ: # pragma: no cover + return "# GETTEXT POISON #" + return gettext.gettext(msg) + +_ = gettext_poison + +def setup_gettext(): + TEXTDOMAIN="git-filter-repo" + podir = os.environ.get("GIT_TEXTDOMAINDIR") or "@@LOCALEDIR@@" + if not os.path.isdir(podir): # pragma: no cover + podir = None # Python has its own fallback; use that + + ## This looks like the most straightforward translation of the relevant + ## code in git.git:gettext.c and git.git:perl/Git/I18n.pm: + #import locale + #locale.setlocale(locale.LC_MESSAGES, ""); + #locale.setlocale(locale.LC_TIME, ""); + #locale.textdomain(TEXTDOMAIN); + #locale.bindtextdomain(TEXTDOMAIN, podir); + ## but the python docs suggest using the gettext module (which doesn't + ## have setlocale()) instead, so: + gettext.textdomain(TEXTDOMAIN); + gettext.bindtextdomain(TEXTDOMAIN, podir); + +def _timedelta_to_seconds(delta): + """ + Converts timedelta to seconds + """ + offset = delta.days*86400 + delta.seconds + (delta.microseconds+0.0)/1000000 + return round(offset) + +class FixedTimeZone(tzinfo): + """ + Fixed offset in minutes east from UTC. + """ + + tz_re = re.compile(br'^([-+]?)(\d\d)(\d\d)$') + + def __init__(self, offset_string): + tzinfo.__init__(self) + sign, hh, mm = FixedTimeZone.tz_re.match(offset_string).groups() + factor = -1 if (sign and sign == b'-') else 1 + self._offset = timedelta(minutes = factor*(60*int(hh) + int(mm))) + self._offset_string = offset_string + + def utcoffset(self, dt): + return self._offset + + def tzname(self, dt): + return self._offset_string + + def dst(self, dt): + return timedelta(0) + +def string_to_date(datestring): + (unix_timestamp, tz_offset) = datestring.split() + return datetime.fromtimestamp(int(unix_timestamp), + FixedTimeZone(tz_offset)) + +def date_to_string(dateobj): + epoch = datetime.fromtimestamp(0, dateobj.tzinfo) + return(b'%d %s' % (int(_timedelta_to_seconds(dateobj - epoch)), + dateobj.tzinfo.tzname(0))) + +def decode(bytestr): + 'Try to convert bytestr to utf-8 for outputting as an error message.' + return bytestr.decode('utf-8', 'backslashreplace') + +def glob_to_regex(glob_bytestr): + 'Translate glob_bytestr into a regex on bytestrings' + + # fnmatch.translate is idiotic and won't accept bytestrings + if (decode(glob_bytestr).encode() != glob_bytestr): # pragma: no cover + raise SystemExit(_("Error: Cannot handle glob %s").format(glob_bytestr)) + + # Create regex operating on string + regex = fnmatch.translate(decode(glob_bytestr)) + + # FIXME: This is an ugly hack... + # fnmatch.translate tries to do multi-line matching and wants the glob to + # match up to the end of the input, which isn't relevant for us, so we + # have to modify the regex. fnmatch.translate has used different regex + # constructs to achieve this with different python versions, so we have + # to check for each of them and then fix it up. It would be much better + # if fnmatch.translate could just take some flags to allow us to specify + # what we want rather than employing this hackery, but since it + # doesn't... + if regex.endswith(r'\Z(?ms)'): # pragma: no cover + regex = regex[0:-7] + elif regex.startswith(r'(?s:') and regex.endswith(r')\Z'): # pragma: no cover + regex = regex[4:-3] + elif regex.startswith(r'(?s:') and regex.endswith(r')\z'): # pragma: no cover + # Yaay, python3.14 for senselessly duplicating \Z as \z... + regex = regex[4:-3] + + # Finally, convert back to regex operating on bytestr + return regex.encode() + +class PathQuoting: + _unescape = {b'a': b'\a', + b'b': b'\b', + b'f': b'\f', + b'n': b'\n', + b'r': b'\r', + b't': b'\t', + b'v': b'\v', + b'"': b'"', + b'\\':b'\\'} + _unescape_re = re.compile(br'\\([a-z"\\]|[0-9]{3})') + _escape = [bytes([x]) for x in range(127)]+[ + b'\\'+bytes(ord(c) for c in oct(x)[2:]) for x in range(127,256)] + _reverse = dict(map(reversed, _unescape.items())) + for x in _reverse: + _escape[ord(x)] = b'\\'+_reverse[x] + _special_chars = [len(x) > 1 for x in _escape] + + @staticmethod + def unescape_sequence(orig): + seq = orig.group(1) + return PathQuoting._unescape[seq] if len(seq) == 1 else bytes([int(seq, 8)]) + + @staticmethod + def dequote(quoted_string): + if quoted_string.startswith(b'"'): + assert quoted_string.endswith(b'"') + return PathQuoting._unescape_re.sub(PathQuoting.unescape_sequence, + quoted_string[1:-1]) + return quoted_string + + @staticmethod + def enquote(unquoted_string): + # Option 1: Quoting when fast-export would: + # pqsc = PathQuoting._special_chars + # if any(pqsc[x] for x in set(unquoted_string)): + # Option 2, perf hack: do minimal amount of quoting required by fast-import + if unquoted_string.startswith(b'"') or b'\n' in unquoted_string: + pqe = PathQuoting._escape + return b'"' + b''.join(pqe[x] for x in unquoted_string) + b'"' + return unquoted_string + +class AncestryGraph(object): + """ + A class that maintains a direct acycle graph of commits for the purpose of + determining if one commit is the ancestor of another. + + A note about identifiers in Commit objects: + * Commit objects have 2 identifiers: commit.old_id and commit.id, because: + * The original fast-export stream identified commits by an identifier. + This is often an integer, but is sometimes a hash (particularly when + --reference-excluded-parents is provided) + * The new fast-import stream we use may not use the same identifiers. + If new blobs or commits are inserted (such as lint-history does), then + the integer (or hash) are no longer valid. + + A note about identifiers in AncestryGraph objects, of which there are three: + * A given AncestryGraph is based on either commit.old_id or commit.id, but + not both. These are the keys for self.value. + * Using full hashes (occasionally) for children in self.graph felt + wasteful, so we use our own internal integer within self.graph. + self.value maps from commit {old_}id to our internal integer id. + * When working with commit.old_id, it is also sometimes useful to be able + to map these to the original hash, i.e. commit.original_id. So, we + also have self.git_hash for mapping from commit.old_id to git's commit + hash. + """ + + def __init__(self): + # The next internal identifier we will use; increments with every commit + # added to the AncestryGraph + self.cur_value = 0 + + # A mapping from the external identifers given to us to the simple integers + # we use in self.graph + self.value = {} + + # A tuple of (depth, list-of-ancestors). Values and keys in this graph are + # all integers from the (values of the) self.value dict. The depth of a + # commit is one more than the max depth of any of its ancestors. + self.graph = {} + + # A mapping from external identifier (i.e. from the keys of self.value) to + # the hash of the given commit. Only populated for graphs based on + # commit.old_id, since we won't know until later what the git_hash for + # graphs based on commit.id (since we have to wait for fast-import to + # create the commit and notify us of its hash; see _pending_renames). + # elsewhere + self.git_hash = {} + + # Reverse maps; only populated if needed. Caller responsible to check + # and ensure they are populated + self._reverse_value = {} + self._hash_to_id = {} + + # Cached results from previous calls to is_ancestor(). + self._cached_is_ancestor = {} + + def record_external_commits(self, external_commits): + """ + Record in graph that each commit in external_commits exists, and is + treated as a root commit with no parents. + """ + for c in external_commits: + if c not in self.value: + self.cur_value += 1 + self.value[c] = self.cur_value + self.graph[self.cur_value] = (1, []) + self.git_hash[c] = c + + def add_commit_and_parents(self, commit, parents, githash = None): + """ + Record in graph that commit has the given parents (all identified by + fast export stream identifiers, usually integers but sometimes hashes). + parents _MUST_ have been first recorded. commit _MUST_ not have been + recorded yet. Also, record the mapping between commit and githash, if + githash is given. + """ + assert all(p in self.value for p in parents) + assert commit not in self.value + + # Get values for commit and parents + self.cur_value += 1 + self.value[commit] = self.cur_value + if githash: + self.git_hash[commit] = githash + graph_parents = [self.value[x] for x in parents] + + # Determine depth for commit, then insert the info into the graph + depth = 1 + if parents: + depth += max(self.graph[p][0] for p in graph_parents) + self.graph[self.cur_value] = (depth, graph_parents) + + def record_hash(self, commit_id, githash): + ''' + If a githash was not recorded for commit_id, when add_commit_and_parents + was called, add it now. + ''' + assert commit_id in self.value + assert commit_id not in self.git_hash + self.git_hash[commit_id] = githash + + def _ensure_reverse_maps_populated(self): + if not self._hash_to_id: + assert not self._reverse_value + self._hash_to_id = {v: k for k, v in self.git_hash.items()} + self._reverse_value = {v: k for k, v in self.value.items()} + + def get_parent_hashes(self, commit_hash): + ''' + Given a commit_hash, return its parents hashes + ''' + # + # We have to map: + # commit hash -> fast export stream id -> graph id + # then lookup + # parent graph ids for given graph id + # then we need to map + # parent graph ids -> parent fast export ids -> parent commit hashes + # + self._ensure_reverse_maps_populated() + commit_fast_export_id = self._hash_to_id[commit_hash] + commit_graph_id = self.value[commit_fast_export_id] + parent_graph_ids = self.graph[commit_graph_id][1] + parent_fast_export_ids = [self._reverse_value[x] for x in parent_graph_ids] + parent_hashes = [self.git_hash[x] for x in parent_fast_export_ids] + return parent_hashes + + def map_to_hash(self, commit_id): + ''' + Given a commit (by fast export stream id), return its hash + ''' + return self.git_hash.get(commit_id, None) + + def is_ancestor(self, possible_ancestor, check): + """ + Return whether possible_ancestor is an ancestor of check + """ + a, b = self.value[possible_ancestor], self.value[check] + original_pair = (a,b) + a_depth = self.graph[a][0] + ancestors = [b] + visited = set() + while ancestors: + ancestor = ancestors.pop() + prev_pair = (a, ancestor) + if prev_pair in self._cached_is_ancestor: + if not self._cached_is_ancestor[prev_pair]: + continue + self._cached_is_ancestor[original_pair] = True + return True + if ancestor in visited: + continue + visited.add(ancestor) + depth, more_ancestors = self.graph[ancestor] + if ancestor == a: + self._cached_is_ancestor[original_pair] = True + return True + elif depth <= a_depth: + continue + ancestors.extend(more_ancestors) + self._cached_is_ancestor[original_pair] = False + return False + +class MailmapInfo(object): + def __init__(self, filename): + self.changes = {} + self._parse_file(filename) + + def _parse_file(self, filename): + name_and_email_re = re.compile(br'(.*?)\s*<([^>]*)>\s*') + comment_re = re.compile(br'\s*#.*') + if not os.access(filename, os.R_OK): + raise SystemExit(_("Cannot read %s") % decode(filename)) + with open(filename, 'br') as f: + count = 0 + for line in f: + count += 1 + err = "Unparseable mailmap file: line #{} is bad: {}".format(count, line) + # Remove comments + line = comment_re.sub(b'', line) + # Remove leading and trailing whitespace + line = line.strip() + if not line: + continue + + m = name_and_email_re.match(line) + if not m: + raise SystemExit(err) + proper_name, proper_email = m.groups() + if len(line) == m.end(): + self.changes[(None, proper_email)] = (proper_name, proper_email) + continue + rest = line[m.end():] + m = name_and_email_re.match(rest) + if m: + commit_name, commit_email = m.groups() + if len(rest) != m.end(): + raise SystemExit(err) + else: + commit_name, commit_email = rest, None + self.changes[(commit_name, commit_email)] = (proper_name, proper_email) + + def translate(self, name, email): + ''' Given a name and email, return the expected new name and email from the + mailmap if there is a translation rule for it, otherwise just return + the given name and email.''' + for old, new in self.changes.items(): + old_name, old_email = old + new_name, new_email = new + if (old_email is None or email.lower() == old_email.lower()) and ( + name == old_name or not old_name): + return (new_name or name, new_email or email) + return (name, email) + +class ProgressWriter(object): + def __init__(self): + self._last_progress_update = time.time() + self._last_message = None + + def show(self, msg): + self._last_message = msg + now = time.time() + if now - self._last_progress_update > .1: + self._last_progress_update = now + sys.stdout.write("\r{}".format(msg)) + sys.stdout.flush() + + def finish(self): + self._last_progress_update = 0 + if self._last_message: + self.show(self._last_message) + sys.stdout.write("\n") + +class _IDs(object): + """ + A class that maintains the 'name domain' of all the 'marks' (short int + id for a blob/commit git object). There are two reasons this mechanism + is necessary: + (1) the output text of fast-export may refer to an object using a different + mark than the mark that was assigned to that object using IDS.new(). + (This class allows you to translate the fast-export marks, "old" to + the marks assigned from IDS.new(), "new"). + (2) when we prune a commit, its "old" id becomes invalid. Any commits + which had that commit as a parent needs to use the nearest unpruned + ancestor as its parent instead. + + Note that for purpose (1) above, this typically comes about because the user + manually creates Blob or Commit objects (for insertion into the stream). + It could also come about if we attempt to read the data from two different + repositories and trying to combine the data (git fast-export will number ids + from 1...n, and having two 1's, two 2's, two 3's, causes issues; granted, we + this scheme doesn't handle the two streams perfectly either, but if the first + fast export stream is entirely processed and handled before the second stream + is started, this mechanism may be sufficient to handle it). + """ + + def __init__(self): + """ + Init + """ + # The id for the next created blob/commit object + self._next_id = 1 + + # A map of old-ids to new-ids (1:1 map) + self._translation = {} + + # A map of new-ids to every old-id that points to the new-id (1:N map) + self._reverse_translation = {} + + def has_renames(self): + """ + Return whether there have been ids remapped to new values + """ + return bool(self._translation) + + def new(self): + """ + Should be called whenever a new blob or commit object is created. The + returned value should be used as the id/mark for that object. + """ + rv = self._next_id + self._next_id += 1 + return rv + + def record_rename(self, old_id, new_id, handle_transitivity = False): + """ + Record that old_id is being renamed to new_id. + """ + if old_id != new_id or old_id in self._translation: + # old_id -> new_id + self._translation[old_id] = new_id + + # Transitivity will be needed if new commits are being inserted mid-way + # through a branch. + if handle_transitivity: + # Anything that points to old_id should point to new_id + if old_id in self._reverse_translation: + for id_ in self._reverse_translation[old_id]: + self._translation[id_] = new_id + + # Record that new_id is pointed to by old_id + if new_id not in self._reverse_translation: + self._reverse_translation[new_id] = [] + self._reverse_translation[new_id].append(old_id) + + def translate(self, old_id): + """ + If old_id has been mapped to an alternate id, return the alternate id. + """ + if old_id in self._translation: + return self._translation[old_id] + else: + return old_id + + def __str__(self): + """ + Convert IDs to string; used for debugging + """ + rv = "Current count: %d\nTranslation:\n" % self._next_id + for k in sorted(self._translation): + rv += " %d -> %s\n" % (k, self._translation[k]) + + rv += "Reverse translation:\n" + reverse_keys = list(self._reverse_translation.keys()) + if None in reverse_keys: # pragma: no cover + reverse_keys.remove(None) + reverse_keys = sorted(reverse_keys) + reverse_keys.append(None) + for k in reverse_keys: + rv += " " + str(k) + " -> " + str(self._reverse_translation[k]) + "\n" + + return rv + +class _GitElement(object): + """ + The base class for all git elements that we create. + """ + + def __init__(self): + # A string that describes what type of Git element this is + self.type = None + + # A flag telling us if this Git element has been dumped + # (i.e. printed) or skipped. Typically elements that have been + # dumped or skipped will not be dumped again. + self.dumped = 0 + + def dump(self, file_): + """ + This version should never be called. Derived classes need to + override! We should note that subclasses should implement this + method such that the output would match the format produced by + fast-export. + """ + raise SystemExit(_("Unimplemented function: %s") % type(self).__name__ + +".dump()") # pragma: no cover + + def __bytes__(self): + """ + Convert GitElement to bytestring; used for debugging + """ + old_dumped = self.dumped + writeme = io.BytesIO() + self.dump(writeme) + output_lines = writeme.getvalue().splitlines() + writeme.close() + self.dumped = old_dumped + return b"%s:\n %s" % (type(self).__name__.encode(), + b"\n ".join(output_lines)) + + def skip(self, new_id=None): + """ + Ensures this element will not be written to output + """ + self.dumped = 2 + +class _GitElementWithId(_GitElement): + """ + The base class for Git elements that have IDs (commits and blobs) + """ + + def __init__(self): + _GitElement.__init__(self) + + # The mark (short, portable id) for this element + self.id = _IDS.new() + + # The previous mark for this element + self.old_id = None + + def skip(self, new_id=None): + """ + This element will no longer be automatically written to output. When a + commit gets skipped, it's ID will need to be translated to that of its + parent. + """ + self.dumped = 2 + + _IDS.record_rename(self.old_id or self.id, new_id) + +class Blob(_GitElementWithId): + """ + This class defines our representation of git blob elements (i.e. our + way of representing file contents). + """ + + def __init__(self, data, original_id = None): + _GitElementWithId.__init__(self) + + # Denote that this is a blob + self.type = 'blob' + + # Record original id + self.original_id = original_id + + # Stores the blob's data + assert(type(data) == bytes) + self.data = data + + def dump(self, file_): + """ + Write this blob element to a file. + """ + self.dumped = 1 + BLOB_HASH_TO_NEW_ID[self.original_id] = self.id + BLOB_NEW_ID_TO_HASH[self.id] = self.original_id + + file_.write(b'blob\n') + file_.write(b'mark :%d\n' % self.id) + file_.write(b'data %d\n%s' % (len(self.data), self.data)) + file_.write(b'\n') + + +class Reset(_GitElement): + """ + This class defines our representation of git reset elements. A reset + event is the creation (or recreation) of a named branch, optionally + starting from a specific revision). + """ + + def __init__(self, ref, from_ref = None): + _GitElement.__init__(self) + + # Denote that this is a reset + self.type = 'reset' + + # The name of the branch being (re)created + self.ref = ref + + # Some reference to the branch/commit we are resetting from + self.from_ref = from_ref + + def dump(self, file_): + """ + Write this reset element to a file + """ + self.dumped = 1 + + file_.write(b'reset %s\n' % self.ref) + if self.from_ref: + if isinstance(self.from_ref, int): + file_.write(b'from :%d\n' % self.from_ref) + else: + file_.write(b'from %s\n' % self.from_ref) + file_.write(b'\n') + +class FileChange(_GitElement): + """ + This class defines our representation of file change elements. File change + elements are components within a Commit element. + """ + + def __init__(self, type_, filename = None, id_ = None, mode = None): + _GitElement.__init__(self) + + # Denote the type of file-change (b'M' for modify, b'D' for delete, etc) + # We could + # assert(type(type_) == bytes) + # here but I don't just due to worries about performance overhead... + self.type = type_ + + # Record the name of the file being changed + self.filename = filename + + # Record the mode (mode describes type of file entry (non-executable, + # executable, or symlink)). + self.mode = mode + + # blob_id is the id (mark) of the affected blob + self.blob_id = id_ + + if type_ == b'DELETEALL': + assert filename is None and id_ is None and mode is None + self.filename = b'' # Just so PathQuoting.enquote doesn't die + else: + assert filename is not None + + if type_ == b'M': + assert id_ is not None and mode is not None + elif type_ == b'D': + assert id_ is None and mode is None + elif type_ == b'R': # pragma: no cover (now avoid fast-export renames) + assert mode is None + if id_ is None: + raise SystemExit(_("new name needed for rename of %s") % filename) + self.filename = (self.filename, id_) + self.blob_id = None + + def dump(self, file_): + """ + Write this file-change element to a file + """ + skipped_blob = (self.type == b'M' and self.blob_id is None) + if skipped_blob: return + self.dumped = 1 + + quoted_filename = PathQuoting.enquote(self.filename) + if self.type == b'M' and isinstance(self.blob_id, int): + file_.write(b'M %s :%d %s\n' % (self.mode, self.blob_id, quoted_filename)) + elif self.type == b'M': + file_.write(b'M %s %s %s\n' % (self.mode, self.blob_id, quoted_filename)) + elif self.type == b'D': + file_.write(b'D %s\n' % quoted_filename) + elif self.type == b'DELETEALL': + file_.write(b'deleteall\n') + else: + raise SystemExit(_("Unhandled filechange type: %s") % self.type) # pragma: no cover + +class Commit(_GitElementWithId): + """ + This class defines our representation of commit elements. Commit elements + contain all the information associated with a commit. + """ + + def __init__(self, branch, + author_name, author_email, author_date, + committer_name, committer_email, committer_date, + message, + file_changes, + parents, + original_id = None, + encoding = None, # encoding for message; None implies UTF-8 + **kwargs): + _GitElementWithId.__init__(self) + self.old_id = self.id + + # Denote that this is a commit element + self.type = 'commit' + + # Record the affected branch + self.branch = branch + + # Record original id + self.original_id = original_id + + # Record author's name + self.author_name = author_name + + # Record author's email + self.author_email = author_email + + # Record date of authoring + self.author_date = author_date + + # Record committer's name + self.committer_name = committer_name + + # Record committer's email + self.committer_email = committer_email + + # Record date the commit was made + self.committer_date = committer_date + + # Record commit message and its encoding + self.encoding = encoding + self.message = message + + # List of file-changes associated with this commit. Note that file-changes + # are also represented as git elements + self.file_changes = file_changes + + self.parents = parents + + def dump(self, file_): + """ + Write this commit element to a file. + """ + self.dumped = 1 + + # Make output to fast-import slightly easier for humans to read if the + # message has no trailing newline of its own; cosmetic, but a nice touch... + extra_newline = b'\n' + if self.message.endswith(b'\n') or not (self.parents or self.file_changes): + extra_newline = b'' + + if not self.parents: + file_.write(b'reset %s\n' % self.branch) + file_.write((b'commit %s\n' + b'mark :%d\n' + b'author %s <%s> %s\n' + b'committer %s <%s> %s\n' + ) % ( + self.branch, self.id, + self.author_name, self.author_email, self.author_date, + self.committer_name, self.committer_email, self.committer_date + )) + if self.encoding: + file_.write(b'encoding %s\n' % self.encoding) + file_.write(b'data %d\n%s%s' % + (len(self.message), self.message, extra_newline)) + for i, parent in enumerate(self.parents): + file_.write(b'from ' if i==0 else b'merge ') + if isinstance(parent, int): + file_.write(b':%d\n' % parent) + else: + file_.write(b'%s\n' % parent) + for change in self.file_changes: + change.dump(file_) + if not self.parents and not self.file_changes: + # Workaround a bug in pre-git-2.22 versions of fast-import with + # the get-mark directive. + file_.write(b'\n') + file_.write(b'\n') + + def first_parent(self): + """ + Return first parent commit + """ + if self.parents: + return self.parents[0] + return None + + def skip(self, new_id=None): + _SKIPPED_COMMITS.add(self.old_id or self.id) + _GitElementWithId.skip(self, new_id) + +class Tag(_GitElementWithId): + """ + This class defines our representation of annotated tag elements. + """ + + def __init__(self, ref, from_ref, + tagger_name, tagger_email, tagger_date, tag_msg, + original_id = None): + _GitElementWithId.__init__(self) + self.old_id = self.id + + # Denote that this is a tag element + self.type = 'tag' + + # Store the name of the tag + self.ref = ref + + # Store the entity being tagged (this should be a commit) + self.from_ref = from_ref + + # Record original id + self.original_id = original_id + + # Store the name of the tagger + self.tagger_name = tagger_name + + # Store the email of the tagger + self.tagger_email = tagger_email + + # Store the date + self.tagger_date = tagger_date + + # Store the tag message + self.message = tag_msg + + def dump(self, file_): + """ + Write this tag element to a file + """ + + self.dumped = 1 + + file_.write(b'tag %s\n' % self.ref) + if (write_marks and self.id): + file_.write(b'mark :%d\n' % self.id) + markfmt = b'from :%d\n' if isinstance(self.from_ref, int) else b'from %s\n' + file_.write(markfmt % self.from_ref) + if self.tagger_name: + file_.write(b'tagger %s <%s> ' % (self.tagger_name, self.tagger_email)) + file_.write(self.tagger_date) + file_.write(b'\n') + file_.write(b'data %d\n%s' % (len(self.message), self.message)) + file_.write(b'\n') + +class Progress(_GitElement): + """ + This class defines our representation of progress elements. The progress + element only contains a progress message, which is printed by fast-import + when it processes the progress output. + """ + + def __init__(self, message): + _GitElement.__init__(self) + + # Denote that this is a progress element + self.type = 'progress' + + # Store the progress message + self.message = message + + def dump(self, file_): + """ + Write this progress element to a file + """ + self.dumped = 1 + + file_.write(b'progress %s\n' % self.message) + file_.write(b'\n') + +class Checkpoint(_GitElement): + """ + This class defines our representation of checkpoint elements. These + elements represent events which force fast-import to close the current + packfile, start a new one, and to save out all current branch refs, tags + and marks. + """ + + def __init__(self): + _GitElement.__init__(self) + + # Denote that this is a checkpoint element + self.type = 'checkpoint' + + def dump(self, file_): + """ + Write this checkpoint element to a file + """ + self.dumped = 1 + + file_.write(b'checkpoint\n') + file_.write(b'\n') + +class LiteralCommand(_GitElement): + """ + This class defines our representation of commands. The literal command + includes only a single line, and is not processed in any special way. + """ + + def __init__(self, line): + _GitElement.__init__(self) + + # Denote that this is a literal element + self.type = 'literal' + + # Store the command + self.line = line + + def dump(self, file_): + """ + Write this progress element to a file + """ + self.dumped = 1 + + file_.write(self.line) + +class Alias(_GitElement): + """ + This class defines our representation of fast-import alias elements. An + alias element is the setting of one mark to the same sha1sum as another, + usually because the newer mark corresponded to a pruned commit. + """ + + def __init__(self, ref, to_ref): + _GitElement.__init__(self) + # Denote that this is a reset + self.type = 'alias' + + self.ref = ref + self.to_ref = to_ref + + def dump(self, file_): + """ + Write this reset element to a file + """ + self.dumped = 1 + + file_.write(b'alias\nmark :%d\nto :%d\n\n' % (self.ref, self.to_ref)) + +class FastExportParser(object): + """ + A class for parsing and handling the output from fast-export. This + class allows the user to register callbacks when various types of + data are encountered in the fast-export output. The basic idea is that, + FastExportParser takes fast-export output, creates the various objects + as it encounters them, the user gets to use/modify these objects via + callbacks, and finally FastExportParser outputs the modified objects + in fast-import format (presumably so they can be used to create a new + repo). + """ + + def __init__(self, + tag_callback = None, commit_callback = None, + blob_callback = None, progress_callback = None, + reset_callback = None, checkpoint_callback = None, + done_callback = None): + # Members below simply store callback functions for the various git + # elements + self._tag_callback = tag_callback + self._blob_callback = blob_callback + self._reset_callback = reset_callback + self._commit_callback = commit_callback + self._progress_callback = progress_callback + self._checkpoint_callback = checkpoint_callback + self._done_callback = done_callback + + # Keep track of which refs appear from the export, and which make it to + # the import (pruning of empty commits, renaming of refs, and creating + # new manual objects and inserting them can cause these to differ). + self._exported_refs = set() + self._imported_refs = set() + + # A list of the branches we've seen, plus the last known commit they + # pointed to. An entry in latest_*commit will be deleted if we get a + # reset for that branch. These are used because of fast-import's weird + # decision to allow having an implicit parent via naming the branch + # instead of requiring branches to be specified via 'from' directives. + self._latest_commit = {} + self._latest_orig_commit = {} + + # A handle to the input source for the fast-export data + self._input = None + + # A handle to the output file for the output we generate (we call dump + # on many of the git elements we create). + self._output = None + + # Stores the contents of the current line of input being parsed + self._currentline = '' + + # Tracks LFS objects we have found + self._lfs_object_tracker = None + + # Compile some regexes and cache those + self._mark_re = re.compile(br'mark :(\d+)\n$') + self._parent_regexes = {} + parent_regex_rules = (br' :(\d+)\n$', br' ([0-9a-f]{40})\n') + for parent_refname in (b'from', b'merge'): + ans = [re.compile(parent_refname+x) for x in parent_regex_rules] + self._parent_regexes[parent_refname] = ans + self._quoted_string_re = re.compile(br'"(?:[^"\\]|\\.)*"') + self._refline_regexes = {} + for refline_name in (b'reset', b'commit', b'tag', b'progress'): + self._refline_regexes[refline_name] = re.compile(refline_name+b' (.*)\n$') + self._user_regexes = {} + for user in (b'author', b'committer', b'tagger'): + self._user_regexes[user] = re.compile(user + b' (.*?) <(.*?)> (.*)\n$') + + def _advance_currentline(self): + """ + Grab the next line of input + """ + self._currentline = self._input.readline() + + def _parse_optional_mark(self): + """ + If the current line contains a mark, parse it and advance to the + next line; return None otherwise + """ + mark = None + matches = self._mark_re.match(self._currentline) + if matches: + mark = int(matches.group(1)) + self._advance_currentline() + return mark + + def _parse_optional_parent_ref(self, refname): + """ + If the current line contains a reference to a parent commit, then + parse it and advance the current line; otherwise return None. Note + that the name of the reference ('from', 'merge') must match the + refname arg. + """ + orig_baseref, baseref = None, None + rule, altrule = self._parent_regexes[refname] + matches = rule.match(self._currentline) + if matches: + orig_baseref = int(matches.group(1)) + # We translate the parent commit mark to what it needs to be in + # our mark namespace + baseref = _IDS.translate(orig_baseref) + self._advance_currentline() + else: + matches = altrule.match(self._currentline) + if matches: + orig_baseref = matches.group(1) + baseref = orig_baseref + self._advance_currentline() + return orig_baseref, baseref + + def _parse_optional_filechange(self): + """ + If the current line contains a file-change object, then parse it + and advance the current line; otherwise return None. We only care + about file changes of type b'M' and b'D' (these are the only types + of file-changes that fast-export will provide). + """ + filechange = None + changetype = self._currentline[0:1] + if changetype == b'M': + (changetype, mode, idnum, path) = self._currentline.split(None, 3) + if idnum[0:1] == b':': + idnum = idnum[1:] + path = path.rstrip(b'\n') + # Check for LFS objects from sources before we might toss this filechange + if mode != b'160000' and self._lfs_object_tracker: + value = int(idnum) if len(idnum) != 40 else idnum + self._lfs_object_tracker.check_file_change_data(value, True) + # We translate the idnum to our id system + if len(idnum) != 40: + idnum = _IDS.translate( int(idnum) ) + if idnum is not None: + if path.startswith(b'"'): + path = PathQuoting.dequote(path) + filechange = FileChange(b'M', path, idnum, mode) + else: + filechange = b'skipped' + self._advance_currentline() + elif changetype == b'D': + (changetype, path) = self._currentline.split(None, 1) + path = path.rstrip(b'\n') + if path.startswith(b'"'): + path = PathQuoting.dequote(path) + filechange = FileChange(b'D', path) + self._advance_currentline() + elif changetype == b'R': # pragma: no cover (now avoid fast-export renames) + rest = self._currentline[2:-1] + if rest.startswith(b'"'): + m = self._quoted_string_re.match(rest) + if not m: + raise SystemExit(_("Couldn't parse rename source")) + orig = PathQuoting.dequote(m.group(0)) + new = rest[m.end()+1:] + else: + orig, new = rest.split(b' ', 1) + if new.startswith(b'"'): + new = PathQuoting.dequote(new) + filechange = FileChange(b'R', orig, new) + self._advance_currentline() + return filechange + + def _parse_original_id(self): + original_id = self._currentline[len(b'original-oid '):].rstrip() + self._advance_currentline() + return original_id + + def _parse_encoding(self): + encoding = self._currentline[len(b'encoding '):].rstrip() + self._advance_currentline() + return encoding + + def _parse_ref_line(self, refname): + """ + Parses string data (often a branch name) from current-line. The name of + the string data must match the refname arg. The program will crash if + current-line does not match, so current-line will always be advanced if + this method returns. + """ + matches = self._refline_regexes[refname].match(self._currentline) + if not matches: + raise SystemExit(_("Malformed %(refname)s line: '%(line)s'") % + ({'refname': refname, 'line':self._currentline}) + ) # pragma: no cover + ref = matches.group(1) + self._advance_currentline() + return ref + + def _parse_user(self, usertype): + """ + Get user name, email, datestamp from current-line. Current-line will + be advanced. + """ + user_regex = self._user_regexes[usertype] + (name, email, when) = user_regex.match(self._currentline).groups() + + self._advance_currentline() + return (name, email, when) + + def _parse_data(self): + """ + Reads data from _input. Current-line will be advanced until it is beyond + the data. + """ + fields = self._currentline.split() + assert fields[0] == b'data' + size = int(fields[1]) + data = self._input.read(size) + self._advance_currentline() + if self._currentline == b'\n': + self._advance_currentline() + return data + + def _parse_blob(self): + """ + Parse input data into a Blob object. Once the Blob has been created, it + will be handed off to the appropriate callbacks. Current-line will be + advanced until it is beyond this blob's data. The Blob will be dumped + to _output once everything else is done (unless it has been skipped by + the callback). + """ + # Parse the Blob + self._advance_currentline() + id_ = self._parse_optional_mark() + + original_id = None + if self._currentline.startswith(b'original-oid'): + original_id = self._parse_original_id(); + + data = self._parse_data() + if self._currentline == b'\n': + self._advance_currentline() + + # Create the blob + blob = Blob(data, original_id) + + # If fast-export text had a mark for this blob, need to make sure this + # mark translates to the blob's true id. + if id_: + blob.old_id = id_ + _IDS.record_rename(id_, blob.id) + + # Check for LFS objects + if self._lfs_object_tracker: + self._lfs_object_tracker.check_blob_data(data, blob.old_id, True) + + # Call any user callback to allow them to use/modify the blob + if self._blob_callback: + self._blob_callback(blob) + + # Now print the resulting blob + if not blob.dumped: + blob.dump(self._output) + + def _parse_reset(self): + """ + Parse input data into a Reset object. Once the Reset has been created, + it will be handed off to the appropriate callbacks. Current-line will + be advanced until it is beyond the reset data. The Reset will be dumped + to _output once everything else is done (unless it has been skipped by + the callback). + """ + # Parse the Reset + ref = self._parse_ref_line(b'reset') + self._exported_refs.add(ref) + ignoreme, from_ref = self._parse_optional_parent_ref(b'from') + if self._currentline == b'\n': + self._advance_currentline() + + # fast-export likes to print extraneous resets that serve no purpose. + # While we could continue processing such resets, that is a waste of + # resources. Also, we want to avoid recording that this ref was + # seen in such cases, since this ref could be rewritten to nothing. + if not from_ref: + self._latest_commit.pop(ref, None) + self._latest_orig_commit.pop(ref, None) + return + + # Create the reset + reset = Reset(ref, from_ref) + + # Call any user callback to allow them to modify the reset + if self._reset_callback: + self._reset_callback(reset) + + # Update metadata + self._latest_commit[reset.ref] = reset.from_ref + self._latest_orig_commit[reset.ref] = reset.from_ref + + # Now print the resulting reset + if not reset.dumped: + self._imported_refs.add(reset.ref) + reset.dump(self._output) + + def _parse_commit(self): + """ + Parse input data into a Commit object. Once the Commit has been created, + it will be handed off to the appropriate callbacks. Current-line will + be advanced until it is beyond the commit data. The Commit will be dumped + to _output once everything else is done (unless it has been skipped by + the callback OR the callback has removed all file-changes from the commit). + """ + # Parse the Commit. This may look involved, but it's pretty simple; it only + # looks bad because a commit object contains many pieces of data. + branch = self._parse_ref_line(b'commit') + self._exported_refs.add(branch) + id_ = self._parse_optional_mark() + + original_id = None + if self._currentline.startswith(b'original-oid'): + original_id = self._parse_original_id(); + + author_name = None + author_email = None + if self._currentline.startswith(b'author'): + (author_name, author_email, author_date) = self._parse_user(b'author') + + (committer_name, committer_email, committer_date) = \ + self._parse_user(b'committer') + + if not author_name and not author_email: + (author_name, author_email, author_date) = \ + (committer_name, committer_email, committer_date) + + encoding = None + if self._currentline.startswith(b'encoding '): + encoding = self._parse_encoding() + + commit_msg = self._parse_data() + + pinfo = [self._parse_optional_parent_ref(b'from')] + # Due to empty pruning, we can have real 'from' and 'merge' lines that + # due to commit rewriting map to a parent of None. We need to record + # 'from' if its non-None, and we need to parse all 'merge' lines. + while self._currentline.startswith(b'merge '): + pinfo.append(self._parse_optional_parent_ref(b'merge')) + orig_parents, parents = [list(tmp) for tmp in zip(*pinfo)] + + # No parents is oddly represented as [None] instead of [], due to the + # special 'from' handling. Convert it here to a more canonical form. + if parents == [None]: + parents = [] + if orig_parents == [None]: + orig_parents = [] + + # fast-import format is kinda stupid in that it allows implicit parents + # based on the branch name instead of requiring them to be specified by + # 'from' directives. The only way to get no parent is by using a reset + # directive first, which clears the latest_commit_for_this_branch tracking. + if not orig_parents and self._latest_commit.get(branch): + parents = [self._latest_commit[branch]] + if not orig_parents and self._latest_orig_commit.get(branch): + orig_parents = [self._latest_orig_commit[branch]] + + # Get the list of file changes + file_changes = [] + file_change = self._parse_optional_filechange() + had_file_changes = file_change is not None + while file_change: + if not (type(file_change) == bytes and file_change == b'skipped'): + file_changes.append(file_change) + file_change = self._parse_optional_filechange() + if self._currentline == b'\n': + self._advance_currentline() + + # Okay, now we can finally create the Commit object + commit = Commit(branch, + author_name, author_email, author_date, + committer_name, committer_email, committer_date, + commit_msg, file_changes, parents, original_id, encoding) + + # If fast-export text had a mark for this commit, need to make sure this + # mark translates to the commit's true id. + if id_: + commit.old_id = id_ + _IDS.record_rename(id_, commit.id) + + # refs/notes/ put commit-message-related material in blobs, and name their + # files according to the hash of other commits. That totally messes with + # all normal callbacks; fast-export should really export these as different + # kinds of objects. Until then, let's just pass these commits through as-is + # and hope the blob callbacks don't mess things up. + if commit.branch.startswith(b'refs/notes/'): + self._imported_refs.add(commit.branch) + commit.dump(self._output) + return + + # Call any user callback to allow them to modify the commit + aux_info = {'orig_parents': orig_parents, + 'had_file_changes': had_file_changes} + if self._commit_callback: + self._commit_callback(commit, aux_info) + + # Now print the resulting commit, or if prunable skip it + self._latest_orig_commit[branch] = commit.id + if not (commit.old_id or commit.id) in _SKIPPED_COMMITS: + self._latest_commit[branch] = commit.id + if not commit.dumped: + self._imported_refs.add(commit.branch) + commit.dump(self._output) + + def _parse_tag(self): + """ + Parse input data into a Tag object. Once the Tag has been created, + it will be handed off to the appropriate callbacks. Current-line will + be advanced until it is beyond the tag data. The Tag will be dumped + to _output once everything else is done (unless it has been skipped by + the callback). + """ + # Parse the Tag + tag = self._parse_ref_line(b'tag') + self._exported_refs.add(b'refs/tags/'+tag) + id_ = self._parse_optional_mark() + ignoreme, from_ref = self._parse_optional_parent_ref(b'from') + + original_id = None + if self._currentline.startswith(b'original-oid'): + original_id = self._parse_original_id(); + + tagger_name, tagger_email, tagger_date = None, None, None + if self._currentline.startswith(b'tagger'): + (tagger_name, tagger_email, tagger_date) = self._parse_user(b'tagger') + tag_msg = self._parse_data() + if self._currentline == b'\n': + self._advance_currentline() + + # Create the tag + tag = Tag(tag, from_ref, + tagger_name, tagger_email, tagger_date, tag_msg, + original_id) + + # If fast-export text had a mark for this tag, need to make sure this + # mark translates to the tag's true id. + if id_: + tag.old_id = id_ + _IDS.record_rename(id_, tag.id) + + # Call any user callback to allow them to modify the tag + if self._tag_callback: + self._tag_callback(tag) + + # The tag might not point at anything that still exists (self.from_ref + # will be None if the commit it pointed to and all its ancestors were + # pruned due to being empty) + if tag.from_ref: + # Print out this tag's information + if not tag.dumped: + self._imported_refs.add(b'refs/tags/'+tag.ref) + tag.dump(self._output) + else: + tag.skip() + + def _parse_progress(self): + """ + Parse input data into a Progress object. Once the Progress has + been created, it will be handed off to the appropriate + callbacks. Current-line will be advanced until it is beyond the + progress data. The Progress will be dumped to _output once + everything else is done (unless it has been skipped by the callback). + """ + # Parse the Progress + message = self._parse_ref_line(b'progress') + if self._currentline == b'\n': + self._advance_currentline() + + # Create the progress message + progress = Progress(message) + + # Call any user callback to allow them to modify the progress messsage + if self._progress_callback: + self._progress_callback(progress) + + # NOTE: By default, we do NOT print the progress message; git + # fast-import would write it to fast_import_pipes which could mess with + # our parsing of output from the 'ls' and 'get-mark' directives we send + # to fast-import. If users want these messages, they need to process + # and handle them in the appropriate callback above. + + def _parse_checkpoint(self): + """ + Parse input data into a Checkpoint object. Once the Checkpoint has + been created, it will be handed off to the appropriate + callbacks. Current-line will be advanced until it is beyond the + checkpoint data. The Checkpoint will be dumped to _output once + everything else is done (unless it has been skipped by the callback). + """ + # Parse the Checkpoint + self._advance_currentline() + if self._currentline == b'\n': + self._advance_currentline() + + # Create the checkpoint + checkpoint = Checkpoint() + + # Call any user callback to allow them to drop the checkpoint + if self._checkpoint_callback: + self._checkpoint_callback(checkpoint) + + # NOTE: By default, we do NOT print the checkpoint message; although it + # we would only realistically get them with --stdin, the fact that we + # are filtering makes me think the checkpointing is less likely to be + # reasonable. In fact, I don't think it's necessary in general. If + # users do want it, they should process it in the checkpoint_callback. + + def _parse_literal_command(self): + """ + Parse literal command. Then just dump the line as is. + """ + # Create the literal command object + command = LiteralCommand(self._currentline) + self._advance_currentline() + + # Now print the resulting literal command + if not command.dumped: + command.dump(self._output) + + def insert(self, obj): + assert not obj.dumped + obj.dump(self._output) + if type(obj) == Commit: + self._imported_refs.add(obj.branch) + elif type(obj) in (Reset, Tag): + self._imported_refs.add(obj.ref) + + def run(self, input, output): + """ + This method filters fast export output. + """ + # Set input. If no args provided, use stdin. + self._input = input + self._output = output + + # Run over the input and do the filtering + self._advance_currentline() + while self._currentline: + if self._currentline.startswith(b'blob'): + self._parse_blob() + elif self._currentline.startswith(b'reset'): + self._parse_reset() + elif self._currentline.startswith(b'commit'): + self._parse_commit() + elif self._currentline.startswith(b'tag'): + self._parse_tag() + elif self._currentline.startswith(b'progress'): + self._parse_progress() + elif self._currentline.startswith(b'checkpoint'): + self._parse_checkpoint() + elif self._currentline.startswith(b'feature'): + self._parse_literal_command() + elif self._currentline.startswith(b'option'): + self._parse_literal_command() + elif self._currentline.startswith(b'done'): + if self._done_callback: + self._done_callback() + self._parse_literal_command() + # Prevent confusion from others writing additional stuff that'll just + # be ignored + self._output.close() + elif self._currentline.startswith(b'#'): + self._parse_literal_command() + elif self._currentline.startswith(b'get-mark') or \ + self._currentline.startswith(b'cat-blob') or \ + self._currentline.startswith(b'ls'): + raise SystemExit(_("Unsupported command: '%s'") % self._currentline) + else: + raise SystemExit(_("Could not parse line: '%s'") % self._currentline) + + def get_exported_and_imported_refs(self): + return self._exported_refs, self._imported_refs + +def record_id_rename(old_id, new_id): + """ + Register a new translation + """ + handle_transitivity = True + _IDS.record_rename(old_id, new_id, handle_transitivity) + +# Internal globals +_IDS = _IDs() +_SKIPPED_COMMITS = set() +BLOB_HASH_TO_NEW_ID = {} +BLOB_NEW_ID_TO_HASH = {} +sdr_next_steps = _(""" +NEXT STEPS FOR YOUR SENSITIVE DATA REMOVAL: + * If you are doing your rewrite in multiple steps, ignore these next steps + until you have completed all your invocations of git-filter-repo. + * See the "Sensitive Data Removal" subsection of the "DISCUSSION" section + of the manual for more details about any of the steps below. + * Inspect this repository and verify that the sensitive data is indeed + completely removed from all commits. + * Force push the rewritten history to the server: + %s + * Contact the server admins for additional steps they need to take; the + First Changed Commit(s)%s may come in handy here. + * Have other colleagues with a clone either discard their clone and reclone + OR follow the detailed steps in the manual to repeatedly rebase and + purge the sensitive data from their copy. Again, the First Changed + Commit(s)%s may come in handy. + * See the "Prevent repeats and avoid future sensitive data spills" section + of the manual. +"""[1:]) + +class SubprocessWrapper(object): + @staticmethod + def decodify(args): + if type(args) == str: + return args + else: + assert type(args) == list + return [decode(x) if type(x)==bytes else x for x in args] + + @staticmethod + def call(*args, **kwargs): + if 'cwd' in kwargs: + kwargs['cwd'] = decode(kwargs['cwd']) + return subprocess.call(SubprocessWrapper.decodify(*args), **kwargs) + + @staticmethod + def check_output(*args, **kwargs): + if 'cwd' in kwargs: + kwargs['cwd'] = decode(kwargs['cwd']) + return subprocess.check_output(SubprocessWrapper.decodify(*args), **kwargs) + + @staticmethod + def check_call(*args, **kwargs): # pragma: no cover # used by filter-lamely + if 'cwd' in kwargs: + kwargs['cwd'] = decode(kwargs['cwd']) + return subprocess.check_call(SubprocessWrapper.decodify(*args), **kwargs) + + @staticmethod + def Popen(*args, **kwargs): + if 'cwd' in kwargs: + kwargs['cwd'] = decode(kwargs['cwd']) + return subprocess.Popen(SubprocessWrapper.decodify(*args), **kwargs) + +subproc = subprocess +if platform.system() == 'Windows' or 'PRETEND_UNICODE_ARGS' in os.environ: + subproc = SubprocessWrapper + +class GitUtils(object): + @staticmethod + def get_commit_count(repo, *args): + """ + Return the number of commits that have been made on repo. + """ + if not args: + args = ['--all'] + if len(args) == 1 and isinstance(args[0], list): + args = args[0] + p = subproc.Popen(["git", "rev-list", "--count"] + args, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + cwd=repo) + if p.wait() != 0: + raise SystemExit(_("%s does not appear to be a valid git repository") + % decode(repo)) + return int(p.stdout.read()) + + @staticmethod + def get_total_objects(repo): + """ + Return the number of objects (both packed and unpacked) + """ + p1 = subproc.Popen(["git", "count-objects", "-v"], + stdout=subprocess.PIPE, cwd=repo) + lines = p1.stdout.read().splitlines() + # Return unpacked objects + packed-objects + return int(lines[0].split()[1]) + int(lines[2].split()[1]) + + @staticmethod + def is_repository_bare(repo_working_dir): + out = subproc.check_output('git rev-parse --is-bare-repository'.split(), + cwd=repo_working_dir) + return (out.strip() == b'true') + + @staticmethod + def determine_git_dir(repo_working_dir): + d = subproc.check_output('git rev-parse --git-dir'.split(), + cwd=repo_working_dir).strip() + if repo_working_dir==b'.' or d.startswith(b'/'): + return d + return os.path.join(repo_working_dir, d) + + @staticmethod + def get_refs(repo_working_dir): + try: + output = subproc.check_output('git show-ref'.split(), + cwd=repo_working_dir) + except subprocess.CalledProcessError as e: + # If error code is 1, there just aren't any refs; i.e. new repo. + # If error code is other than 1, some other error (e.g. not a git repo) + if e.returncode != 1: + raise SystemExit('fatal: {}'.format(e)) + output = '' + return dict(reversed(x.split()) for x in output.splitlines()) + + @staticmethod + def get_config_settings(repo_working_dir): + output = '' + try: + output = subproc.check_output('git config --list --null'.split(), + cwd=repo_working_dir) + except subprocess.CalledProcessError as e: # pragma: no cover + raise SystemExit('fatal: {}'.format(e)) + + # FIXME: Ignores multi-valued keys, just let them overwrite for now + return dict(item.split(b'\n', maxsplit=1) + for item in output.strip().split(b"\0") if item) + + @staticmethod + def get_blob_sizes(quiet = False): + blob_size_progress = ProgressWriter() + num_blobs = 0 + processed_blobs_msg = _("Processed %d blob sizes") + + # Get sizes of blobs by sha1 + cmd = '--batch-check=%(objectname) %(objecttype) ' + \ + '%(objectsize) %(objectsize:disk)' + cf = subproc.Popen(['git', 'cat-file', '--batch-all-objects', cmd], + bufsize = -1, + stdout = subprocess.PIPE) + unpacked_size = {} + packed_size = {} + for line in cf.stdout: + try: + sha, objtype, objsize, objdisksize = line.split() + objsize, objdisksize = int(objsize), int(objdisksize) + if objtype == b'blob': + unpacked_size[sha] = objsize + packed_size[sha] = objdisksize + num_blobs += 1 + except ValueError: # pragma: no cover + sys.stderr.write(_("Error: unexpected `git cat-file` output: \"%s\"\n") % line) + if not quiet: + blob_size_progress.show(processed_blobs_msg % num_blobs) + cf.wait() + if not quiet: + blob_size_progress.finish() + return unpacked_size, packed_size + + @staticmethod + def get_file_changes(repo, parent_hash, commit_hash): + """ + Return a FileChanges list with the differences between parent_hash + and commit_hash + """ + file_changes = [] + + cmd = ["git", "diff-tree", "-r", parent_hash, commit_hash] + output = subproc.check_output(cmd, cwd=repo) + for line in output.splitlines(): + fileinfo, path = line.split(b'\t', 1) + if path.startswith(b'"'): + path = PathQuoting.dequote(path) + oldmode, mode, oldhash, newhash, changetype = fileinfo.split() + if changetype == b'D': + file_changes.append(FileChange(b'D', path)) + elif changetype in (b'A', b'M', b'T'): + identifier = BLOB_HASH_TO_NEW_ID.get(newhash, newhash) + file_changes.append(FileChange(b'M', path, identifier, mode)) + else: # pragma: no cover + raise SystemExit("Unknown change type for line {}".format(line)) + + return file_changes + + @staticmethod + def print_my_version(): + with open(__file__, 'br') as f: + contents = f.read() + # If people replaced @@LOCALEDIR@@ string to point at their local + # directory, undo it so we can get original source version. + contents = re.sub(br'\A#\!.*', + br'#!/usr/bin/env python3', contents) + contents = re.sub(br'(\("GIT_TEXTDOMAINDIR"\) or ").*"', + br'\1@@LOCALEDIR@@"', contents) + + cmd = 'git hash-object --stdin'.split() + version = subproc.check_output(cmd, input=contents).strip() + print(decode(version[0:12])) + +class FilteringOptions(object): + default_replace_text = b'***REMOVED***' + class AppendFilter(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + user_path = values + suffix = option_string[len('--path-'):] or 'match' + if suffix.startswith('rename'): + mod_type = 'rename' + match_type = option_string[len('--path-rename-'):] or 'match' + values = values.split(b':') + if len(values) != 2: + raise SystemExit(_("Error: --path-rename expects one colon in its" + " argument: .")) + if values[0] and values[1] and not ( + values[0].endswith(b'/') == values[1].endswith(b'/')): + raise SystemExit(_("Error: With --path-rename, if OLD_NAME and " + "NEW_NAME are both non-empty and either ends " + "with a slash then both must.")) + if any(v.startswith(b'/') for v in values): + raise SystemExit(_("Error: Pathnames cannot begin with a '/'")) + components = values[0].split(b'/') + values[1].split(b'/') + else: + mod_type = 'filter' + match_type = suffix + components = values.split(b'/') + if values.startswith(b'/'): + raise SystemExit(_("Error: Pathnames cannot begin with a '/'")) + for illegal_path in [b'.', b'..']: + if illegal_path in components: + raise SystemExit(_("Error: Invalid path component '%s' found in '%s'") + % (decode(illegal_path), decode(user_path))) + if match_type == 'regex': + values = re.compile(values) + items = getattr(namespace, self.dest, []) or [] + items.append((mod_type, match_type, values)) + if (match_type, mod_type) == ('glob', 'filter'): + if not values.endswith(b'*'): + extension = b'*' if values.endswith(b'/') else b'/*' + items.append((mod_type, match_type, values+extension)) + setattr(namespace, self.dest, items) + + class HelperFilter(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + af = FilteringOptions.AppendFilter(dest='path_changes', + option_strings=None) + dirname = values if values[-1:] == b'/' else values+b'/' + if option_string == '--subdirectory-filter': + af(parser, namespace, dirname, '--path-match') + af(parser, namespace, dirname+b':', '--path-rename') + elif option_string == '--to-subdirectory-filter': + af(parser, namespace, b':'+dirname, '--path-rename') + else: + raise SystemExit(_("Error: HelperFilter given invalid option_string: %s") + % option_string) # pragma: no cover + + class FileWithPathsFilter(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + if not namespace.path_changes: + namespace.path_changes = [] + namespace.path_changes += FilteringOptions.get_paths_from_file(values) + + @staticmethod + def create_arg_parser(): + # Include usage in the summary, so we can put the description first + summary = _('''Rewrite (or analyze) repository history + + git-filter-repo destructively rewrites history (unless --analyze or + --dry-run are given) according to specified rules. It refuses to do any + rewriting unless either run from a clean fresh clone, or --force was + given. + + Basic Usage: + git-filter-repo --analyze + git-filter-repo [FILTER/RENAME/CONTROL OPTIONS] + + See EXAMPLES section for details. + ''').rstrip() + + # Provide a long helpful examples section + example_text = _('''CALLBACKS + + Most callback functions are of the same general format. For a command line + argument like + --foo-callback 'BODY' + + the following code will be compiled and called: + def foo_callback(foo): + BODY + + The exception on callbacks is the --file-info-callback, which will be + discussed further below. + + Given the callback style, we can thus make a simple callback to replace + 'Jon' with 'John' in author/committer/tagger names: + git filter-repo --name-callback 'return name.replace(b"Jon", b"John")' + + To remove all 'Tested-by' tags in commit (or tag) messages: + git filter-repo --message-callback 'return re.sub(br"\\nTested-by:.*", "", message)' + + To remove all .DS_Store files: + git filter-repo --filename-callback 'return None if os.path.basename(filename) == b".DS_Store" else filename' + + Note that if BODY resolves to a filename, then the contents of that file + will be used as the BODY in the callback function. + + The --file-info-callback has a more involved function callback; for it the + following code will be compiled and called: + def file_info_callback(filename, mode, blob_id, value): + BODY + + It is designed to be used in cases where filtering depends on both + filename and contents (and maybe mode). It is called for file changes + other than deletions (since deletions have no file contents to operate + on). This callback is expected to return a tuple of (filename, mode, + blob_id). It can make use of the following functions from the value + instance: + value.get_contents_by_identifier(blob_id) -> contents (bytestring) + value.get_size_by_identifier(blob_id) -> size_of_blob (int) + value.insert_file_with_contents(contents) -> blob_id + value.is_binary(contents) -> bool + value.apply_replace_text(contents) -> new_contents (bytestring) + and can read/write the following data member from the value instance: + value.data (dict) + + The filename can be used for renaming the file similar to + --filename-callback (or None to drop the change), and mode is one + of b'100644', b'100755', b'120000', or b'160000'. + + For more detailed examples and explanations AND caveats, see + https://htmlpreview.github.io/?https://github.com/newren/git-filter-repo/blob/docs/html/git-filter-repo.html#CALLBACKS + +EXAMPLES + + To get a bunch of reports mentioning renames that have occurred in + your repo and listing sizes of objects aggregated by any of path, + directory, extension, or blob-id: + git filter-repo --analyze + + (These reports can help you choose how to filter your repo; it can + be useful to re-run this command after filtering to regenerate the + report and verify the changes look correct.) + + To extract the history that touched just 'guides' and 'tools/releases': + git filter-repo --path guides/ --path tools/releases + + To remove foo.zip and bar/baz/zips from every revision in history: + git filter-repo --path foo.zip --path bar/baz/zips/ --invert-paths + + To replace the text 'password' with 'p455w0rd': + git filter-repo --replace-text <(echo "password==>p455w0rd") + + To use the current version of the .mailmap file to update authors, + committers, and taggers throughout history and make it permanent: + git filter-repo --use-mailmap + + To extract the history of 'src/', rename all files to have a new leading + directory 'my-module' (e.g. src/foo.java -> my-module/src/foo.java), and + add a 'my-module-' prefix to all tags: + git filter-repo --path src/ --to-subdirectory-filter my-module --tag-rename '':'my-module-' + + For more detailed examples and explanations, see + https://htmlpreview.github.io/?https://github.com/newren/git-filter-repo/blob/docs/html/git-filter-repo.html#EXAMPLES''') + + # Create the basic parser + parser = argparse.ArgumentParser(description=summary, + usage = argparse.SUPPRESS, + add_help = False, + epilog = example_text, + formatter_class=argparse.RawDescriptionHelpFormatter) + + analyze = parser.add_argument_group(title=_("Analysis")) + analyze.add_argument('--analyze', action='store_true', + help=_("Analyze repository history and create a report that may be " + "useful in determining what to filter in a subsequent run. " + "Will not modify your repo.")) + analyze.add_argument('--report-dir', + metavar='DIR_OR_FILE', + type=os.fsencode, + dest='report_dir', + help=_("Directory to write report, defaults to GIT_DIR/filter_repo/analysis," + "refuses to run if exists, --force delete existing dir first.")) + + path = parser.add_argument_group(title=_("Filtering based on paths " + "(see also --filename-callback)"), + description=textwrap.dedent(_(""" + These options specify the paths to select. Note that much like git + itself, renames are NOT followed so you may need to specify multiple + paths, e.g. `--path olddir/ --path newdir/` + """[1:]))) + + path.add_argument('--invert-paths', action='store_false', dest='inclusive', + help=_("Invert the selection of files from the specified " + "--path-{match,glob,regex} options below, i.e. only select " + "files matching none of those options.")) + + path.add_argument('--path-match', '--path', metavar='DIR_OR_FILE', + type=os.fsencode, + action=FilteringOptions.AppendFilter, dest='path_changes', + help=_("Exact paths (files or directories) to include in filtered " + "history. Multiple --path options can be specified to get " + "a union of paths.")) + path.add_argument('--path-glob', metavar='GLOB', type=os.fsencode, + action=FilteringOptions.AppendFilter, dest='path_changes', + help=_("Glob of paths to include in filtered history. Multiple " + "--path-glob options can be specified to get a union of " + "paths.")) + path.add_argument('--path-regex', metavar='REGEX', type=os.fsencode, + action=FilteringOptions.AppendFilter, dest='path_changes', + help=_("Regex of paths to include in filtered history. Multiple " + "--path-regex options can be specified to get a union of " + "paths")) + path.add_argument('--use-base-name', action='store_true', + help=_("Match on file base name instead of full path from the top " + "of the repo. Incompatible with --path-rename, and " + "incompatible with matching against directory names.")) + + rename = parser.add_argument_group(title=_("Renaming based on paths " + "(see also --filename-callback)")) + rename.add_argument('--path-rename', '--path-rename-match', + metavar='OLD_NAME:NEW_NAME', dest='path_changes', type=os.fsencode, + action=FilteringOptions.AppendFilter, + help=_("Path to rename; if filename or directory matches OLD_NAME " + "rename to NEW_NAME. Multiple --path-rename options can be " + "specified. NOTE: If you combine filtering options with " + "renaming ones, do not rely on a rename argument to select " + "paths; you also need a filter to select them.")) + + helpers = parser.add_argument_group(title=_("Path shortcuts")) + helpers.add_argument('--paths', help=argparse.SUPPRESS, metavar='IGNORE') + helpers.add_argument('--paths-from-file', metavar='FILENAME', + type=os.fsencode, + action=FilteringOptions.FileWithPathsFilter, dest='path_changes', + help=_("Specify several path filtering and renaming directives, one " + "per line. Lines with '==>' in them specify path renames, " + "and lines can begin with 'literal:' (the default), 'glob:', " + "or 'regex:' to specify different matching styles. Blank " + "lines and lines starting with a '#' are ignored.")) + helpers.add_argument('--subdirectory-filter', metavar='DIRECTORY', + action=FilteringOptions.HelperFilter, type=os.fsencode, + help=_("Only look at history that touches the given subdirectory " + "and treat that directory as the project root. Equivalent " + "to using '--path DIRECTORY/ --path-rename DIRECTORY/:'")) + helpers.add_argument('--to-subdirectory-filter', metavar='DIRECTORY', + action=FilteringOptions.HelperFilter, type=os.fsencode, + help=_("Treat the project root as if it were under DIRECTORY. " + "Equivalent to using '--path-rename :DIRECTORY/'")) + + contents = parser.add_argument_group(title=_("Content editing filters " + "(see also --blob-callback)")) + contents.add_argument('--replace-text', metavar='EXPRESSIONS_FILE', + help=_("A file with expressions that, if found, will be replaced. " + "By default, each expression is treated as literal text, " + "but 'regex:' and 'glob:' prefixes are supported. You can " + "end the line with '==>' and some replacement text to " + "choose a replacement choice other than the default of '{}'." + .format(decode(FilteringOptions.default_replace_text)))) + contents.add_argument('--strip-blobs-bigger-than', metavar='SIZE', + dest='max_blob_size', default=0, + help=_("Strip blobs (files) bigger than specified size (e.g. '5M', " + "'2G', etc)")) + contents.add_argument('--strip-blobs-with-ids', metavar='BLOB-ID-FILENAME', + help=_("Read git object ids from each line of the given file, and " + "strip all of them from history")) + + refrename = parser.add_argument_group(title=_("Renaming of refs " + "(see also --refname-callback)")) + refrename.add_argument('--tag-rename', metavar='OLD:NEW', type=os.fsencode, + help=_("Rename tags starting with OLD to start with NEW. For " + "example, --tag-rename foo:bar will rename tag foo-1.2.3 " + "to bar-1.2.3; either OLD or NEW can be empty.")) + + messages = parser.add_argument_group(title=_("Filtering of commit messages " + "(see also --message-callback)")) + messages.add_argument('--replace-message', metavar='EXPRESSIONS_FILE', + help=_("A file with expressions that, if found in commit or tag " + "messages, will be replaced. This file uses the same syntax " + "as --replace-text.")) + messages.add_argument('--preserve-commit-hashes', action='store_true', + help=_("By default, since commits are rewritten and thus gain new " + "hashes, references to old commit hashes in commit messages " + "are replaced with new commit hashes (abbreviated to the same " + "length as the old reference). Use this flag to turn off " + "updating commit hashes in commit messages.")) + messages.add_argument('--preserve-commit-encoding', action='store_true', + help=_("Do not reencode commit messages into UTF-8. By default, if " + "the commit object specifies an encoding for the commit " + "message, the message is re-encoded into UTF-8.")) + + people = parser.add_argument_group(title=_("Filtering of names & emails " + "(see also --name-callback " + "and --email-callback)")) + people.add_argument('--mailmap', dest='mailmap', metavar='FILENAME', + type=os.fsencode, + help=_("Use specified mailmap file (see git-shortlog(1) for " + "details on the format) when rewriting author, committer, " + "and tagger names and emails. If the specified file is " + "part of git history, historical versions of the file will " + "be ignored; only the current contents are consulted.")) + people.add_argument('--use-mailmap', dest='mailmap', + action='store_const', const=b'.mailmap', + help=_("Same as: '--mailmap .mailmap' ")) + + parents = parser.add_argument_group(title=_("Parent rewriting")) + parents.add_argument('--replace-refs', default=None, + choices=['delete-no-add', 'delete-and-add', + 'update-no-add', 'update-or-add', + 'update-and-add', 'old-default'], + help=_("How to handle replace refs (see git-replace(1)). Replace " + "refs can be added during the history rewrite as a way to " + "allow users to pass old commit IDs (from before " + "git-filter-repo was run) to git commands and have git know " + "how to translate those old commit IDs to the new " + "(post-rewrite) commit IDs. Also, replace refs that existed " + "before the rewrite can either be deleted or updated. The " + "choices to pass to --replace-refs thus need to specify both " + "what to do with existing refs and what to do with commit " + "rewrites. Thus 'update-and-add' means to update existing " + "replace refs, and for any commit rewrite (even if already " + "pointed at by a replace ref) add a new refs/replace/ reference " + "to map from the old commit ID to the new commit ID. The " + "default is update-no-add, meaning update existing replace refs " + "but do not add any new ones. There is also a special " + "'old-default' option for picking the default used in versions " + "prior to git-filter-repo-2.45, namely 'update-and-add' upon " + "the first run of git-filter-repo in a repository and " + "'update-or-add' if running git-filter-repo again on a " + "repository.")) + parents.add_argument('--prune-empty', default='auto', + choices=['always', 'auto', 'never'], + help=_("Whether to prune empty commits. 'auto' (the default) means " + "only prune commits which become empty (not commits which were " + "empty in the original repo, unless their parent was pruned). " + "When the parent of a commit is pruned, the first non-pruned " + "ancestor becomes the new parent.")) + parents.add_argument('--prune-degenerate', default='auto', + choices=['always', 'auto', 'never'], + help=_("Since merge commits are needed for history topology, they " + "are typically exempt from pruning. However, they can become " + "degenerate with the pruning of other commits (having fewer " + "than two parents, having one commit serve as both parents, or " + "having one parent as the ancestor of the other.) If such " + "merge commits have no file changes, they can be pruned. The " + "default ('auto') is to only prune empty merge commits which " + "become degenerate (not which started as such).")) + parents.add_argument('--no-ff', action='store_true', + help=_("Even if the first parent is or becomes an ancestor of another " + "parent, do not prune it. This modifies how " + "--prune-degenerate behaves, and may be useful in projects who " + "always use merge --no-ff.")) + + callback = parser.add_argument_group(title=_("Generic callback code snippets")) + callback.add_argument('--filename-callback', metavar="FUNCTION_BODY_OR_FILE", + help=_("Python code body for processing filenames; see CALLBACKS " + "sections below.")) + callback.add_argument('--file-info-callback', metavar="FUNCTION_BODY_OR_FILE", + help=_("Python code body for processing file and metadata; see " + "CALLBACKS sections below.")) + callback.add_argument('--message-callback', metavar="FUNCTION_BODY_OR_FILE", + help=_("Python code body for processing messages (both commit " + "messages and tag messages); see CALLBACKS section below.")) + callback.add_argument('--name-callback', metavar="FUNCTION_BODY_OR_FILE", + help=_("Python code body for processing names of people; see " + "CALLBACKS section below.")) + callback.add_argument('--email-callback', metavar="FUNCTION_BODY_OR_FILE", + help=_("Python code body for processing emails addresses; see " + "CALLBACKS section below.")) + callback.add_argument('--refname-callback', metavar="FUNCTION_BODY_OR_FILE", + help=_("Python code body for processing refnames; see CALLBACKS " + "section below.")) + + callback.add_argument('--blob-callback', metavar="FUNCTION_BODY_OR_FILE", + help=_("Python code body for processing blob objects; see " + "CALLBACKS section below.")) + callback.add_argument('--commit-callback', metavar="FUNCTION_BODY_OR_FILE", + help=_("Python code body for processing commit objects; see " + "CALLBACKS section below.")) + callback.add_argument('--tag-callback', metavar="FUNCTION_BODY_OR_FILE", + help=_("Python code body for processing tag objects. Note that " + "lightweight tags have no tag object and are thus not " + "handled by this callback. See CALLBACKS section below.")) + callback.add_argument('--reset-callback', metavar="FUNCTION_BODY_OR_FILE", + help=_("Python code body for processing reset objects; see " + "CALLBACKS section below.")) + + sdr = parser.add_argument_group(title=_("Sensitive Data Removal Handling")) + sdr.add_argument('--sensitive-data-removal', '--sdr', action='store_true', + help=_("This rewrite is intended to remove sensitive data from a " + "repository. Gather extra information from the rewrite needed " + "to provide additional instructions on how to clean up other " + "copies.")) + sdr.add_argument('--no-fetch', action='store_true', + help=_("By default, --sensitive-data-removal will trigger a " + "mirror-like fetch of all refs from origin, discarding local " + "changes, but ensuring that _all_ fetchable refs that hold on " + "to the sensitve data are rewritten. This flag removes that " + "fetch, risking that other refs continue holding on to the " + "sensitive data. This option is implied by --partial or any " + "flag that implies --partial.")) + + desc = _( + "Specifying alternate source or target locations implies --partial,\n" + "except that the normal default for --replace-refs is used. However,\n" + "unlike normal uses of --partial, this doesn't risk mixing old and new\n" + "history since the old and new histories are in different repositories.") + location = parser.add_argument_group(title=_("Location to filter from/to"), + description=desc) + location.add_argument('--source', type=os.fsencode, + help=_("Git repository to read from")) + location.add_argument('--target', type=os.fsencode, + help=_("Git repository to overwrite with filtered history")) + + order = parser.add_argument_group(title=_("Ordering of commits")) + order.add_argument('--date-order', action='store_true', + help=_("Processes commits in commit timestamp order.")) + + misc = parser.add_argument_group(title=_("Miscellaneous options")) + misc.add_argument('--help', '-h', action='store_true', + help=_("Show this help message and exit.")) + misc.add_argument('--version', action='store_true', + help=_("Display filter-repo's version and exit.")) + misc.add_argument('--proceed', action='store_true', + help=_("Avoid triggering the no-arguments-specified check.")) + misc.add_argument('--force', '-f', action='store_true', + help=_("Rewrite repository history even if the current repo does not " + "look like a fresh clone. History rewriting is irreversible " + "(and includes immediate pruning of reflogs and old objects), " + "so be cautious about using this flag.")) + misc.add_argument('--partial', action='store_true', + help=_("Do a partial history rewrite, resulting in the mixture of " + "old and new history. This disables rewriting " + "refs/remotes/origin/* to refs/heads/*, disables removing " + "of the 'origin' remote, disables removing unexported refs, " + "disables expiring the reflog, and disables the automatic " + "post-filter gc. Also, this modifies --tag-rename and " + "--refname-callback options such that instead of replacing " + "old refs with new refnames, it will instead create new " + "refs and keep the old ones around. Use with caution.")) + misc.add_argument('--no-gc', action='store_true', + help=_("Do not run 'git gc' after filtering.")) + # WARNING: --refs presents a problem with become-degenerate pruning: + # * Excluding a commit also excludes its ancestors so when some other + # commit has an excluded ancestor as a parent we have no way of + # knowing what it is an ancestor of without doing a special + # full-graph walk. + misc.add_argument('--refs', nargs='+', + help=_("Limit history rewriting to the specified refs. Implies " + "--partial. In addition to the normal caveats of --partial " + "(mixing old and new history, no automatic remapping of " + "refs/remotes/origin/* to refs/heads/*, etc.), this also may " + "cause problems for pruning of degenerate empty merge " + "commits when negative revisions are specified.")) + + misc.add_argument('--dry-run', action='store_true', + help=_("Do not change the repository. Run `git fast-export` and " + "filter its output, and save both the original and the " + "filtered version for comparison. This also disables " + "rewriting commit messages due to not knowing new commit " + "IDs and disables filtering of some empty commits due to " + "inability to query the fast-import backend." )) + misc.add_argument('--debug', action='store_true', + help=_("Print additional information about operations being " + "performed and commands being run. When used together " + "with --dry-run, also show extra information about what " + "would be run.")) + # WARNING: --state-branch has some problems: + # * It does not work well with manually inserted objects (user creating + # Blob() or Commit() or Tag() objects and calling + # RepoFilter.insert(obj) on them). + # * It does not work well with multiple source or multiple target repos + # * It doesn't work so well with pruning become-empty commits (though + # --refs doesn't work so well with it either) + # These are probably fixable, given some work (e.g. re-importing the + # graph at the beginning to get the AncestryGraph right, doing our own + # export of marks instead of using fast-export --export-marks, etc.), but + # for now just hide the option. + misc.add_argument('--state-branch', + #help=_("Enable incremental filtering by saving the mapping of old " + # "to new objects to the specified branch upon exit, and" + # "loading that mapping from that branch (if it exists) " + # "upon startup.")) + help=argparse.SUPPRESS) + misc.add_argument('--stdin', action='store_true', + help=_("Instead of running `git fast-export` and filtering its " + "output, filter the fast-export stream from stdin. The " + "stdin must be in the expected input format (e.g. it needs " + "to include original-oid directives).")) + misc.add_argument('--quiet', action='store_true', + help=_("Pass --quiet to other git commands called")) + return parser + + @staticmethod + def sanity_check_args(args): + if args.analyze and args.path_changes: + raise SystemExit(_("Error: --analyze is incompatible with --path* flags; " + "it's a read-only operation.")) + if args.analyze and args.stdin: + raise SystemExit(_("Error: --analyze is incompatible with --stdin.")) + # If no path_changes are found, initialize with empty list but mark as + # not inclusive so that all files match + if args.path_changes == None: + args.path_changes = [] + args.inclusive = False + else: + # Similarly, if we have no filtering paths, then no path should be + # filtered out. Based on how newname() works, the easiest way to + # achieve that is setting args.inclusive to False. + if not any(x[0] == 'filter' for x in args.path_changes): + args.inclusive = False + # Also check for incompatible --use-base-name and --path-rename flags. + if args.use_base_name: + if any(x[0] == 'rename' for x in args.path_changes): + raise SystemExit(_("Error: --use-base-name and --path-rename are " + "incompatible.")) + # Also throw some sanity checks on git version here; + # PERF: remove these checks once new enough git versions are common + p = subproc.Popen('git fast-export -h'.split(), + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + output = p.stdout.read() + if b'--anonymize-map' not in output: # pragma: no cover + global date_format_permissive + date_format_permissive = False + if not any(x in output for x in [b'--mark-tags',b'--[no-]mark-tags']): # pragma: no cover + global write_marks + write_marks = False + if args.state_branch: + # We need a version of git-fast-export with --mark-tags + raise SystemExit(_("Error: need git >= 2.24.0")) + if not any(x in output for x in [b'--reencode', b'--[no-]reencode']): # pragma: no cover + if args.preserve_commit_encoding: + # We need a version of git-fast-export with --reencode + raise SystemExit(_("Error: need git >= 2.23.0")) + else: + # Set args.preserve_commit_encoding to None which we'll check for later + # to avoid passing --reencode=yes to fast-export (that option was the + # default prior to git-2.23) + args.preserve_commit_encoding = None + # If we don't have fast-exoprt --reencode, we may also be missing + # diff-tree --combined-all-paths, which is even more important... + p = subproc.Popen('git diff-tree -h'.split(), + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + output = p.stdout.read() + if b'--combined-all-paths' not in output: + # We need a version of git-diff-tree with --combined-all-paths + raise SystemExit(_("Error: need git >= 2.22.0")) + if args.sensitive_data_removal: + p = subproc.Popen('git cat-file -h'.split(), + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + output = p.stdout.read() + if b"--batch-command" not in output: # pragma: no cover + raise SystemExit(_("Error: need git >= 2.36.0")) + # End of sanity checks on git version + if args.max_blob_size: + suffix = args.max_blob_size[-1] + if suffix not in '1234567890': + mult = {'K': 1024, 'M': 1024**2, 'G': 1024**3} + if suffix not in mult: + raise SystemExit(_("Error: could not parse --strip-blobs-bigger-than" + " argument %s") + % args.max_blob_size) + args.max_blob_size = int(args.max_blob_size[0:-1]) * mult[suffix] + else: + args.max_blob_size = int(args.max_blob_size) + if args.file_info_callback and ( + args.stdin or args.blob_callback or args.filename_callback): + raise SystemExit(_("Error: --file-info-callback is incompatible with " + "--stdin, --blob-callback,\nand --filename-callback.")) + + @staticmethod + def get_replace_text(filename): + replace_literals = [] + replace_regexes = [] + with open(filename, 'br') as f: + for line in f: + line = line.rstrip(b'\r\n') + + # Determine the replacement + replacement = FilteringOptions.default_replace_text + if b'==>' in line: + line, replacement = line.rsplit(b'==>', 1) + + # See if we need to match via regex + regex = None + if line.startswith(b'regex:'): + regex = line[6:] + elif line.startswith(b'glob:'): + regex = glob_to_regex(line[5:]) + if regex: + replace_regexes.append((re.compile(regex), replacement)) + else: + # Otherwise, find the literal we need to replace + if line.startswith(b'literal:'): + line = line[8:] + if not line: + continue + replace_literals.append((line, replacement)) + return {'literals': replace_literals, 'regexes': replace_regexes} + + @staticmethod + def get_paths_from_file(filename): + new_path_changes = [] + with open(filename, 'br') as f: + for line in f: + line = line.rstrip(b'\r\n') + + # Skip blank lines + if not line: + continue + # Skip comment lines + if line.startswith(b'#'): + continue + + # Determine the replacement + match_type, repl = 'literal', None + if b'==>' in line: + line, repl = line.rsplit(b'==>', 1) + + # See if we need to match via regex + match_type = 'match' # a.k.a. 'literal' + if line.startswith(b'regex:'): + match_type = 'regex' + match = re.compile(line[6:]) + elif line.startswith(b'glob:'): + match_type = 'glob' + match = line[5:] + if repl: + raise SystemExit(_("Error: In %s, 'glob:' and '==>' are incompatible (renaming globs makes no sense)" % decode(filename))) + else: + if line.startswith(b'literal:'): + match = line[8:] + else: + match = line + if repl is not None: + if match and repl and match.endswith(b'/') != repl.endswith(b'/'): + raise SystemExit(_("Error: When rename directories, if OLDNAME " + "and NEW_NAME are both non-empty and either " + "ends with a slash then both must.")) + + # Record the filter or rename + if repl is not None: + new_path_changes.append(['rename', match_type, (match, repl)]) + else: + new_path_changes.append(['filter', match_type, match]) + if match_type == 'glob' and not match.endswith(b'*'): + extension = b'*' if match.endswith(b'/') else b'/*' + new_path_changes.append(['filter', match_type, match+extension]) + return new_path_changes + + @staticmethod + def default_options(): + return FilteringOptions.parse_args([], error_on_empty = False) + + @staticmethod + def parse_args(input_args, error_on_empty = True): + parser = FilteringOptions.create_arg_parser() + if not input_args and error_on_empty: + parser.print_usage() + raise SystemExit(_("No arguments specified.")) + args = parser.parse_args(input_args) + if args.help: + parser.print_help() + raise SystemExit() + if args.paths: + raise SystemExit("Error: Option `--paths` unrecognized; did you mean --path or --paths-from-file?") + if args.version: + GitUtils.print_my_version() + raise SystemExit() + FilteringOptions.sanity_check_args(args) + if args.mailmap: + args.mailmap = MailmapInfo(args.mailmap) + if args.replace_text: + args.replace_text = FilteringOptions.get_replace_text(args.replace_text) + if args.replace_message: + args.replace_message = FilteringOptions.get_replace_text(args.replace_message) + if args.strip_blobs_with_ids: + with open(args.strip_blobs_with_ids, 'br') as f: + args.strip_blobs_with_ids = set(f.read().split()) + else: + args.strip_blobs_with_ids = set() + if (args.partial or args.refs) and not args.replace_refs: + args.replace_refs = 'update-no-add' + args.repack = not (args.partial or args.refs or args.no_gc) + if args.refs or args.source or args.target: + args.partial = True + if args.partial: + args.no_fetch = True + if not args.refs: + args.refs = ['--all'] + return args + +class RepoAnalyze(object): + + # First, several helper functions for analyze_commit() + + @staticmethod + def equiv_class(stats, filename): + return stats['equivalence'].get(filename, (filename,)) + + @staticmethod + def setup_equivalence_for_rename(stats, oldname, newname): + # if A is renamed to B and B is renamed to C, then the user thinks of + # A, B, and C as all being different names for the same 'file'. We record + # this as an equivalence class: + # stats['equivalence'][name] = (A,B,C) + # for name being each of A, B, and C. + old_tuple = stats['equivalence'].get(oldname, ()) + if newname in old_tuple: + return + elif old_tuple: + new_tuple = tuple(list(old_tuple)+[newname]) + else: + new_tuple = (oldname, newname) + for f in new_tuple: + stats['equivalence'][f] = new_tuple + + @staticmethod + def setup_or_update_rename_history(stats, commit, oldname, newname): + rename_commits = stats['rename_history'].get(oldname, set()) + rename_commits.add(commit) + stats['rename_history'][oldname] = rename_commits + + @staticmethod + def handle_renames(stats, commit, change_types, filenames): + for index, change_type in enumerate(change_types): + if change_type == ord(b'R'): + oldname, newname = filenames[index], filenames[-1] + RepoAnalyze.setup_equivalence_for_rename(stats, oldname, newname) + RepoAnalyze.setup_or_update_rename_history(stats, commit, + oldname, newname) + + @staticmethod + def handle_file(stats, graph, commit, modes, shas, filenames): + mode, sha, filename = modes[-1], shas[-1], filenames[-1] + + # Figure out kind of deletions to undo for this file, and update lists + # of all-names-by-sha and all-filenames + delmode = 'tree_deletions' + if mode != b'040000': + delmode = 'file_deletions' + stats['names'][sha].add(filename) + stats['allnames'].add(filename) + + # If the file (or equivalence class of files) was recorded as deleted, + # clearly it isn't anymore + equiv = RepoAnalyze.equiv_class(stats, filename) + for f in equiv: + stats[delmode].pop(f, None) + + # If we get a modify/add for a path that was renamed, we may need to break + # the equivalence class. However, if the modify/add was on a branch that + # doesn't have the rename in its history, we are still okay. + need_to_break_equivalence = False + if equiv[-1] != filename: + for rename_commit in stats['rename_history'][filename]: + if graph.is_ancestor(rename_commit, commit): + need_to_break_equivalence = True + + if need_to_break_equivalence: + for f in equiv: + if f in stats['equivalence']: + del stats['equivalence'][f] + + @staticmethod + def analyze_commit(stats, graph, commit, parents, date, file_changes): + graph.add_commit_and_parents(commit, parents) + for change in file_changes: + modes, shas, change_types, filenames = change + if len(parents) == 1 and change_types.startswith(b'R'): + change_types = b'R' # remove the rename score; we don't care + if modes[-1] == b'160000': + continue + elif modes[-1] == b'000000': + # Track when files/directories are deleted + for f in RepoAnalyze.equiv_class(stats, filenames[-1]): + if any(x == b'040000' for x in modes[0:-1]): + stats['tree_deletions'][f] = date + else: + stats['file_deletions'][f] = date + elif change_types.strip(b'AMT') == b'': + RepoAnalyze.handle_file(stats, graph, commit, modes, shas, filenames) + elif modes[-1] == b'040000' and change_types.strip(b'RAM') == b'': + RepoAnalyze.handle_file(stats, graph, commit, modes, shas, filenames) + elif change_types.strip(b'RAMT') == b'': + RepoAnalyze.handle_file(stats, graph, commit, modes, shas, filenames) + RepoAnalyze.handle_renames(stats, commit, change_types, filenames) + else: + raise SystemExit(_("Unhandled change type(s): %(change_type)s " + "(in commit %(commit)s)") + % ({'change_type': change_types, 'commit': commit}) + ) # pragma: no cover + + @staticmethod + def gather_data(args): + unpacked_size, packed_size = GitUtils.get_blob_sizes() + stats = {'names': collections.defaultdict(set), + 'allnames' : set(), + 'file_deletions': {}, + 'tree_deletions': {}, + 'equivalence': {}, + 'rename_history': collections.defaultdict(set), + 'unpacked_size': unpacked_size, + 'packed_size': packed_size, + 'num_commits': 0} + + # Setup the rev-list/diff-tree process + processed_commits_msg = _("Processed %d commits") + commit_parse_progress = ProgressWriter() + num_commits = 0 + cmd = ('git rev-list --topo-order --reverse {}'.format(' '.join(args.refs)) + + ' | git diff-tree --stdin --always --root --format=%H%n%P%n%cd' + + ' --date=short -M -t -c --raw --combined-all-paths') + dtp = subproc.Popen(cmd, shell=True, bufsize=-1, stdout=subprocess.PIPE) + f = dtp.stdout + line = f.readline() + if not line: + raise SystemExit(_("Nothing to analyze; repository is empty.")) + cont = bool(line) + graph = AncestryGraph() + while cont: + commit = line.rstrip() + parents = f.readline().split() + date = f.readline().rstrip() + + # We expect a blank line next; if we get a non-blank line then + # this commit modified no files and we need to move on to the next. + # If there is no line, we've reached end-of-input. + line = f.readline() + if not line: + cont = False + line = line.rstrip() + + # If we haven't reached end of input, and we got a blank line meaning + # a commit that has modified files, then get the file changes associated + # with this commit. + file_changes = [] + if cont and not line: + cont = False + for line in f: + if not line.startswith(b':'): + cont = True + break + n = 1+max(1, len(parents)) + assert line.startswith(b':'*(n-1)) + relevant = line[n-1:-1] + splits = relevant.split(None, n) + modes = splits[0:n] + splits = splits[n].split(None, n) + shas = splits[0:n] + splits = splits[n].split(b'\t') + change_types = splits[0] + filenames = [PathQuoting.dequote(x) for x in splits[1:]] + file_changes.append([modes, shas, change_types, filenames]) + + # If someone is trying to analyze a subset of the history, make sure + # to avoid dying on commits with parents that we haven't seen before + if args.refs: + graph.record_external_commits([p for p in parents + if not p in graph.value]) + + # Analyze this commit and update progress + RepoAnalyze.analyze_commit(stats, graph, commit, parents, date, + file_changes) + num_commits += 1 + commit_parse_progress.show(processed_commits_msg % num_commits) + + # Show the final commits processed message and record the number of commits + commit_parse_progress.finish() + stats['num_commits'] = num_commits + + # Close the output, ensure rev-list|diff-tree pipeline completed successfully + dtp.stdout.close() + if dtp.wait(): + raise SystemExit(_("Error: rev-list|diff-tree pipeline failed; see above.")) # pragma: no cover + + return stats + + @staticmethod + def write_report(reportdir, stats): + def datestr(datetimestr): + return datetimestr if datetimestr else _('').encode() + + def dirnames(path): + while True: + path = os.path.dirname(path) + yield path + if path == b'': + break + + # Compute aggregate size information for paths, extensions, and dirs + total_size = {'packed': 0, 'unpacked': 0} + path_size = {'packed': collections.defaultdict(int), + 'unpacked': collections.defaultdict(int)} + ext_size = {'packed': collections.defaultdict(int), + 'unpacked': collections.defaultdict(int)} + dir_size = {'packed': collections.defaultdict(int), + 'unpacked': collections.defaultdict(int)} + for sha in stats['names']: + size = {'packed': stats['packed_size'][sha], + 'unpacked': stats['unpacked_size'][sha]} + for which in ('packed', 'unpacked'): + for name in stats['names'][sha]: + total_size[which] += size[which] + path_size[which][name] += size[which] + basename, ext = os.path.splitext(name) + ext_size[which][ext] += size[which] + for dirname in dirnames(name): + dir_size[which][dirname] += size[which] + + # Determine if and when extensions and directories were deleted + ext_deleted_data = {} + for name in stats['allnames']: + when = stats['file_deletions'].get(name, None) + + # Update the extension + basename, ext = os.path.splitext(name) + if when is None: + ext_deleted_data[ext] = None + elif ext in ext_deleted_data: + if ext_deleted_data[ext] is not None: + ext_deleted_data[ext] = max(ext_deleted_data[ext], when) + else: + ext_deleted_data[ext] = when + + dir_deleted_data = {} + for name in dir_size['packed']: + dir_deleted_data[name] = stats['tree_deletions'].get(name, None) + + with open(os.path.join(reportdir, b"README"), 'bw') as f: + # Give a basic overview of this file + f.write(b"== %s ==\n" % _("Overall Statistics").encode()) + f.write((" %s: %d\n" % (_("Number of commits"), + stats['num_commits'])).encode()) + f.write((" %s: %d\n" % (_("Number of filenames"), + len(path_size['packed']))).encode()) + f.write((" %s: %d\n" % (_("Number of directories"), + len(dir_size['packed']))).encode()) + f.write((" %s: %d\n" % (_("Number of file extensions"), + len(ext_size['packed']))).encode()) + f.write(b"\n") + f.write((" %s: %d\n" % (_("Total unpacked size (bytes)"), + total_size['unpacked'])).encode()) + f.write((" %s: %d\n" % (_("Total packed size (bytes)"), + total_size['packed'])).encode()) + f.write(b"\n") + + # Mention issues with the report + f.write(("== %s ==\n" % _("Caveats")).encode()) + f.write(("=== %s ===\n" % _("Sizes")).encode()) + f.write(textwrap.dedent(_(""" + Packed size represents what size your repository would be if no + trees, commits, tags, or other metadata were included (though it may + fail to represent de-duplication; see below). It also represents the + current packing, which may be suboptimal if you haven't gc'ed for a + while. + + Unpacked size represents what size your repository would be if no + trees, commits, tags, or other metadata were included AND if no + files were packed; i.e., without delta-ing or compression. + + Both unpacked and packed sizes can be slightly misleading. Deleting + a blob from history not save as much space as the unpacked size, + because it is obviously normally stored in packed form. Also, + deleting a blob from history may not save as much space as its packed + size either, because another blob could be stored as a delta against + that blob, so when you remove one blob another blob's packed size may + grow. + + Also, the sum of the packed sizes can add up to more than the + repository size; if the same contents appeared in the repository in + multiple places, git will automatically de-dupe and store only one + copy, while the way sizes are added in this analysis adds the size + for each file path that has those contents. Further, if a file is + ever reverted to a previous version's contents, the previous + version's size will be counted multiple times in this analysis, even + though git will only store it once. + """)[1:]).encode()) + f.write(b"\n") + f.write(("=== %s ===\n" % _("Deletions")).encode()) + f.write(textwrap.dedent(_(""" + Whether a file is deleted is not a binary quality, since it can be + deleted on some branches but still exist in others. Also, it might + exist in an old tag, but have been deleted in versions newer than + that. More thorough tracking could be done, including looking at + merge commits where one side of history deleted and the other modified, + in order to give a more holistic picture of deletions. However, that + algorithm would not only be more complex to implement, it'd also be + quite difficult to present and interpret by users. Since --analyze + is just about getting a high-level rough picture of history, it instead + implements the simplistic rule that is good enough for 98% of cases: + A file is marked as deleted if the last commit in the fast-export + stream that mentions the file lists it as deleted. + This makes it dependent on topological ordering, but generally gives + the "right" answer. + """)[1:]).encode()) + f.write(b"\n") + f.write(("=== %s ===\n" % _("Renames")).encode()) + f.write(textwrap.dedent(_(""" + Renames share the same non-binary nature that deletions do, plus + additional challenges: + * If the renamed file is renamed again, instead of just two names for + a path you can have three or more. + * Rename pairs of the form (oldname, newname) that we consider to be + different names of the "same file" might only be valid over certain + commit ranges. For example, if a new commit reintroduces a file + named oldname, then new versions of oldname aren't the "same file" + anymore. We could try to portray this to the user, but it's easier + for the user to just break the pairing and only report unbroken + rename pairings to the user. + * The ability for users to rename files differently in different + branches means that our chains of renames will not necessarily be + linear but may branch out. + """)[1:]).encode()) + f.write(b"\n") + + # Equivalence classes for names, so if folks only want to keep a + # certain set of paths, they know the old names they want to include + # too. + with open(os.path.join(reportdir, b"renames.txt"), 'bw') as f: + seen = set() + for pathname,equiv_group in sorted(stats['equivalence'].items(), + key=lambda x:(x[1], x[0])): + if equiv_group in seen: + continue + seen.add(equiv_group) + f.write(("{} ->\n ".format(decode(equiv_group[0])) + + "\n ".join(decode(x) for x in equiv_group[1:]) + + "\n").encode()) + + # List directories in reverse sorted order of unpacked size + with open(os.path.join(reportdir, b"directories-deleted-sizes.txt"), 'bw') as f: + msg = "=== %s ===\n" % _("Deleted directories by reverse size") + f.write(msg.encode()) + msg = _("Format: unpacked size, packed size, date deleted, directory name\n") + f.write(msg.encode()) + for dirname, size in sorted(dir_size['packed'].items(), + key=lambda x:(x[1],x[0]), reverse=True): + if (dir_deleted_data[dirname]): + f.write(b" %10d %10d %-10s %s\n" % (dir_size['unpacked'][dirname], + size, + datestr(dir_deleted_data[dirname]), + dirname or _('').encode())) + + with open(os.path.join(reportdir, b"directories-all-sizes.txt"), 'bw') as f: + f.write(("=== %s ===\n" % _("All directories by reverse size")).encode()) + msg = _("Format: unpacked size, packed size, date deleted, directory name\n") + f.write(msg.encode()) + for dirname, size in sorted(dir_size['packed'].items(), + key=lambda x:(x[1],x[0]), reverse=True): + f.write(b" %10d %10d %-10s %s\n" % (dir_size['unpacked'][dirname], + size, + datestr(dir_deleted_data[dirname]), + dirname or _("").encode())) + + # List extensions in reverse sorted order of unpacked size + with open(os.path.join(reportdir, b"extensions-deleted-sizes.txt"), 'bw') as f: + msg = "=== %s ===\n" % _("Deleted extensions by reverse size") + f.write(msg.encode()) + msg = _("Format: unpacked size, packed size, date deleted, extension name\n") + f.write(msg.encode()) + for extname, size in sorted(ext_size['packed'].items(), + key=lambda x:(x[1],x[0]), reverse=True): + if (ext_deleted_data[extname]): + f.write(b" %10d %10d %-10s %s\n" % (ext_size['unpacked'][extname], + size, + datestr(ext_deleted_data[extname]), + extname or _('').encode())) + + with open(os.path.join(reportdir, b"extensions-all-sizes.txt"), 'bw') as f: + f.write(("=== %s ===\n" % _("All extensions by reverse size")).encode()) + msg = _("Format: unpacked size, packed size, date deleted, extension name\n") + f.write(msg.encode()) + for extname, size in sorted(ext_size['packed'].items(), + key=lambda x:(x[1],x[0]), reverse=True): + f.write(b" %10d %10d %-10s %s\n" % (ext_size['unpacked'][extname], + size, + datestr(ext_deleted_data[extname]), + extname or _('').encode())) + + # List files in reverse sorted order of unpacked size + with open(os.path.join(reportdir, b"path-deleted-sizes.txt"), 'bw') as f: + msg = "=== %s ===\n" % _("Deleted paths by reverse accumulated size") + f.write(msg.encode()) + msg = _("Format: unpacked size, packed size, date deleted, path name(s)\n") + f.write(msg.encode()) + for pathname, size in sorted(path_size['packed'].items(), + key=lambda x:(x[1],x[0]), reverse=True): + when = stats['file_deletions'].get(pathname, None) + if when: + f.write(b" %10d %10d %-10s %s\n" % (path_size['unpacked'][pathname], + size, + datestr(when), + pathname)) + + with open(os.path.join(reportdir, b"path-all-sizes.txt"), 'bw') as f: + msg = "=== %s ===\n" % _("All paths by reverse accumulated size") + f.write(msg.encode()) + msg = _("Format: unpacked size, packed size, date deleted, path name\n") + f.write(msg.encode()) + for pathname, size in sorted(path_size['packed'].items(), + key=lambda x:(x[1],x[0]), reverse=True): + when = stats['file_deletions'].get(pathname, None) + f.write(b" %10d %10d %-10s %s\n" % (path_size['unpacked'][pathname], + size, + datestr(when), + pathname)) + + # List of filenames and sizes in descending order + with open(os.path.join(reportdir, b"blob-shas-and-paths.txt"), 'bw') as f: + f.write(("=== %s ===\n" % _("Files by sha and associated pathnames in reverse size")).encode()) + f.write(_("Format: sha, unpacked size, packed size, filename(s) object stored as\n").encode()) + for sha, size in sorted(stats['packed_size'].items(), + key=lambda x:(x[1],x[0]), reverse=True): + if sha not in stats['names']: + # Some objects in the repository might not be referenced, or not + # referenced by the branches/tags the user cares about; skip them. + continue + names_with_sha = stats['names'][sha] + if len(names_with_sha) == 1: + names_with_sha = names_with_sha.pop() + else: + names_with_sha = b'[' + b', '.join(sorted(names_with_sha)) + b']' + f.write(b" %s %10d %10d %s\n" % (sha, + stats['unpacked_size'][sha], + size, + names_with_sha)) + + @staticmethod + def run(args): + if args.report_dir: + reportdir = args.report_dir + else: + git_dir = GitUtils.determine_git_dir(b'.') + + # Create the report directory as necessary + results_tmp_dir = os.path.join(git_dir, b'filter-repo') + if not os.path.isdir(results_tmp_dir): + os.mkdir(results_tmp_dir) + reportdir = os.path.join(results_tmp_dir, b"analysis") + + if os.path.isdir(reportdir): + if args.force: + sys.stdout.write(_("Warning: Removing recursively: \"%s\"\n") % decode(reportdir)) + shutil.rmtree(reportdir) + else: + sys.stdout.write(_("Error: dir already exists (use --force to delete): \"%s\"\n") % decode(reportdir)) + sys.exit(1) + + os.mkdir(reportdir) + + # Gather the data we need + stats = RepoAnalyze.gather_data(args) + + # Write the reports + sys.stdout.write(_("Writing reports to \"%s\"...") % decode(reportdir)) + sys.stdout.flush() + RepoAnalyze.write_report(reportdir, stats) + sys.stdout.write(_("done.\n")) + sys.stdout.write(_("README: \"%s\"\n") % decode( os.path.join(reportdir, b"README") )) + +class FileInfoValueHelper: + def __init__(self, replace_text, insert_blob_func, source_working_dir): + self.data = {} + self._replace_text = replace_text + self._insert_blob_func = insert_blob_func + cmd = ['git', 'cat-file', '--batch-command'] + self._cat_file_process = subproc.Popen(cmd, + stdin = subprocess.PIPE, + stdout = subprocess.PIPE, + cwd = source_working_dir) + + def finalize(self): + self._cat_file_process.stdin.close() + self._cat_file_process.wait() + + def get_contents_by_identifier(self, blobhash): + self._cat_file_process.stdin.write(b'contents '+blobhash+b'\n') + self._cat_file_process.stdin.flush() + line = self._cat_file_process.stdout.readline() + try: + (oid, oidtype, size) = line.split() + except ValueError: + assert(line == blobhash+b" missing\n") + return None + size = int(size) # Convert e.g. b'6283' to 6283 + assert(oidtype == b'blob') + contents_plus_newline = self._cat_file_process.stdout.read(size+1) + return contents_plus_newline[:-1] # return all but the newline + + def get_size_by_identifier(self, blobhash): + self._cat_file_process.stdin.write(b'info '+blobhash+b'\n') + self._cat_file_process.stdin.flush() + line = self._cat_file_process.stdout.readline() + (oid, oidtype, size) = line.split() + size = int(size) # Convert e.g. b'6283' to 6283 + assert(oidtype == b'blob') + return size + + def insert_file_with_contents(self, contents): + blob = Blob(contents) + self._insert_blob_func(blob) + return blob.id + + def is_binary(self, contents): + return b"\0" in contents[0:8192] + + def apply_replace_text(self, contents): + new_contents = contents + for literal, replacement in self._replace_text['literals']: + new_contents = new_contents.replace(literal, replacement) + for regex, replacement in self._replace_text['regexes']: + new_contents = regex.sub(replacement, new_contents) + return new_contents + +class LFSObjectTracker: + class LFSObjs: + def __init__(self): + self.id_to_object_map = {} + self.objects = set() + + def __init__(self, file_info, check_sources, check_targets): + self.source_objects = LFSObjectTracker.LFSObjs() + self.target_objects = LFSObjectTracker.LFSObjs() + self.hash_to_object_map = {} + self.file_info = file_info + self.check_sources = check_sources + self.check_targets = check_targets + self.objects_orphaned = False + + def _get_lfs_values(self, contents): + values = {} + if len(contents) > 1024: + return {} + for line in contents.splitlines(): + try: + (key, value) = line.split(b' ', 1) + except ValueError: + return {} + if not values and key != b'version': + return values + values[key] = value + return values + + def check_blob_data(self, contents, fast_export_id, source): + if source and not self.check_sources: + return + mymap = self.source_objects if source else self.target_objects + lfs_object_id = self._get_lfs_values(contents).get(b'oid') + if lfs_object_id: + mymap.id_to_object_map[fast_export_id] = lfs_object_id + + def check_file_change_data(self, git_id, source): + if source and not self.check_sources: + return + mymap = self.source_objects if source else self.target_objects + if isinstance(git_id, int): + lfs_object_id = mymap.id_to_object_map.get(git_id) + if lfs_object_id: + mymap.objects.add(lfs_object_id) + else: + if git_id in self.hash_to_object_map: + mymap.objects.add(self.hash_to_object_map[git_id]) + return + size = self.file_info.get_size_by_identifier(git_id) + if size >= 1024: + return + contents = self.file_info.get_contents_by_identifier(git_id) + lfs_object_id = self._get_lfs_values(contents).get(b'oid') + if lfs_object_id: + self.hash_to_object_map[git_id] = lfs_object_id + mymap.objects.add(lfs_object_id) + + def check_output_object(self, obj): + if not self.check_targets: + return + if type(obj) == Blob: + self.check_blob_data(obj.data, obj.id, False) + elif type(obj) == Commit: + for change in obj.file_changes: + sys.stdout.flush() + if change.type != b'M' or change.mode == b'160000': + continue + self.check_file_change_data(change.blob_id, False) + + def find_all_lfs_objects_in_repo(self, repo, source): + if not source: + self.file_info = FileInfoValueHelper(None, None, repo) + p = subproc.Popen(["git", "rev-list", "--objects", "--all"], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + cwd=repo) + for line in p.stdout.readlines(): + try: + (git_oid, filename) = line.split() + except ValueError: + # Commit and tree objects only have oid + continue + + mymap = self.source_objects if source else self.target_objects + size = self.file_info.get_size_by_identifier(git_oid) + if size >= 1024: + continue + contents = self.file_info.get_contents_by_identifier(git_oid) + lfs_object_id = self._get_lfs_values(contents).get(b'oid') + if lfs_object_id: + mymap.objects.add(lfs_object_id) + if not source: + self.file_info.finalize() + +class InputFileBackup: + def __init__(self, input_file, output_file): + self.input_file = input_file + self.output_file = output_file + + def close(self): + self.input_file.close() + self.output_file.close() + + def read(self, size): + output = self.input_file.read(size) + self.output_file.write(output) + return output + + def readline(self): + line = self.input_file.readline() + self.output_file.write(line) + return line + +class DualFileWriter: + def __init__(self, file1, file2): + self.file1 = file1 + self.file2 = file2 + + def write(self, *args): + self.file1.write(*args) + self.file2.write(*args) + + def flush(self): + self.file1.flush() + self.file2.flush() + + def close(self): + self.file1.close() + self.file2.close() + +class RepoFilter(object): + def __init__(self, + args, + filename_callback = None, + message_callback = None, + name_callback = None, + email_callback = None, + refname_callback = None, + blob_callback = None, + commit_callback = None, + tag_callback = None, + reset_callback = None, + done_callback = None, + file_info_callback = None): + + self._args = args + + # Repo we are exporting + self._repo_working_dir = None + + # Store callbacks for acting on objects printed by FastExport + self._blob_callback = blob_callback + self._commit_callback = commit_callback + self._tag_callback = tag_callback + self._reset_callback = reset_callback + self._done_callback = done_callback + + # Store callbacks for acting on slices of FastExport objects + self._filename_callback = filename_callback # filenames from commits + self._message_callback = message_callback # commit OR tag message + self._name_callback = name_callback # author, committer, tagger + self._email_callback = email_callback # author, committer, tagger + self._refname_callback = refname_callback # from commit/tag/reset + self._file_info_callback = file_info_callback # various file info + self._handle_arg_callbacks() + + # Helpers for callbacks + self._file_info_value = None + + # Defaults for input + self._input = None + self._fep = None # Fast Export Process + self._fe_orig = None # Path to where original fast-export output stored + self._fe_filt = None # Path to where filtered fast-export output stored + self._parser = None # FastExportParser object we are working with + + # Defaults for output + self._output = None + self._fip = None # Fast Import Process + self._import_pipes = None + self._managed_output = True + + # A tuple of (depth, list-of-ancestors). Commits and ancestors are + # identified by their id (their 'mark' in fast-export or fast-import + # speak). The depth of a commit is one more than the max depth of any + # of its ancestors. + self._graph = AncestryGraph() + # Another one, for ancestry of commits in the original repo + self._orig_graph = AncestryGraph() + + # Names of files that were tweaked in any commit; such paths could lead + # to subsequent commits being empty + self._files_tweaked = set() + + # A set of commit hash pairs (oldhash, newhash) which used to be merge + # commits but due to filtering were turned into non-merge commits. + # The commits probably have suboptimal commit messages (e.g. "Merge branch + # next into master"). + self._commits_no_longer_merges = [] + + # A dict of original_ids to new_ids; filtering commits means getting + # new commit hash (sha1sums), and we record the mapping both for + # diagnostic purposes and so we can rewrite commit messages. Note that + # the new_id can be None rather than a commit hash if the original + # commit became empty and was pruned or was otherwise dropped. + self._commit_renames = {} + + # A set of original_ids (i.e. original hashes) for which we have not yet + # gotten the new hashses; the value is always the corresponding fast-export + # id (i.e. commit.id) + self._pending_renames = collections.OrderedDict() + + # A dict of commit_hash[0:7] -> set(commit_hashes with that prefix). + # + # It's common for commit messages to refer to commits by abbreviated + # commit hashes, as short as 7 characters. To facilitate translating + # such short hashes, we have a mapping of prefixes to full old hashes. + self._commit_short_old_hashes = collections.defaultdict(set) + + # A set of commit hash references appearing in commit messages which + # mapped to a valid commit that was removed entirely in the filtering + # process. The commit message will continue to reference the + # now-missing commit hash, since there was nothing to map it to. + self._commits_referenced_but_removed = set() + + # Other vars related to metadata tracking + self._already_ran = False + self._changed_refs = set() + self._lfs_object_tracker = None + + # Progress handling (number of commits parsed, etc.) + self._progress_writer = ProgressWriter() + self._num_commits = 0 + + # Size of blobs in the repo + self._unpacked_size = {} + + # Other vars + self._sanity_checks_handled = False + self._finalize_handled = False + self._orig_refs = None + self._config_settings = {} + self._newnames = {} + self._stash = None + + # Cache a few message translations for performance reasons + self._parsed_message = _("Parsed %d commits") + + # Compile some regexes and cache those + self._hash_re = re.compile(br'(\b[0-9a-f]{7,40}\b)') + + def _handle_arg_callbacks(self): + def make_callback(args, bdy): + callback_globals = {g: globals()[g] for g in public_globals} + callback_locals = {} + if type(args) == str: + args = (args, '_do_not_use_this_var = None') + exec('def callback({}):\n'.format(', '.join(args))+ + ' '+'\n '.join(bdy.splitlines()), callback_globals, callback_locals) + return callback_locals['callback'] + def handle(which, args=None): + which_under = which.replace('-','_') + if not args: + args = which + callback_field = '_{}_callback'.format(which_under) + code_string = getattr(self._args, which_under+'_callback') + if code_string: + if os.path.exists(code_string): + with open(code_string, 'r', encoding='utf-8') as f: + code_string = f.read() + if getattr(self, callback_field): + raise SystemExit(_("Error: Cannot pass a %s_callback to RepoFilter " + "AND pass --%s-callback" + % (which_under, which))) + if 'return ' not in code_string and \ + which not in ('blob', 'commit', 'tag', 'reset'): + raise SystemExit(_("Error: --%s-callback should have a return statement") + % which) + setattr(self, callback_field, make_callback(args, code_string)) + handle('filename') + handle('message') + handle('name') + handle('email') + handle('refname') + handle('blob') + handle('commit') + handle('tag') + handle('reset') + handle('file-info', ('filename', 'mode', 'blob_id', 'value')) + + def _run_sanity_checks(self): + self._sanity_checks_handled = True + if not self._managed_output: + if not self._args.replace_refs: + # If not _managed_output we don't want to make extra changes to the + # repo, so set default to no-op 'update-no-add' + self._args.replace_refs = 'update-no-add' + return + + if self._args.debug: + print("[DEBUG] Passed arguments:\n{}".format(self._args)) + + # Determine basic repository information + target_working_dir = self._args.target or b'.' + self._orig_refs = GitUtils.get_refs(target_working_dir) + is_bare = GitUtils.is_repository_bare(target_working_dir) + self._config_settings = GitUtils.get_config_settings(target_working_dir) + + # Determine if this is second or later run of filter-repo + tmp_dir = self.results_tmp_dir(create_if_missing=False) + ran_path = os.path.join(tmp_dir, b'already_ran') + self._already_ran = os.path.isfile(ran_path) + if self._already_ran: + current_time = time.time() + file_mod_time = os.path.getmtime(ran_path) + file_age = current_time - file_mod_time + if file_age > 86400: # file older than a day + msg = (f"The previous run is older than a day ({decode(ran_path)} already exists).\n" + f"See \"Already Ran\" section in the manual for more information.\n" + f"Treat this run as a continuation of filtering in the previous run (Y/N)? ") + response = input(msg) + + if response.lower() != 'y': + os.remove(ran_path) + self._already_ran = False + + # Interaction between --already-ran and --sensitive_data_removal + msg = textwrap.dedent(_("""\ + Error: Cannot specify --sensitive-data-removal on a follow-up invocation + of git-filter-repo unless it was specified in previously runs.""")) + if self._already_ran: + sdr_path = os.path.join(tmp_dir, b'sensitive_data_removal') + sdr_previously = os.path.isfile(sdr_path) + if not sdr_previously and self._args.sensitive_data_removal: + raise SystemExit(msg) + # Treat this as a --sensitive-data-removal run if a previous run was, + # even if it wasn't specified this time + self._args.sensitive_data_removal = sdr_previously + + # Have to check sensitive_data_removal interactions here instead of + # sanity_check_args because of the above interaction with already_ran stuff + if self._args.sensitive_data_removal: + if self._args.stdin: + msg = _("Error: sensitive data removal is incompatible with --stdin") + raise SystemExit(msg) + if self._args.source or self._args.target: + msg = _("Error: sensitive data removal is incompatible with --source and --target") + raise SystemExit(msg) + + # Default for --replace-refs + if not self._args.replace_refs: + self._args.replace_refs = 'delete-no-add' + if self._args.replace_refs == 'old-default': + self._args.replace_refs = ('update-or-add' if self._already_ran + else 'update-and-add') + + # Do sanity checks from the correct directory + if not self._args.force and not self._already_ran: + cwd = os.getcwd() + os.chdir(target_working_dir) + RepoFilter.sanity_check(self._orig_refs, is_bare, self._config_settings) + os.chdir(cwd) + + def _setup_lfs_orphaning_checks(self): + # Do a couple checks to see if we want to do lfs orphaning checks + if not self._args.sensitive_data_removal: + return + metadata_dir = self.results_tmp_dir() + lfs_objects_file = os.path.join(metadata_dir, b'original_lfs_objects') + if self._already_ran: + # Check if we did lfs filtering in the previous run + if not os.path.isfile(lfs_objects_file): + return + + # Set up self._file_info_value so we can query git for stuff + source_working_dir = self._args.source or b'.' + self._file_info_value = FileInfoValueHelper(self._args.replace_text, + self.insert, + source_working_dir) + + # One more check to see if we want to do lfs orphaning checks + if not self._already_ran: + # Check if lfs filtering is active in HEAD's .gitattributes file + a = self._file_info_value.get_contents_by_identifier(b"HEAD:.gitattributes") + if not a or not re.search(rb'\bfilter=lfs\b', a): + return + + # Set up the object tracker + check_sources = not self._already_ran and not self._args.partial + check_targets = not self._args.partial + self._lfs_object_tracker = LFSObjectTracker(self._file_info_value, + check_sources, + check_targets) + self._parser._lfs_object_tracker = self._lfs_object_tracker # kinda gross + + # Get initial objects + if self._already_ran: + with open(lfs_objects_file, 'br') as f: + for line in f: + self._lfs_object_tracker.source_objects.objects.add(line.strip()) + elif self._args.partial: + source = True + self._lfs_object_tracker.find_all_lfs_objects_in_repo(source_working_dir, + source) + + @staticmethod + def loose_objects_are_replace_refs(git_dir, refs, num_loose_objects): + replace_objects = set() + for refname, rev in refs.items(): + if not refname.startswith(b'refs/replace/'): + continue + replace_objects.add(rev) + + validobj_re = re.compile(rb'^[0-9a-f]{40}$') + object_dir=os.path.join(git_dir, b'objects') + for root, dirs, files in os.walk(object_dir): + for filename in files: + objname = os.path.basename(root)+filename + if objname not in replace_objects and validobj_re.match(objname): + return False + + return True + + @staticmethod + def sanity_check(refs, is_bare, config_settings): + def abort(reason): + dirname = config_settings.get(b'remote.origin.url', b'') + msg = "" + if dirname and os.path.isdir(dirname): + msg = _("Note: when cloning local repositories, you need to pass\n" + " --no-local to git clone to avoid this issue.\n") + raise SystemExit( + _("Aborting: Refusing to destructively overwrite repo history since\n" + "this does not look like a fresh clone.\n" + " (%s)\n%s" + "Please operate on a fresh clone instead. If you want to proceed\n" + "anyway, use --force.") % (reason, msg)) + + # Avoid letting people running with weird setups and overwriting GIT_DIR + # elsewhere + git_dir = GitUtils.determine_git_dir(b'.') + if is_bare and git_dir != b'.': + abort(_("GIT_DIR must be .")) + elif not is_bare and git_dir != b'.git': + abort(_("GIT_DIR must be .git")) + + # Check for refname collisions + if config_settings.get(b'core.ignorecase', b'false') == b'true': + collisions = collections.defaultdict(list) + for ref in refs: + collisions[ref.lower()].append(ref) + msg = "" + for ref in collisions: + if len(collisions[ref]) >= 2: + msg += " " + decode(b", ".join(collisions[ref])) + "\n" + if msg: + raise SystemExit( + _("Aborting: Cannot rewrite history on a case insensitive\n" + "filesystem since you have refs that differ in case only:\n" + "%s") % msg) + if config_settings.get(b'core.precomposeunicode', b'false') == b'true': + import unicodedata # Mac users need to have python-3.8 + collisions = collections.defaultdict(list) + for ref in refs: + strref = decode(ref) + collisions[unicodedata.normalize('NFC', strref)].append(strref) + msg = "" + for ref in collisions: + if len(collisions[ref]) >= 2: + msg += " " + ", ".join(collisions[ref]) + "\n" + if msg: + raise SystemExit( + _("Aborting: Cannot rewrite history on a character normalizing\n" + "filesystem since you have refs that differ in normalization:\n" + "%s") % msg) + + # Make sure repo is fully packed, just like a fresh clone would be. + # Note that transfer.unpackLimit defaults to 100, meaning that a + # repository with no packs and less than 100 objects should be considered + # fully packed. + output = subproc.check_output('git count-objects -v'.split()) + stats = dict(x.split(b': ') for x in output.splitlines()) + num_packs = int(stats[b'packs']) + num_loose_objects = int(stats[b'count']) + if num_packs > 1 or \ + num_loose_objects >= 100 or \ + (num_packs == 1 and num_loose_objects > 0 and + not RepoFilter.loose_objects_are_replace_refs(git_dir, refs, + num_loose_objects)): + abort(_("expected freshly packed repo")) + + # Make sure there is precisely one remote, named "origin"...or that this + # is a new bare repo with no packs and no remotes + output = subproc.check_output('git remote'.split()).strip() + if not (output == b"origin" or (num_packs == 0 and not output)): + abort(_("expected one remote, origin")) + + # Make sure that all reflogs have precisely one entry + reflog_dir=os.path.join(git_dir, b'logs') + for root, dirs, files in os.walk(reflog_dir): + for filename in files: + pathname = os.path.join(root, filename) + with open(pathname, 'br') as f: + if len(f.read().splitlines()) > 1: + shortpath = pathname[len(reflog_dir)+1:] + abort(_("expected at most one entry in the reflog for %s") % + decode(shortpath)) + + # Make sure there are no stashed changes + if b'refs/stash' in refs: + abort(_("has stashed changes")) + + # Do extra checks in non-bare repos + if not is_bare: + # Avoid uncommitted, unstaged, or untracked changes + if subproc.call('git diff --staged --quiet'.split()): + abort(_("you have uncommitted changes")) + if subproc.call('git diff --quiet'.split()): + abort(_("you have unstaged changes")) + untracked_output = subproc.check_output('git ls-files -o'.split()) + if len(untracked_output) > 0: + uf = untracked_output.rstrip(b'\n').split(b'\n') + # Since running git-filter-repo can result in files being written to + # __pycache__ (depending on python version, env vars, etc.), let's + # ignore those as far as "clean clone" is concerned. + relevant_uf = [x for x in uf + if not x.startswith(b'__pycache__/git_filter_repo.')] + if len(relevant_uf) > 0: + abort(_("you have untracked changes")) + + # Avoid unpushed changes + for refname, rev in refs.items(): + if not refname.startswith(b'refs/heads/'): + continue + origin_ref = refname.replace(b'refs/heads/', b'refs/remotes/origin/') + if origin_ref not in refs: + abort(_('%s exists, but %s not found') % (decode(refname), + decode(origin_ref))) + if rev != refs[origin_ref]: + abort(_('%s does not match %s') % (decode(refname), + decode(origin_ref))) + + # Make sure there is only one worktree + output = subproc.check_output('git worktree list'.split()) + if len(output.splitlines()) > 1: + abort(_('you have multiple worktrees')) + + def cleanup(self, repo, repack, reset, + run_quietly=False, show_debuginfo=False): + ''' cleanup repo; if repack then expire reflogs and do a gc --prune=now. + if reset then do a reset --hard. Optionally also curb output if + run_quietly is True, or go the opposite direction and show extra + output if show_debuginfo is True. ''' + assert not (run_quietly and show_debuginfo) + + if (repack and not run_quietly and not show_debuginfo): + print(_("Repacking your repo and cleaning out old unneeded objects")) + quiet_flags = '--quiet' if run_quietly else '' + cleanup_cmds = [] + if repack: + cleanup_cmds = ['git reflog expire --expire=now --all'.split(), + 'git gc {} --prune=now'.format(quiet_flags).split()] + if reset: + cleanup_cmds.insert(0, 'git reset {} --hard'.format(quiet_flags).split()) + location_info = ' (in {})'.format(decode(repo)) if repo != b'.' else '' + for cmd in cleanup_cmds: + if show_debuginfo: + print("[DEBUG] Running{}: {}".format(location_info, ' '.join(cmd))) + ret = subproc.call(cmd, cwd=repo) + if ret != 0: + raise SystemExit("fatal: running '%s' failed!" % ' '.join(cmd)) + if cmd[0:3] == 'git reflog expire'.split(): + self._write_stash() + + def _get_rename(self, old_hash): + # If we already know the rename, just return it + new_hash = self._commit_renames.get(old_hash, None) + if new_hash: + return new_hash + + # If it's not in the remaining pending renames, we don't know it + if old_hash is not None and old_hash not in self._pending_renames: + return None + + # Read through the pending renames until we find it or we've read them all, + # and return whatever we might find + self._flush_renames(old_hash) + return self._commit_renames.get(old_hash, None) + + def _flush_renames(self, old_hash=None, limit=0): + # Parse through self._pending_renames until we have read enough. We have + # read enough if: + # self._pending_renames is empty + # old_hash != None and we found a rename for old_hash + # limit > 0 and len(self._pending_renames) started less than 2*limit + # limit > 0 and len(self._pending_renames) < limit + if limit and len(self._pending_renames) < 2 * limit: + return + fi_input, fi_output = self._import_pipes + while self._pending_renames: + orig_hash, new_fast_export_id = self._pending_renames.popitem(last=False) + new_hash = fi_output.readline().rstrip() + self._commit_renames[orig_hash] = new_hash + self._graph.record_hash(new_fast_export_id, new_hash) + if old_hash == orig_hash: + return + if limit and len(self._pending_renames) < limit: + return + + def _translate_commit_hash(self, matchobj_or_oldhash): + old_hash = matchobj_or_oldhash + if not isinstance(matchobj_or_oldhash, bytes): + old_hash = matchobj_or_oldhash.group(1) + orig_len = len(old_hash) + new_hash = self._get_rename(old_hash) + if new_hash is None: + if old_hash[0:7] not in self._commit_short_old_hashes: + self._commits_referenced_but_removed.add(old_hash) + return old_hash + possibilities = self._commit_short_old_hashes[old_hash[0:7]] + matches = [x for x in possibilities + if x[0:orig_len] == old_hash] + if len(matches) != 1: + self._commits_referenced_but_removed.add(old_hash) + return old_hash + old_hash = matches[0] + new_hash = self._get_rename(old_hash) + + assert new_hash is not None + return new_hash[0:orig_len] + + def _maybe_trim_extra_parents(self, orig_parents, parents): + '''Due to pruning of empty commits, some parents could be non-existent + (None) or otherwise redundant. Remove the non-existent parents, and + remove redundant parents ***SO LONG AS*** that doesn't transform a + merge commit into a non-merge commit. + + Returns a tuple: + (parents, new_first_parent_if_would_become_non_merge)''' + + always_prune = (self._args.prune_degenerate == 'always') + + # Pruning of empty commits means multiple things: + # * An original parent of this commit may have been pruned causing the + # need to rewrite the reported parent to the nearest ancestor. We + # want to know when we're dealing with such a parent. + # * Further, there may be no "nearest ancestor" if the entire history + # of that parent was also pruned. (Detectable by the parent being + # 'None') + # Remove all parents rewritten to None, and keep track of which parents + # were rewritten to an ancestor. + tmp = zip(parents, + orig_parents, + [(x in _SKIPPED_COMMITS or always_prune) for x in orig_parents]) + tmp2 = [x for x in tmp if x[0] is not None] + if not tmp2: + # All ancestors have been pruned; we have no parents. + return [], None + parents, orig_parents, is_rewritten = [list(x) for x in zip(*tmp2)] + + # We can't have redundant parents if we don't have at least 2 parents + if len(parents) < 2: + return parents, None + + # Don't remove redundant parents if user doesn't want us to + if self._args.prune_degenerate == 'never': + return parents, None + + # Remove duplicate parents (if both sides of history have lots of commits + # which become empty due to pruning, the most recent ancestor on both + # sides may be the same commit), except only remove parents that have + # been rewritten due to previous empty pruning. + seen = set() + seen_add = seen.add + # Deleting duplicate rewritten parents means keeping parents if either + # they have not been seen or they are ones that have not been rewritten. + parents_copy = parents + uniq = [[p, orig_parents[i], is_rewritten[i]] for i, p in enumerate(parents) + if not (p in seen or seen_add(p)) or not is_rewritten[i]] + parents, orig_parents, is_rewritten = [list(x) for x in zip(*uniq)] + if len(parents) < 2: + return parents_copy, parents[0] + + # Flatten unnecessary merges. (If one side of history is entirely + # empty commits that were pruned, we may end up attempting to + # merge a commit with its ancestor. Remove parents that are an + # ancestor of another parent.) + num_parents = len(parents) + to_remove = [] + for cur in range(num_parents): + if not is_rewritten[cur]: + continue + for other in range(num_parents): + if cur == other: + continue + if not self._graph.is_ancestor(parents[cur], parents[other]): + continue + # parents[cur] is an ancestor of parents[other], so parents[cur] + # seems redundant. However, if it was intentionally redundant + # (e.g. a no-ff merge) in the original, then we want to keep it. + if not always_prune and \ + self._orig_graph.is_ancestor(orig_parents[cur], + orig_parents[other]): + continue + # Some folks want their history to have all first parents be merge + # commits (except for any root commits), and always do a merge --no-ff. + # For such folks, don't remove the first parent even if it's an + # ancestor of other commits. + if self._args.no_ff and cur == 0: + continue + # Okay so the cur-th parent is an ancestor of the other-th parent, + # and it wasn't that way in the original repository; mark the + # cur-th parent as removable. + to_remove.append(cur) + break # cur removed, so skip rest of others -- i.e. check cur+=1 + for x in reversed(to_remove): + parents.pop(x) + if len(parents) < 2: + return parents_copy, parents[0] + + return parents, None + + def _prunable(self, commit, new_1st_parent, had_file_changes, orig_parents): + parents = commit.parents + + if self._args.prune_empty == 'never': + return False + always_prune = (self._args.prune_empty == 'always') + + # For merge commits, unless there are prunable (redundant) parents, we + # do not want to prune + if len(parents) >= 2 and not new_1st_parent: + return False + + if len(parents) < 2: + # Special logic for commits that started empty... + if not had_file_changes and not always_prune: + had_parents_pruned = (len(parents) < len(orig_parents) or + (len(orig_parents) == 1 and + orig_parents[0] in _SKIPPED_COMMITS)) + # If the commit remains empty and had parents which were pruned, + # then prune this commit; otherwise, retain it + return (not commit.file_changes and had_parents_pruned) + + # We can only get here if the commit didn't start empty, so if it's + # empty now, it obviously became empty + if not commit.file_changes: + return True + + # If there are no parents of this commit and we didn't match the case + # above, then this commit cannot be pruned. Since we have no parent(s) + # to compare to, abort now to prevent future checks from failing. + if not parents: + return False + + # Similarly, we cannot handle the hard cases if we don't have a pipe + # to communicate with fast-import + if not self._import_pipes: + return False + + # If there have not been renames/remappings of IDs (due to insertion of + # new blobs), then we can sometimes know things aren't prunable with a + # simple check + if not _IDS.has_renames(): + # non-merge commits can only be empty if blob/file-change editing caused + # all file changes in the commit to have the same file contents as + # the parent. + changed_files = set(change.filename for change in commit.file_changes) + if len(orig_parents) < 2 and changed_files - self._files_tweaked: + return False + + # Finally, the hard case: due to either blob rewriting, or due to pruning + # of empty commits wiping out the first parent history back to the merge + # base, the list of file_changes we have may not actually differ from our + # (new) first parent's version of the files, i.e. this would actually be + # an empty commit. Check by comparing the contents of this commit to its + # (remaining) parent. + # + # NOTE on why this works, for the case of original first parent history + # having been pruned away due to being empty: + # The first parent history having been pruned away due to being + # empty implies the original first parent would have a tree (after + # filtering) that matched the merge base's tree. Since + # file_changes has the changes needed to go from what would have + # been the first parent to our new commit, and what would have been + # our first parent has a tree that matches the merge base, then if + # the new first parent has a tree matching the versions of files in + # file_changes, then this new commit is empty and thus prunable. + fi_input, fi_output = self._import_pipes + self._flush_renames() # Avoid fi_output having other stuff present + # Optimization note: we could have two loops over file_changes, the + # first doing all the self._output.write() calls, and the second doing + # the rest. But I'm worried about fast-import blocking on fi_output + # buffers filling up so I instead read from it as I go. + for change in commit.file_changes: + parent = new_1st_parent or commit.parents[0] # exists due to above checks + quoted_filename = PathQuoting.enquote(change.filename) + if isinstance(parent, int): + self._output.write(b"ls :%d %s\n" % (parent, quoted_filename)) + else: + self._output.write(b"ls %s %s\n" % (parent, quoted_filename)) + self._output.flush() + parent_version = fi_output.readline().split() + if change.type == b'D': + if parent_version != [b'missing', quoted_filename]: + return False + else: + blob_sha = change.blob_id + if isinstance(change.blob_id, int): + self._output.write(b"get-mark :%d\n" % change.blob_id) + self._output.flush() + blob_sha = fi_output.readline().rstrip() + if parent_version != [change.mode, b'blob', blob_sha, quoted_filename]: + return False + + return True + + def _record_remapping(self, commit, orig_parents): + new_id = None + # Record the mapping of old commit hash to new one + if commit.original_id and self._import_pipes: + fi_input, fi_output = self._import_pipes + self._output.write(b"get-mark :%d\n" % commit.id) + self._output.flush() + orig_id = commit.original_id + self._commit_short_old_hashes[orig_id[0:7]].add(orig_id) + # Note that we have queued up an id for later reading; flush a + # few of the older ones if we have too many queued up + self._pending_renames[orig_id] = commit.id + self._flush_renames(None, limit=40) + # Also, record if this was a merge commit that turned into a non-merge + # commit. + if len(orig_parents) >= 2 and len(commit.parents) < 2: + self._commits_no_longer_merges.append((commit.original_id, new_id)) + + def callback_metadata(self, extra_items = dict()): + return {'commit_rename_func': self._translate_commit_hash, + 'ancestry_graph': self._graph, + 'original_ancestry_graph': self._orig_graph, + **extra_items} + + def _tweak_blob(self, blob): + if self._args.max_blob_size and len(blob.data) > self._args.max_blob_size: + blob.skip() + + if blob.original_id in self._args.strip_blobs_with_ids: + blob.skip() + + if ( self._args.replace_text + and not self._file_info_callback + # not (if blob contains zero byte in the first 8Kb, that is, if blob is binary data) + and not b"\0" in blob.data[0:8192] + ): + for literal, replacement in self._args.replace_text['literals']: + blob.data = blob.data.replace(literal, replacement) + for regex, replacement in self._args.replace_text['regexes']: + blob.data = regex.sub(replacement, blob.data) + + if self._blob_callback: + self._blob_callback(blob, self.callback_metadata()) + + self._insert_into_stream(blob) + + def _filter_files(self, commit): + def filename_matches(path_expression, pathname): + ''' Returns whether path_expression matches pathname or a leading + directory thereof, allowing path_expression to not have a trailing + slash even if it is meant to match a leading directory. ''' + if path_expression == b'': + return True + n = len(path_expression) + if (pathname.startswith(path_expression) and + (path_expression[n-1:n] == b'/' or + len(pathname) == n or + pathname[n:n+1] == b'/')): + return True + return False + + def newname(path_changes, pathname, use_base_name, filtering_is_inclusive): + ''' Applies filtering and rename changes from path_changes to pathname, + returning any of None (file isn't wanted), original filename (file + is wanted with original name), or new filename. ''' + wanted = False + full_pathname = pathname + if use_base_name: + pathname = os.path.basename(pathname) + for (mod_type, match_type, path_exp) in path_changes: + if mod_type == 'filter' and not wanted: + assert match_type in ('match', 'glob', 'regex') + if match_type == 'match' and filename_matches(path_exp, pathname): + wanted = True + if match_type == 'glob' and fnmatch.fnmatch(pathname, path_exp): + wanted = True + if match_type == 'regex' and path_exp.search(pathname): + wanted = True + elif mod_type == 'rename': + match, repl = path_exp + assert match_type in ('match','regex') # glob was translated to regex + if match_type == 'match' and filename_matches(match, full_pathname): + full_pathname = full_pathname.replace(match, repl, 1) + pathname = full_pathname # rename incompatible with use_base_name + if match_type == 'regex': + full_pathname = match.sub(repl, full_pathname) + pathname = full_pathname # rename incompatible with use_base_name + return full_pathname if (wanted == filtering_is_inclusive) else None + + args = self._args + new_file_changes = {} # Assumes no renames or copies, otherwise collisions + for change in commit.file_changes: + # NEEDSWORK: _If_ we ever want to pass `--full-tree` to fast-export and + # parse that output, we'll need to modify this block; `--full-tree` + # issues a deleteall directive which has no filename, and thus this + # block would normally strip it. Of course, FileChange() and + # _parse_optional_filechange() would need updates too. + if change.type == b'DELETEALL': + new_file_changes[b''] = change + continue + if change.filename in self._newnames: + change.filename = self._newnames[change.filename] + else: + original_filename = change.filename + change.filename = newname(args.path_changes, change.filename, + args.use_base_name, args.inclusive) + if self._filename_callback: + change.filename = self._filename_callback(change.filename) + self._newnames[original_filename] = change.filename + if not change.filename: + continue # Filtering criteria excluded this file; move on to next one + if change.filename in new_file_changes: + # Getting here means that path renaming is in effect, and caused one + # path to collide with another. That's usually bad, but can be okay + # under two circumstances: + # 1) Sometimes people have a file named OLDFILE in old revisions of + # history, and they rename to NEWFILE, and would like to rewrite + # history so that all revisions refer to it as NEWFILE. As such, + # we can allow a collision when (at least) one of the two paths + # is a deletion. Note that if OLDFILE and NEWFILE are unrelated + # this also allows the rewrite to continue, which makes sense + # since OLDFILE is no longer in the way. + # 2) If OLDFILE and NEWFILE are exactly equal, then writing them + # both to the same location poses no problem; we only need one + # file. (This could come up if someone copied a file in some + # commit, then later either deleted the file or kept it exactly + # in sync with the original with any changes, and then decides + # they want to rewrite history to only have one of the two files) + colliding_change = new_file_changes[change.filename] + if change.type == b'D': + # We can just throw this one away and keep the other + continue + elif change.type == b'M' and ( + change.mode == colliding_change.mode and + change.blob_id == colliding_change.blob_id): + # The two are identical, so we can throw this one away and keep other + continue + elif new_file_changes[change.filename].type != b'D': + raise SystemExit(_("File renaming caused colliding pathnames!\n") + + _(" Commit: {}\n").format(commit.original_id) + + _(" Filename: {}").format(change.filename)) + # Strip files that are too large + if self._args.max_blob_size and \ + self._unpacked_size.get(change.blob_id, 0) > self._args.max_blob_size: + continue + if self._args.strip_blobs_with_ids and \ + change.blob_id in self._args.strip_blobs_with_ids: + continue + # Otherwise, record the change + new_file_changes[change.filename] = change + commit.file_changes = [v for k,v in sorted(new_file_changes.items())] + + def _tweak_commit(self, commit, aux_info): + if self._args.replace_message: + for literal, replacement in self._args.replace_message['literals']: + commit.message = commit.message.replace(literal, replacement) + for regex, replacement in self._args.replace_message['regexes']: + commit.message = regex.sub(replacement, commit.message) + if self._message_callback: + commit.message = self._message_callback(commit.message) + + # Change the commit message according to callback + if not self._args.preserve_commit_hashes: + commit.message = self._hash_re.sub(self._translate_commit_hash, + commit.message) + + # Change the author & committer according to mailmap rules + args = self._args + if args.mailmap: + commit.author_name, commit.author_email = \ + args.mailmap.translate(commit.author_name, commit.author_email) + commit.committer_name, commit.committer_email = \ + args.mailmap.translate(commit.committer_name, commit.committer_email) + # Change author & committer according to callbacks + if self._name_callback: + commit.author_name = self._name_callback(commit.author_name) + commit.committer_name = self._name_callback(commit.committer_name) + if self._email_callback: + commit.author_email = self._email_callback(commit.author_email) + commit.committer_email = self._email_callback(commit.committer_email) + + # Sometimes the 'branch' given is a tag; if so, rename it as requested so + # we don't get any old tagnames + if self._args.tag_rename: + commit.branch = RepoFilter._do_tag_rename(args.tag_rename, commit.branch) + if self._refname_callback: + commit.branch = self._refname_callback(commit.branch) + + # Filter or rename the list of file changes + orig_file_changes = set(commit.file_changes) + self._filter_files(commit) + + # Record ancestry graph + parents, orig_parents = commit.parents, aux_info['orig_parents'] + if self._args.state_branch: + external_parents = parents + else: + external_parents = [p for p in parents if not isinstance(p, int)] + # The use of 'reversed' is intentional here; there is a risk that we have + # duplicates in parents, and we want to map from parents to the first + # entry we find in orig_parents in such cases. + parent_reverse_dict = dict(zip(reversed(parents), reversed(orig_parents))) + + self._graph.record_external_commits(external_parents) + self._orig_graph.record_external_commits(external_parents) + self._graph.add_commit_and_parents(commit.id, parents) # new githash unknown + self._orig_graph.add_commit_and_parents(commit.old_id, orig_parents, + commit.original_id) + + # Prune parents (due to pruning of empty commits) if relevant, note that + # new_1st_parent is None unless this was a merge commit that is becoming + # a non-merge + prev_1st_parent = parents[0] if parents else None + parents, new_1st_parent = self._maybe_trim_extra_parents(orig_parents, + parents) + commit.parents = parents + + # If parents were pruned, then we need our file changes to be relative + # to the new first parent + # + # Notes: + # * new_1st_parent and new_1st_parent != parents[0] uniquely happens for example when: + # working on merge, selecting subset of files and merge base still + # valid while first parent history doesn't touch any of those paths, + # but second parent history does. prev_1st_parent had already been + # rewritten to the non-None first ancestor and it remains valid. + # self._maybe_trim_extra_parents() avoids removing this first parent + # because it'd make the commit a non-merge. However, if there are + # no file_changes of note, we'll drop this commit and mark + # new_1st_parent as the new replacement. To correctly determine if + # there are no file_changes of note, we need to have the list of + # file_changes relative to new_1st_parent. + # (See t9390#3, "basic -> basic-ten using '--path ten'") + # * prev_1st_parent != parents[0] happens for example when: + # similar to above, but the merge base is no longer valid and was + # pruned away as well. Then parents started as e.g. [None, $num], + # and both prev_1st_parent and new_1st_parent are None, while parents + # after self._maybe_trim_extra_parents() becomes just [$num]. + # (See t9390#67, "degenerate merge with non-matching filename".) + # Since $num was originally a second parent, we need to rewrite + # file changes to be relative to parents[0]. + # * TODO: We should be getting the changes relative to the new first + # parent even if self._fep is None, BUT we can't. Our method of + # getting the changes right now is an external git diff invocation, + # which we can't do if we just have a fast export stream. We can't + # really work around it by querying the fast-import stream either, + # because the 'ls' directive only allows us to list info about + # specific paths, but we need to find out which paths exist in two + # commits and then query them. We could maybe force checkpointing in + # fast-import, then doing a diff from what'll be the new first parent + # back to prev_1st_parent (which may be None, i.e. empty tree), using + # the fact that in A->{B,C}->D, where D is merge of B & C, the diff + # from C->D == C->A + A->B + B->D, and in these cases A==B, so it + # simplifies to C->D == C->A + B->D, and C is our new 1st parent + # commit, A is prev_1st_commit, and B->D is commit.file_changes that + # we already have. However, checkpointing the fast-import process + # and figuring out how long to wait before we can run our diff just + # seems excessive. For now, just punt and assume the merge wasn't + # "evil" (i.e. that it's remerge-diff is empty, as is true for most + # merges). If the merge isn't evil, no further steps are necessary. + if parents and self._fep and ( + prev_1st_parent != parents[0] or + new_1st_parent and new_1st_parent != parents[0]): + # Get the id from the original fast export stream corresponding to the + # new 1st parent. As noted above, that new 1st parent might be + # new_1st_parent, or if that is None, it'll be parents[0]. + will_be_1st = new_1st_parent or parents[0] + old_id = parent_reverse_dict[will_be_1st] + # Now, translate that to a hash + will_be_1st_commit_hash = self._orig_graph.map_to_hash(old_id) + # Get the changes from what is going to be the new 1st parent to this + # merge commit. Note that since we are going from the new 1st parent + # to the merge commit, we can just replace the existing + # commit.file_changes rather than getting something we need to combine + # with the existing commit.file_changes. Also, we can just replace + # because prev_1st_parent is an ancestor of will_be_1st_commit_hash + # (or prev_1st_parent is None and first parent history is gone), so + # even if we retain prev_1st_parent and do not prune it, the changes + # will still work given the snapshot-based way fast-export/fast-import + # work. + commit.file_changes = GitUtils.get_file_changes(self._repo_working_dir, + will_be_1st_commit_hash, + commit.original_id) + + # Save these and filter them + orig_file_changes = set(commit.file_changes) + self._filter_files(commit) + + # Process the --file-info-callback + if self._file_info_callback: + if self._file_info_value is None: + source_working_dir = self._args.source or b'.' + self._file_info_value = FileInfoValueHelper(self._args.replace_text, + self.insert, + source_working_dir) + new_file_changes = [] + for change in commit.file_changes: + if change.type != b'D': + assert(change.type == b'M') + (filename, mode, blob_id) = \ + self._file_info_callback(change.filename, + change.mode, + change.blob_id, + self._file_info_value) + if mode is None: + # TODO: Should deletion of the file even be a feature? Might + # want to remove this branch of the if-elif-else. + assert(filename is not None) + assert(blob_id is not None) + new_change = FileChange(b'D', filename) + elif filename is None: + continue # Drop the FileChange from this commit + else: + new_change = FileChange(b'M', filename, blob_id, mode) + else: + new_change = change # use change as-is for deletions + new_file_changes.append(new_change) + commit.file_changes = new_file_changes + + # Call the user-defined callback, if any + if self._commit_callback: + self._commit_callback(commit, self.callback_metadata(aux_info)) + + # Find out which files were modified by the callbacks. Such paths could + # lead to subsequent commits being empty (e.g. if removing a line containing + # a password from every version of a file that had the password, and some + # later commit did nothing more than remove that line) + final_file_changes = set(commit.file_changes) + if self._args.replace_text or self._blob_callback: + differences = orig_file_changes.union(final_file_changes) + else: + differences = orig_file_changes.symmetric_difference(final_file_changes) + self._files_tweaked.update(x.filename for x in differences) + + # Now print the resulting commit, or if prunable skip it + if not commit.dumped: + if not self._prunable(commit, new_1st_parent, + aux_info['had_file_changes'], orig_parents): + self._insert_into_stream(commit) + self._record_remapping(commit, orig_parents) + else: + rewrite_to = new_1st_parent or commit.first_parent() + commit.skip(new_id = rewrite_to) + if self._args.state_branch: + alias = Alias(commit.old_id or commit.id, rewrite_to or deleted_hash) + self._insert_into_stream(alias) + if commit.branch.startswith(b'refs/') or commit.branch == b'HEAD': + # The special check above is because when direct revisions are passed + # along to fast-export (such as with stashes), there is a chance the + # revision is rewritten to nothing. In such cases, we don't want to + # point an invalid ref that just names a revision to some other point. + reset = Reset(commit.branch, rewrite_to or deleted_hash) + self._insert_into_stream(reset) + self._commit_renames[commit.original_id] = None + + # Show progress + self._num_commits += 1 + if not self._args.quiet: + self._progress_writer.show(self._parsed_message % self._num_commits) + + @staticmethod + def _do_tag_rename(rename_pair, tagname): + old, new = rename_pair.split(b':', 1) + old, new = b'refs/tags/'+old, b'refs/tags/'+new + if tagname.startswith(old): + return tagname.replace(old, new, 1) + return tagname + + def _tweak_tag(self, tag): + # Tweak the tag message according to callbacks + if self._args.replace_message: + for literal, replacement in self._args.replace_message['literals']: + tag.message = tag.message.replace(literal, replacement) + for regex, replacement in self._args.replace_message['regexes']: + tag.message = regex.sub(replacement, tag.message) + if self._message_callback: + tag.message = self._message_callback(tag.message) + + # Tweak the tag name according to tag-name-related callbacks + tag_prefix = b'refs/tags/' + fullref = tag_prefix+tag.ref + if self._args.tag_rename: + fullref = RepoFilter._do_tag_rename(self._args.tag_rename, fullref) + if self._refname_callback: + fullref = self._refname_callback(fullref) + if not fullref.startswith(tag_prefix): + msg = "Error: fast-import requires tags to be in refs/tags/ namespace." + msg += "\n {} renamed to {}".format(tag_prefix+tag.ref, fullref) + raise SystemExit(msg) + tag.ref = fullref[len(tag_prefix):] + + # Tweak the tagger according to callbacks + if self._args.mailmap: + tag.tagger_name, tag.tagger_email = \ + self._args.mailmap.translate(tag.tagger_name, tag.tagger_email) + if self._name_callback: + tag.tagger_name = self._name_callback(tag.tagger_name) + if self._email_callback: + tag.tagger_email = self._email_callback(tag.tagger_email) + + # Call general purpose tag callback + if self._tag_callback: + self._tag_callback(tag, self.callback_metadata()) + + def _tweak_reset(self, reset): + if self._args.tag_rename: + reset.ref = RepoFilter._do_tag_rename(self._args.tag_rename, reset.ref) + if self._refname_callback: + reset.ref = self._refname_callback(reset.ref) + if self._reset_callback: + self._reset_callback(reset, self.callback_metadata()) + + def results_tmp_dir(self, create_if_missing=True): + target_working_dir = self._args.target or b'.' + git_dir = GitUtils.determine_git_dir(target_working_dir) + d = os.path.join(git_dir, b'filter-repo') + if create_if_missing and not os.path.isdir(d): + os.mkdir(d) + return d + + def _load_marks_file(self, marks_basename): + full_branch = 'refs/heads/{}'.format(self._args.state_branch) + marks_file = os.path.join(self.results_tmp_dir(), marks_basename) + working_dir = self._args.target or b'.' + cmd = ['git', '-C', working_dir, 'show-ref', full_branch] + contents = b'' + if subproc.call(cmd, stdout=subprocess.DEVNULL) == 0: + cmd = ['git', '-C', working_dir, 'show', + '%s:%s' % (full_branch, decode(marks_basename))] + try: + contents = subproc.check_output(cmd) + except subprocess.CalledProcessError as e: # pragma: no cover + raise SystemExit(_("Failed loading %s from %s") % + (decode(marks_basename), full_branch)) + if contents: + biggest_id = max(int(x.split()[0][1:]) for x in contents.splitlines()) + _IDS._next_id = max(_IDS._next_id, biggest_id+1) + with open(marks_file, 'bw') as f: + f.write(contents) + return marks_file + + def _save_marks_files(self): + basenames = [b'source-marks', b'target-marks'] + working_dir = self._args.target or b'.' + + # Check whether the branch exists + parent = [] + full_branch = 'refs/heads/{}'.format(self._args.state_branch) + cmd = ['git', '-C', working_dir, 'show-ref', full_branch] + if subproc.call(cmd, stdout=subprocess.DEVNULL) == 0: + parent = ['-p', full_branch] + + # Run 'git hash-object $MARKS_FILE' for each marks file, save result + blob_hashes = {} + for marks_basename in basenames: + marks_file = os.path.join(self.results_tmp_dir(), marks_basename) + if not os.path.isfile(marks_file): # pragma: no cover + raise SystemExit(_("Failed to find %s to save to %s") + % (marks_file, self._args.state_branch)) + cmd = ['git', '-C', working_dir, 'hash-object', '-w', marks_file] + blob_hashes[marks_basename] = subproc.check_output(cmd).strip() + + # Run 'git mktree' to create a tree out of it + p = subproc.Popen(['git', '-C', working_dir, 'mktree'], + stdin=subprocess.PIPE, stdout=subprocess.PIPE) + for b in basenames: + p.stdin.write(b'100644 blob %s\t%s\n' % (blob_hashes[b], b)) + p.stdin.close() + p.wait() + tree = p.stdout.read().strip() + + # Create the new commit + cmd = (['git', '-C', working_dir, 'commit-tree', '-m', 'New mark files', + tree] + parent) + commit = subproc.check_output(cmd).strip() + subproc.call(['git', '-C', working_dir, 'update-ref', full_branch, commit]) + + def importer_only(self): + self._run_sanity_checks() + self._setup_output() + + def set_output(self, outputRepoFilter): + assert outputRepoFilter._output + + # set_output implies this RepoFilter is doing exporting, though may not + # be the only one. + self._setup_input(use_done_feature = False) + + # Set our output management up to pipe to outputRepoFilter's locations + self._managed_output = False + self._output = outputRepoFilter._output + self._import_pipes = outputRepoFilter._import_pipes + + # Handle sanity checks, though currently none needed for export-only cases + self._run_sanity_checks() + + def _read_stash(self): + if self._stash: + return + if self._orig_refs and b'refs/stash' in self._orig_refs and \ + self._args.refs == ['--all']: + repo_working_dir = self._args.source or b'.' + git_dir = GitUtils.determine_git_dir(repo_working_dir) + stash = os.path.join(git_dir, b'logs', b'refs', b'stash') + if os.path.exists(stash): + self._stash = [] + with open(stash, 'br') as f: + for line in f: + (oldhash, newhash, rest) = line.split(None, 2) + self._stash.append((newhash, rest)) + self._args.refs.extend([x[0] for x in self._stash]) + + def _write_stash(self): + last = deleted_hash + if self._stash: + target_working_dir = self._args.target or b'.' + git_dir = GitUtils.determine_git_dir(target_working_dir) + stash = os.path.join(git_dir, b'logs', b'refs', b'stash') + with open(stash, 'bw') as f: + for (hash, rest) in self._stash: + new_hash = self._get_rename(hash) + if new_hash is None: + continue + f.write(b' '.join([last, new_hash, rest]) + b'\n') + last = new_hash + print(_("Rewrote the stash.")) + + def _setup_input(self, use_done_feature): + if self._args.stdin: + self._input = sys.stdin.detach() + sys.stdin = None # Make sure no one tries to accidentally use it + self._fe_orig = None + else: + self._read_stash() + skip_blobs = (self._blob_callback is None and + (self._args.replace_text is None or + self._file_info_callback is not None) and + self._args.source == self._args.target) + extra_flags = [] + if skip_blobs: + extra_flags.append('--no-data') + if self._args.max_blob_size: + self._unpacked_size, packed_size = GitUtils.get_blob_sizes() + if use_done_feature: + extra_flags.append('--use-done-feature') + if write_marks: + extra_flags.append(b'--mark-tags') + if self._args.state_branch: + assert(write_marks) + source_marks_file = self._load_marks_file(b'source-marks') + extra_flags.extend([b'--export-marks='+source_marks_file, + b'--import-marks='+source_marks_file]) + if self._args.preserve_commit_encoding is not None: # pragma: no cover + reencode = 'no' if self._args.preserve_commit_encoding else 'yes' + extra_flags.append('--reencode='+reencode) + if self._args.date_order: + extra_flags.append('--date-order') + location = ['-C', self._args.source] if self._args.source else [] + fep_cmd = ['git'] + location + ['fast-export', '--show-original-ids', + '--signed-tags=strip', '--tag-of-filtered-object=rewrite', + '--fake-missing-tagger', '--reference-excluded-parents' + ] + extra_flags + self._args.refs + self._fep = subproc.Popen(fep_cmd, bufsize=-1, stdout=subprocess.PIPE) + self._input = self._fep.stdout + if self._args.dry_run or self._args.debug: + self._fe_orig = os.path.join(self.results_tmp_dir(), + b'fast-export.original') + output = open(self._fe_orig, 'bw') + self._input = InputFileBackup(self._input, output) + if self._args.debug: + tmp = [decode(x) if isinstance(x, bytes) else x for x in fep_cmd] + print("[DEBUG] Running: {}".format(' '.join(tmp))) + print(" (saving a copy of the output at {})" + .format(decode(self._fe_orig))) + + def _setup_output(self): + if not self._args.dry_run: + location = ['-C', self._args.target] if self._args.target else [] + fip_cmd = ['git'] + location + ['-c', 'core.ignorecase=false', + 'fast-import', '--force', '--quiet'] + if date_format_permissive: + fip_cmd.append('--date-format=raw-permissive') + if self._args.state_branch: + target_marks_file = self._load_marks_file(b'target-marks') + fip_cmd.extend([b'--export-marks='+target_marks_file, + b'--import-marks='+target_marks_file]) + self._fip = subproc.Popen(fip_cmd, bufsize=-1, + stdin=subprocess.PIPE, stdout=subprocess.PIPE) + self._import_pipes = (self._fip.stdin, self._fip.stdout) + if self._args.dry_run or self._args.debug: + self._fe_filt = os.path.join(self.results_tmp_dir(), + b'fast-export.filtered') + self._output = open(self._fe_filt, 'bw') + else: + self._output = self._fip.stdin + if self._args.debug and not self._args.dry_run: + self._output = DualFileWriter(self._fip.stdin, self._output) + tmp = [decode(x) if isinstance(x, bytes) else x for x in fip_cmd] + print("[DEBUG] Running: {}".format(' '.join(tmp))) + print(" (using the following file as input: {})" + .format(decode(self._fe_filt))) + + def _migrate_origin_to_heads(self): + source_working_dir = self._args.source or b'.' + target_working_dir = self._args.target or b'.' + refs_to_migrate = set(x for x in self._orig_refs + if x.startswith(b'refs/remotes/origin/')) + refs_to_warn_about = set() + if refs_to_migrate: + if self._args.debug: + print("[DEBUG] Migrating refs/remotes/origin/* -> refs/heads/*") + p = subproc.Popen('git update-ref --no-deref --stdin'.split(), + stdin=subprocess.PIPE, cwd=source_working_dir) + for ref in refs_to_migrate: + if ref == b'refs/remotes/origin/HEAD': + p.stdin.write(b'delete %s %s\n' % (ref, self._orig_refs[ref])) + del self._orig_refs[ref] + continue + newref = ref.replace(b'refs/remotes/origin/', b'refs/heads/') + if newref not in self._orig_refs: + p.stdin.write(b'create %s %s\n' % (newref, self._orig_refs[ref])) + self._orig_refs[newref] = self._orig_refs[ref] + elif self._orig_refs[ref] != self._orig_refs[newref]: + refs_to_warn_about.add(newref) + p.stdin.write(b'delete %s %s\n' % (ref, self._orig_refs[ref])) + del self._orig_refs[ref] + p.stdin.close() + if p.wait(): # pragma: no cover + msg = _("git update-ref failed; see above") + raise SystemExit(msg) + + if b'remote.origin.url' not in self._config_settings: + return + + # For sensitive data removals, fetch ALL refs. Non-mirror clones normally + # only grab branches and tags, but other refs may hold on to the sensitive + # data as well. + if self._args.sensitive_data_removal and \ + not self._args.no_fetch and \ + not self._already_ran and \ + self._config_settings.get(b'remote.origin.mirror', b'false') != b'true': + + if refs_to_warn_about: + msg = ("Warning: You have refs modified from upstream:\n " + + "\n ".join([decode(x) for x in refs_to_warn_about]) + + "\n" + + " We want to forcibly fetch from upstream to ensure\n" + + " that all relevent refs are rewritten, but this will\n" + + " discard your local changes before starting the\n" + + " rewrite. Proceed with fetch (Y/N)?") + response = input(msg) + + if response.lower() != 'y': + self._args.no_fetch = True + # Don't do the fetch, and don't remove the origin remote + return + + cmd = 'git fetch -q --prune --update-head-ok --refmap "" origin +refs/*:refs/*' + m = _("NOTICE: Fetching all refs from origin to make sure we rewrite\n" + " all history that may reference the sensitive data, via\n" + " "+cmd) + print(m) + ret = subproc.call([arg if arg != '""' else '' for arg in cmd.split()], + cwd=source_working_dir) + if ret != 0: # pragma: no cover + m = _("Warning: Fetching all refs from origin failed") + print(m) + if self._args.sensitive_data_removal: + return + + # Now remove the origin remote + url = self._config_settings[b'remote.origin.url'].decode(errors='replace') + m = _("NOTICE: Removing 'origin' remote; see 'Why is my origin removed?'\n" + " in the manual if you want to push back there.\n" + " (was %s)") % url + print(m) + subproc.call('git remote rm origin'.split(), cwd=target_working_dir) + + def _final_commands(self): + self._finalize_handled = True + self._done_callback and self._done_callback() + + if self._file_info_value: + self._file_info_value.finalize() + if not self._args.quiet: + self._progress_writer.finish() + + def _ref_update(self, target_working_dir): + # Start the update-ref process + p = subproc.Popen('git update-ref --no-deref --stdin'.split(), + stdin=subprocess.PIPE, + cwd=target_working_dir) + + # Remove replace_refs from _orig_refs + replace_refs = {k:v for k, v in self._orig_refs.items() + if k.startswith(b'refs/replace/')} + reverse_replace_refs = collections.defaultdict(list) + for k,v in replace_refs.items(): + reverse_replace_refs[v].append(k) + all(map(self._orig_refs.pop, replace_refs)) + + # Remove unused refs + exported_refs, imported_refs = self.get_exported_and_imported_refs() + refs_to_nuke = exported_refs - imported_refs + # Because revisions can be passed to fast-export which handles them as + # though they were refs, we might have bad "refs" to nuke; strip them out. + refs_to_nuke = [x for x in refs_to_nuke + if x.startswith(b'refs/') or x == b'HEAD'] + if self._args.partial: + refs_to_nuke = set() + if refs_to_nuke and self._args.debug: + print("[DEBUG] Deleting the following refs:\n "+ + decode(b"\n ".join(sorted(refs_to_nuke)))) + p.stdin.write(b''.join([b"delete %s\n" % x + for x in refs_to_nuke])) + + # Delete or update and add replace_refs; note that fast-export automatically + # handles 'update-no-add', we only need to take action for the other four + # choices for replace_refs. + self._flush_renames() + actual_renames = {k:v for k,v in self._commit_renames.items() if k != v} + if self._args.replace_refs in ['delete-no-add', 'delete-and-add']: + # Delete old replace refs, if unwanted + replace_refs_to_nuke = set(replace_refs) + if self._args.replace_refs == 'delete-and-add': + # git-update-ref won't allow us to update a ref twice, so be careful + # to avoid deleting refs we'll later update + replace_refs_to_nuke = replace_refs_to_nuke.difference( + [b'refs/replace/'+x for x in actual_renames]) + p.stdin.write(b''.join([b"delete %s\n" % x + for x in replace_refs_to_nuke])) + if self._args.replace_refs in ['delete-and-add', 'update-or-add', + 'update-and-add']: + # Add new replace refs + update_only = (self._args.replace_refs == 'update-or-add') + p.stdin.write(b''.join([b"update refs/replace/%s %s\n" % (old, new) + for old,new in actual_renames.items() + if new and not (update_only and + old in reverse_replace_refs)])) + + # Complete the update-ref process + p.stdin.close() + if p.wait(): + raise SystemExit(_("git update-ref failed; see above")) # pragma: no cover + + def _remap_to(self, oldish_hash): + ''' + Given an oldish_hash (from the beginning of the current run), return: + IF oldish_hash is NOT pruned: + the hash of the rewrite of oldish_hash + otherwise: + the hash of the rewrite of the first unpruned ancestor of oldish_hash + ''' + old_id = self._orig_graph._hash_to_id[oldish_hash] + new_id = _IDS.translate(old_id) + new_hash = self._graph.git_hash[new_id] if new_id else deleted_hash + return new_hash + + def _compute_metadata(self, metadata_dir, orig_refs): + # + # First, handle commit_renames + # + old_commit_renames = dict() + if not self._already_ran: + commit_renames = {old: new + for old, new in self._commit_renames.items() + } + else: + # Read commit-map into old_commit_renames + with open(os.path.join(metadata_dir, b'commit-map'), 'br') as f: + f.readline() # Skip the header line + for line in f: + (old,new) = line.split() + old_commit_renames[old] = new + # Use A->B mappings in old_commit_renames, and B->C mappings in + # self._commit_renames to yield A->C mappings in commit_renames + commit_renames = {old: self._commit_renames.get(newish, newish) + for old, newish in old_commit_renames.items()} + # If there are any B->C mappings in self._commit_renames for which + # there was no A->B mapping in old_commit_renames, then add the + # B->C mapping to commit_renames too. + seen = set(old_commit_renames.values()) + commit_renames.update({old: new + for old, new in self._commit_renames.items() + if old not in seen}) + + # + # Second, handle ref_maps + # + exported_refs, imported_refs = self.get_exported_and_imported_refs() + + old_commit_unrenames = dict() + if not self._already_ran: + old_ref_map = dict((refname, (old_hash, deleted_hash)) + for refname, old_hash in orig_refs.items() + if refname in exported_refs) + else: + # old_commit_renames talk about how commits were renamed in the original + # run. Let's reverse it to find out how to get from the intermediate + # commit name, back to the original. Because everything in orig_refs + # right now refers to the intermediate commits after the first run(s), + # and we need to map them back to what they were before any changes. + old_commit_unrenames = dict((v,k) for (k,v) in old_commit_renames.items()) + + old_ref_map = {} + # Populate old_ref_map from the 'ref-map' file + with open(os.path.join(metadata_dir, b'ref-map'), 'br') as f: + f.readline() # Skip the header line + for line in f: + (old,intermediate,ref) = line.split() + old_ref_map[ref] = (old, intermediate) + # Append to old_ref_map items from orig_refs that were exported, but + # get the actual original commit name + for refname, old_hash in orig_refs.items(): + if refname in old_ref_map: + continue + if refname not in exported_refs: + continue + # Compute older_hash + original_hash = old_commit_unrenames.get(old_hash, old_hash) + old_ref_map[refname] = (original_hash, deleted_hash) + + new_refs = {} + new_refs_initialized = False + ref_maps = {} + self._orig_graph._ensure_reverse_maps_populated() + for refname, pair in old_ref_map.items(): + old_hash, hash_ref_becomes_if_not_imported_in_this_run = pair + if refname not in imported_refs: + new_hash = hash_ref_becomes_if_not_imported_in_this_run + elif old_hash in commit_renames: + intermediate = old_commit_renames.get(old_hash,old_hash) + if intermediate in self._commit_renames: + new_hash = self._remap_to(intermediate) + else: + new_hash = intermediate + else: # Must be either an annotated tag, or a ref whose tip was pruned + if not new_refs_initialized: + target_working_dir = self._args.target or b'.' + new_refs = GitUtils.get_refs(target_working_dir) + new_refs_initialized = True + if refname in new_refs: + new_hash = new_refs[refname] + else: + new_hash = deleted_hash + ref_maps[refname] = (old_hash, new_hash) + if self._args.source or self._args.target: + if not new_refs_initialized: + target_working_dir = self._args.target or b'.' + new_refs = GitUtils.get_refs(target_working_dir) + new_refs_initialized = True + for ref, new_hash in new_refs.items(): + if ref not in orig_refs and not ref.startswith(b'refs/replace/'): + old_hash = b'0'*len(new_hash) + ref_maps[ref] = (old_hash, new_hash) + + # + # Third, handle first_changes + # + + old_first_changes = dict() + if self._already_ran: + # Read first_changes into old_first_changes + with open(os.path.join(metadata_dir, b'first-changed-commits'), 'br') as f: + for line in f: + changed_commit, undeleted_self_or_ancestor = line.strip().split() + old_first_changes[changed_commit] = undeleted_self_or_ancestor + # We need to find the commits that were modified whose parents were not. + # To be able to find parents, we need the commit names as of the beginning + # of this run, and then when we are done, we need to map them back to the + # name of the commits from before any git-filter-repo runs. + # + # We are excluding here any commits deleted in previous git-filter-repo + # runs + undo_old_commit_renames = dict((v,k) for (k,v) in old_commit_renames.items() + if v != deleted_hash) + # Get a list of all commits that were changed, as of the beginning of + # this latest run. + changed_commits = {new + for (old,new) in old_commit_renames.items() + if old != new and new != deleted_hash} | \ + {old + for (old,new) in self._commit_renames.items() + if old != new} + special_changed_commits = {old + for (old,new) in old_commit_renames.items() + if new == deleted_hash} + first_changes = dict() + for (old,new) in self._commit_renames.items(): + if old == new: + # old wasn't modified, can't be first change if not even a change + continue + if old_commit_unrenames.get(old,old) != old: + # old was already modified in previous run; while it might represent + # something that is still a first change, we'll handle that as we + # loop over old_first_changes below + continue + if any(parent in changed_commits + for parent in self._orig_graph.get_parent_hashes(old)): + # a parent of old was modified, so old is not a first change + continue + # At this point, old IS a first change. We need to find out what new + # commit it maps to, or if it doesn't map to one, what new commit was + # its most recent ancestor that wasn't pruned. + if new is None: + new = self._remap_to(old) + first_changes[old] = (new if new is not None else deleted_hash) + for (old,undeleted_self_or_ancestor) in old_first_changes.items(): + if undeleted_self_or_ancestor == deleted_hash: + # old represents a commit that was pruned and whose entire ancestry + # was pruned. So, old is still a first change + first_changes[old] = undeleted_self_or_ancestor + continue + intermediate = old_commit_renames.get(old, old) + usoa = undeleted_self_or_ancestor + new_ancestor = self._commit_renames.get(usoa, usoa) + if intermediate == deleted_hash: + # old was pruned in previous rewrite + if usoa != new_ancestor: + # old's ancestor got rewritten in this filtering run; we can drop + # this one from first_changes. + continue + # Getting here means old was a first change and old was pruned in a + # previous run, and its ancestors that survived were non rewritten in + # this run, so old remains a first change + first_changes[old] = new_ancestor # or usoa, since new_ancestor == usoa + continue + assert(usoa == intermediate) # old wasn't pruned => usoa == intermediate + + # Check whether parents of intermediate were rewritten. Note that + # intermediate in self._commit_renames only means that intermediate was + # processed by the latest filtering (not necessarily that it changed), + # but we need to know that before we can check for parent hashes having + # changed. + if intermediate not in self._commit_renames: + # This commit was not processed by this run, so it remains a first + # change + first_changes[old] = usoa + continue + if any(parent in changed_commits + for parent in self._orig_graph.get_parent_hashes(intermediate)): + # An ancestor was modified by this run, so it is no longer a first + # change; continue to the next one. + continue + # This change is a first_change; find the new commit its usoa maps to + new = self._remap_to(intermediate) + assert(new is not None) + first_changes[old] = new + + return commit_renames, ref_maps, first_changes + + def _handle_lfs_metadata(self, metadata_dir): + if self._lfs_object_tracker is None: + print("NOTE: LFS object orphaning not checked (LFS not in use)") + return + + if self._args.partial: + target_working_dir = self._args.target or b'.' + source = False + self._lfs_object_tracker.find_all_lfs_objects_in_repo(target_working_dir, + source) + + with open(os.path.join(metadata_dir, b'original_lfs_objects'), 'bw') as f: + for obj in sorted(self._lfs_object_tracker.source_objects.objects): + f.write(obj+b"\n") + + orphaned_lfs_path = os.path.join(metadata_dir, b'orphaned_lfs_objects') + msg = textwrap.dedent(_(f"""\ + NOTE: There were LFS Objects Orphaned by this rewrite recorded in + {decode(orphaned_lfs_path)}.""")) + with open(orphaned_lfs_path, 'bw') as f: + differences = self._lfs_object_tracker.source_objects.objects - \ + self._lfs_object_tracker.target_objects.objects + for obj in sorted(differences): + f.write(obj+b"\n") + if differences: + self._lfs_object_tracker.objects_orphaned = True + print(msg) + + def _record_metadata(self, metadata_dir, orig_refs): + self._flush_renames() + commit_renames, ref_maps, first_changes = \ + self._compute_metadata(metadata_dir, orig_refs) + + if self._args.sensitive_data_removal: + changed_commits = sum(k!=v for (k,v) in commit_renames.items()) + print(f"You rewrote {changed_commits} (of {len(commit_renames)}) commits.") + print("") # Add a blank line before important rewrite information + print(f"NOTE: First Changed Commit(s) is/are:\n " + + decode(b"\n ".join(x for x in first_changes))) + + with open(os.path.join(metadata_dir, b'sensitive_data_removal'), 'bw') as f: + pass # Write nothing; we only need the file created + + self._handle_lfs_metadata(metadata_dir) + print("") # Add a blank line after important rewrite information + + with open(os.path.join(metadata_dir, b'commit-map'), 'bw') as f: + f.write(("%-40s %s\n" % (_("old"), _("new"))).encode()) + for (old,new) in sorted(commit_renames.items()): + msg = b'%s %s\n' % (old, new if new != None else deleted_hash) + f.write(msg) + + with open(os.path.join(metadata_dir, b'ref-map'), 'bw') as f: + f.write(("%-40s %-40s %s\n" % (_("old"), _("new"), _("ref"))).encode()) + for refname, hash_pair in sorted(ref_maps.items()): + (old_hash, new_hash) = hash_pair + f.write(b'%s %s %s\n' % (old_hash, new_hash, refname)) + if old_hash != new_hash: + self._changed_refs.add(refname) + + with open(os.path.join(metadata_dir, b'changed-refs'), 'bw') as f: + for refname in sorted(self._changed_refs): + f.write(b'%s\n' % refname) + + with open(os.path.join(metadata_dir, b'first-changed-commits'), 'bw') as f: + for commit, undeleted_self_or_ancestor in sorted(first_changes.items()): + f.write(b'%s %s\n' % (commit, undeleted_self_or_ancestor)) + + with open(os.path.join(metadata_dir, b'suboptimal-issues'), 'bw') as f: + issues_found = False + if self._commits_no_longer_merges: + issues_found = True + + f.write(textwrap.dedent(_(''' + The following commits used to be merge commits but due to filtering + are now regular commits; they likely have suboptimal commit messages + (e.g. "Merge branch next into master"). Original commit hash on the + left, commit hash after filtering/rewriting on the right: + ''')[1:]).encode()) + for oldhash, newhash in self._commits_no_longer_merges: + f.write(' {} {}\n'.format(oldhash, newhash).encode()) + f.write(b'\n') + + if self._commits_referenced_but_removed: + issues_found = True + f.write(textwrap.dedent(_(''' + The following commits were filtered out, but referenced in another + commit message. The reference to the now-nonexistent commit hash + (or a substring thereof) was left as-is in any commit messages: + ''')[1:]).encode()) + for bad_commit_reference in self._commits_referenced_but_removed: + f.write(' {}\n'.format(bad_commit_reference).encode()) + f.write(b'\n') + + if not issues_found: + f.write(_("No filtering problems encountered.\n").encode()) + + with open(os.path.join(metadata_dir, b'already_ran'), 'bw') as f: + f.write(_("This file exists to allow you to filter again without --force,\n" + "and to specify that metadata files should be updated instead\n" + "of rewritten").encode()) + + def finish(self): + ''' Alternative to run() when there is no input of our own to parse, + meaning that run only really needs to close the handle to fast-import + and let it finish, thus making a call to "run" feel like a misnomer. ''' + assert not self._input + assert self._managed_output + self.run() + + def insert(self, obj, direct_insertion = False): + if not direct_insertion: + if type(obj) == Blob: + self._tweak_blob(obj) + elif type(obj) == Commit: + aux_info = {'orig_parents': obj.parents, + 'had_file_changes': bool(obj.file_changes)} + self._tweak_commit(obj, aux_info) + elif type(obj) == Reset: + self._tweak_reset(obj) + elif type(obj) == Tag: + self._tweak_tag(obj) + self._insert_into_stream(obj) + + def _insert_into_stream(self, obj): + if not obj.dumped: + if self._lfs_object_tracker: + self._lfs_object_tracker.check_output_object(obj) + if self._parser: + self._parser.insert(obj) + else: + obj.dump(self._output) + + def get_exported_and_imported_refs(self): + return self._parser.get_exported_and_imported_refs() + + def run(self): + start = time.time() + if not self._input and not self._output: + self._run_sanity_checks() + if not self._args.dry_run and not self._args.partial: + self._read_stash() + self._migrate_origin_to_heads() + self._setup_input(use_done_feature = True) + self._setup_output() + assert self._sanity_checks_handled + + if self._input: + # Create and run the filter + self._repo_working_dir = self._args.source or b'.' + self._parser = FastExportParser(blob_callback = self._tweak_blob, + commit_callback = self._tweak_commit, + tag_callback = self._tweak_tag, + reset_callback = self._tweak_reset, + done_callback = self._final_commands) + self._setup_lfs_orphaning_checks() + self._parser.run(self._input, self._output) + if not self._finalize_handled: + self._final_commands() + + # Make sure fast-export completed successfully + if not self._args.stdin and self._fep.wait(): + raise SystemExit(_("Error: fast-export failed; see above.")) # pragma: no cover + self._input.close() + + # If we're not the manager of self._output, we should avoid post-run cleanup + if not self._managed_output: + return + + # Close the output and ensure fast-import successfully completes + self._output.close() + if not self._args.dry_run and self._fip.wait(): + raise SystemExit(_("Error: fast-import failed; see above.")) # pragma: no cover + + # With fast-export and fast-import complete, update state if requested + if self._args.state_branch: + self._save_marks_files() + + # Notify user how long it took, before doing a gc and such + msg = "New history written in {:.2f} seconds..." + if self._args.repack: + msg = "New history written in {:.2f} seconds; now repacking/cleaning..." + print(msg.format(time.time()-start)) + + # Exit early, if requested + if self._args.dry_run: + print(_("NOTE: Not running fast-import or cleaning up; --dry-run passed.")) + if self._fe_orig: + print(_(" Requested filtering can be seen by comparing:")) + print(" " + decode(self._fe_orig)) + else: + print(_(" Requested filtering can be seen at:")) + print(" " + decode(self._fe_filt)) + return + + target_working_dir = self._args.target or b'.' + if self._input: + self._ref_update(target_working_dir) + + # Write out data about run + self._record_metadata(self.results_tmp_dir(), self._orig_refs) + + # Final cleanup: + # If we need a repack, then nuke the reflogs and repack. + # If we need a reset, do a reset --hard + reset = not GitUtils.is_repository_bare(target_working_dir) + self.cleanup(target_working_dir, self._args.repack, reset, + run_quietly=self._args.quiet, + show_debuginfo=self._args.debug) + + # Let user know how long it took + print(_("Completely finished after {:.2f} seconds.") + .format(time.time()-start)) + + # Give post-rewrite instructions for cleaning up other copies for SDR + if self._args.sensitive_data_removal: + lfs_note = "" + if self._lfs_object_tracker and \ + self._lfs_object_tracker.objects_orphaned == True: + lfs_note = _(" and LFS Objects Orphaned") + push_command = "git push --force --mirror origin" + if self._args.no_fetch: + if self._args.partial: + push_command = "git push --force origin " + \ + " ".join(sorted([decode(x) for x in self._changed_refs])) + else: + push_command = "git push --all --tags origin" + print("") + print(sdr_next_steps % (push_command, lfs_note, lfs_note)) + +def main(): + setup_gettext() + args = FilteringOptions.parse_args(sys.argv[1:]) + if args.analyze: + RepoAnalyze.run(args) + else: + filter = RepoFilter(args) + filter.run() + +if __name__ == '__main__': + main() diff --git a/contrib/lint-php-src b/contrib/lint-php-src new file mode 100755 index 00000000000..49734c86071 --- /dev/null +++ b/contrib/lint-php-src @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +set -u + +target="${1:-src}" +php_bin="${PHP_BIN:-php}" + +if [[ ! -d "$target" ]]; then + echo "PHP syntax check target does not exist: $target" + exit 1 +fi + +if ! php_version="$("$php_bin" -r 'echo PHP_VERSION;' 2>/dev/null)"; then + echo "Could not determine PHP version using $php_bin." + exit 1 +fi + +echo "Checking PHP syntax in $target/ with PHP $php_version" + +while IFS= read -r -d '' file; do + if ! output="$("$php_bin" -l "$file" 2>&1)"; then + echo "$output" + echo "Fix the errors above before continuing." + exit 1 + fi +done < <(find "$target" -type f -name '*.php' -print0) + +echo "No obvious PHP errors found" diff --git a/contrib/pre-commit b/contrib/pre-commit index 17332fc62ac..9836ae75070 100644 --- a/contrib/pre-commit +++ b/contrib/pre-commit @@ -28,7 +28,7 @@ done if [ "$SFILES" != "" ] then echo "Running PHPCS" - ./vendor/bin/phpcs --standard=vendor/cakephp/cakephp-codesniffer/CakePHP $SFILES + ./vendor/bin/phpcs $SFILES if [ $? != 0 ] then echo "PHPCS Errors found; commit aborted." diff --git a/contrib/validate-deprecation-aliases.php b/contrib/validate-deprecation-aliases.php new file mode 100644 index 00000000000..175c338d519 --- /dev/null +++ b/contrib/validate-deprecation-aliases.php @@ -0,0 +1,76 @@ +#!/usr/bin/php -q + $iterator */ +$iterator = new RecursiveIteratorIterator($di); + +$code = 0; +foreach ($iterator as $file) { + if (pathinfo((string)$file, PATHINFO_EXTENSION) !== 'php') { + continue; + } + if (pathinfo((string)$file, PATHINFO_FILENAME) === 'functions') { + continue; + } + if (strpos($file->getRealPath(), '/TestSuite/')) { + continue; + } + + $content = file_get_contents((string)$file); + if (!strpos($content, 'class_alias(')) { + continue; + } + + preg_match('#class_alias\(\s*\'([^\']+)\',#', $content, $matches); + if (!$matches) { + var_dump($content); + var_dump($file->getPath()); + exit(1); + } + + echo $matches[1] . PHP_EOL; + $filePath = str_replace('\\', '/', $matches[1]); + $filePath = str_replace('Cake/', $path, $filePath); + $filePath .= '.php'; + if (!file_exists($filePath)) { + throw new RuntimeException('Cannot find path for `' . $matches[1] . '`'); + } + + $newFileContent = file_get_contents($filePath); + + if (!str_contains($newFileContent, 'class_exists(') && !str_contains($newFileContent, 'class_alias(')) { + $oldPath = str_replace($path, '', $file->getRealPath()); + $newPath = str_replace($path, '', $filePath); + echo "\033[31m" . ' * Missing `class_exists()` or `class_alias()` on new file for `' . $oldPath . '` => `' . $newPath . '`' . "\033[0m" . PHP_EOL; + $code = 1; + } else { + echo ' * OK' . PHP_EOL; + } +} + +exit($code); diff --git a/contrib/validate-split-packages-phpstan.php b/contrib/validate-split-packages-phpstan.php new file mode 100644 index 00000000000..7c7b4431501 --- /dev/null +++ b/contrib/validate-split-packages-phpstan.php @@ -0,0 +1,80 @@ +#!/usr/bin/php -q + $iterator */ +$iterator = new RegexIterator($iterator, '~/src/\w+/composer.json$~'); + +$packages = []; +$code = 0; +foreach ($iterator as $file) { + $filePath = $file->getPath(); + $package = substr($filePath, strrpos($filePath, '/') + 1); + $packages[$filePath . '/'] = $package; +} +ksort($packages); + +$phivePharsXml = simplexml_load_file(dirname(__FILE__, 2) . DS . '.phive' . DS . 'phars.xml'); +$phpstanVersion = null; +foreach ($phivePharsXml->phar as $phar) { + if ($phar->attributes()->name == 'phpstan') { + $phpstanVersion = (string)$phar->attributes()->version; + break; + } +} +// Prefer the dev branches of sibling cakephp packages over the latest tagged +// release. The split packages require siblings as `5.4.*@dev`, which also matches +// already-published RC/stable tags; composer prefers those tags, so it would +// validate against released code that can lag behind the monorepo. Forcing dev +// stability makes it resolve the `dev-` mirror instead, matching local src. +$composerCommand = 'composer config minimum-stability dev' + . ' && composer config prefer-stable false' + . ' && composer require --dev phpstan/phpstan:' . $phpstanVersion; + +$issues = []; +foreach ($packages as $path => $package) { + if (!file_exists($path . 'phpstan.neon.dist')) { + continue; + } + + $exitCode = null; + exec( + 'cd ' . $path . ' && ' . $composerCommand . ' && vendor/bin/phpstan analyze ./', + $output, + $exitCode + ); + if ($exitCode !== 0) { + $code = $exitCode; + + $issues[] = $package . ': ' . PHP_EOL . implode(PHP_EOL, $output); + } + exec('cd ' . $path . ' && rm composer.lock && rm -rf vendor && git checkout composer.json'); +} + +echo implode(PHP_EOL . PHP_EOL, $issues); + +exit($code); diff --git a/contrib/validate-split-packages.php b/contrib/validate-split-packages.php new file mode 100644 index 00000000000..01dfa998945 --- /dev/null +++ b/contrib/validate-split-packages.php @@ -0,0 +1,103 @@ +#!/usr/bin/php -q + $iterator */ +$iterator = new RegexIterator($iterator, '~/src/\w+/composer.json$~'); + +$packages = []; +$code = 0; +foreach ($iterator as $file) { + $filePath = $file->getPath(); + $package = substr($filePath, strrpos($filePath, '/') + 1); + if ($package === 'ORM') { + $fullName = 'cakephp/orm'; + } else { + $fullName = 'cakephp/' . Inflector::dasherize($package); + } + $packages[$fullName] = $package; +} +ksort($packages); + +$mainJsonContent = file_get_contents(dirname(__FILE__, 2) . DS . 'composer.json'); +$mainJson = json_decode($mainJsonContent, true); +$mainReplace = $mainJson['replace']; +$missing = []; +foreach ($packages as $fullPackageName => $package) { + if (!empty($mainReplace[$fullPackageName])) { + unset($mainReplace[$fullPackageName]); + + continue; + } + + $missing[] = $package; +} +if ($mainReplace) { + echo "\033[31m" . ' * Missing "replace" statement in ROOT composer.json for package `' . $package . '`' . "\033[0m" . PHP_EOL; + $code = 1; +} +if ($missing) { + echo "\033[31m" . ' * Extra "replace" statement in ROOT composer.json for non-existent package(s) `' . implode(', ', $missing) . '`' . "\033[0m" . PHP_EOL; + $code = 1; +} + +$mainRequire = $mainJson['require']; + +$issues = []; +foreach ($packages as $package) { + $content = file_get_contents($path . $package . DS . 'composer.json'); + $json = json_decode($content, true); + $require = $json['require'] ?? []; + + foreach ($require as $packageName => $constraint) { + if (isset($packages[$packageName])) { + continue; + } + + if (!isset($mainRequire[$packageName])) { + $issues[$package][] = 'Missing package requirement `' . $packageName . ': ' . $constraint . '` in ROOT composer.json'; + + continue; + } + + if ($mainRequire[$packageName] !== $constraint) { + $issues[$package][] = 'Package requirement `' . $packageName . ': ' . $constraint . '` does not match the one in ROOT composer.json (`' . $mainRequire[$packageName] . '`)'; + } + } +} + +foreach ($issues as $packageName => $packageIssues) { + echo "\033[31m" . $packageName . ':' . "\033[0m" . PHP_EOL; + foreach ($packageIssues as $issue) { + echo "\033[31m" . ' - ' . $issue . "\033[0m" . PHP_EOL; + $code = 1; + } +} + +exit($code); diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 00000000000..0b37bd17669 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,41 @@ + + + config/ + src/ + tests/ + + + + + + + + + + + + + + + + + + + + + + + tests/test_app/* + + + + + + + + + + 0 + src/Collection/functions.php + + diff --git a/phpcs.xml.dist b/phpcs.xml.dist deleted file mode 100644 index 9d4c21b752d..00000000000 --- a/phpcs.xml.dist +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - 0 - - diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 00000000000..e47233b8c60 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,291 @@ +parameters: + ignoreErrors: + - + message: '#^Call to an undefined method Redis\|RedisCluster\:\:connect\(\)\.$#' + identifier: method.notFound + count: 2 + path: src/Cache/Engine/RedisEngine.php + + - + message: '#^Call to an undefined method Redis\|RedisCluster\:\:pconnect\(\)\.$#' + identifier: method.notFound + count: 2 + path: src/Cache/Engine/RedisEngine.php + + - + message: '#^Unsafe usage of new static\(\)\.$#' + identifier: new.static + count: 1 + path: src/Collection/Iterator/NestIterator.php + + - + message: '#^Unsafe usage of new static\(\)\.$#' + identifier: new.static + count: 1 + path: src/Collection/Iterator/NoChildrenIterator.php + + - + message: '#^Parameter \#1 \$args of method Cake\\Command\\RoutesGenerateCommand\:\:_splitArgs\(\) expects array\, array\\|string\> given\.$#' + identifier: argument.type + count: 1 + path: src/Command/RoutesGenerateCommand.php + + - + message: '#^Unsafe usage of new static\(\)\.$#' + identifier: new.static + count: 2 + path: src/Console/ConsoleOptionParser.php + + - + message: '#^Unknown parameter \$components in call to method ReflectionClass\\:\:newInstance\(\)\.$#' + identifier: argument.unknown + count: 1 + path: src/Controller/ControllerFactory.php + + - + message: '#^Unknown parameter \$request in call to method ReflectionClass\\:\:newInstance\(\)\.$#' + identifier: argument.unknown + count: 1 + path: src/Controller/ControllerFactory.php + + - + message: '#^Unsafe usage of new static\(\)\.$#' + identifier: new.static + count: 9 + path: src/Database/Expression/QueryExpression.php + + - + message: '#^Dead catch \- InvalidArgumentException is never thrown in the try block\.$#' + identifier: catch.neverThrown + count: 1 + path: src/Database/Type/DateTimeType.php + + - + message: '#^Unsafe usage of new static\(\)\.$#' + identifier: new.static + count: 2 + path: src/Datasource/Paging/SortableFieldsBuilder.php + + - + message: '#^Template type TKey of method Cake\\Datasource\\QueryInterface\:\:all\(\) is not referenced in a parameter\.$#' + identifier: method.templateTypeNotInParameter + count: 1 + path: src/Datasource/QueryInterface.php + + - + message: '#^Template type TValue of method Cake\\Datasource\\QueryInterface\:\:all\(\) is not referenced in a parameter\.$#' + identifier: method.templateTypeNotInParameter + count: 1 + path: src/Datasource/QueryInterface.php + + - + message: '#^Constructor of class Cake\\Error\\Renderer\\ConsoleExceptionRenderer has an unused parameter \$request\.$#' + identifier: constructor.unusedParameter + count: 1 + path: src/Error/Renderer/ConsoleExceptionRenderer.php + + - + message: '#^Method Cake\\Event\\EventManager\:\:dispatch\(\) should return Cake\\Event\\EventInterface\ but returns Cake\\Event\\Event\\|Cake\\Event\\EventInterface\\.$#' + identifier: return.type + count: 2 + path: src/Event/EventManager.php + + - + message: '#^Unsafe usage of new static\(\)\.$#' + identifier: new.static + count: 1 + path: src/Event/EventManager.php + + - + message: '#^Unsafe usage of new static\(\)\.$#' + identifier: new.static + count: 1 + path: src/Http/Client.php + + - + message: '#^Constructor of class Cake\\Http\\Client\\Auth\\Digest has an unused parameter \$options\.$#' + identifier: constructor.unusedParameter + count: 1 + path: src/Http/Client/Auth/Digest.php + + - + message: '#^Unsafe usage of new static\(\)\.$#' + identifier: new.static + count: 1 + path: src/Http/Cookie/Cookie.php + + - + message: '#^Unsafe usage of new static\(\)\.$#' + identifier: new.static + count: 2 + path: src/Http/Cookie/CookieCollection.php + + - + message: '#^Unsafe usage of new static\(\)\.$#' + identifier: new.static + count: 1 + path: src/Http/Session.php + + - + message: '#^@readonly property cannot have a default value\.$#' + identifier: property.readOnlyByPhpDocDefaultValue + count: 7 + path: src/I18n/Date.php + + - + message: '#^@readonly property cannot have a default value\.$#' + identifier: property.readOnlyByPhpDocDefaultValue + count: 9 + path: src/I18n/DateTime.php + + - + message: '#^@readonly property cannot have a default value\.$#' + identifier: property.readOnlyByPhpDocDefaultValue + count: 4 + path: src/I18n/Time.php + + - + message: '#^PHPDoc tag @var with type callable\(\)\: mixed is not subtype of native type Closure\(string\)\: string\.$#' + identifier: varTag.nativeType + count: 1 + path: src/ORM/Association/DependentDeleteHelper.php + + - + message: '#^Unsafe usage of new static\(\)\.$#' + identifier: new.static + count: 2 + path: src/ORM/EagerLoader.php + + - + message: '#^Method Cake\\ORM\\Query\\SelectQuery\:\:find\(\) should return static\(Cake\\ORM\\Query\\SelectQuery\\) but returns Cake\\ORM\\Query\\SelectQuery\\.$#' + identifier: return.type + count: 1 + path: src/ORM/Query/SelectQuery.php + + - + message: '#^PHPDoc tag @var with type class\-string\ is not subtype of native type ''Cake\\\\Datasource\\\\RulesChecker''\|class\-string\\.$#' + identifier: varTag.nativeType + count: 1 + path: src/ORM/Table.php + + - + message: '#^Call to function assert\(\) with true will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/Routing/Route/Route.php + + - + message: '#^Unsafe usage of new static\(\)\.$#' + identifier: new.static + count: 1 + path: src/Routing/RouteBuilder.php + + - + message: '#^Call to method toString\(\) of internal interface PHPUnit\\Framework\\SelfDescribing from outside its root namespace PHPUnit\.$#' + identifier: method.internalInterface + count: 1 + path: src/TestSuite/Constraint/Response/StatusCodeBase.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with false and non\-falsy\-string will always evaluate to false\.$#' + identifier: method.impossibleType + count: 1 + path: src/TestSuite/TestCase.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true and ''%%s'' will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: src/TestSuite/TestCase.php + + - + message: '#^PHPDoc tag @var with type array\ is not subtype of native type array\{explains\?\: list\, attrs\?\: non\-empty\-list\, 0\?\: string, 1\?\: string, 2\?\: int\<1, max\>\}\.$#' + identifier: varTag.nativeType + count: 1 + path: src/TestSuite/TestCase.php + + - + message: '#^PHPDoc tag @var with type array\ is not subtype of native type array\{explains\?\: list\, attrs\: non\-empty\-list\, 0\?\: string, 1\?\: string, 2\?\: int\<1, max\>\}\.$#' + identifier: varTag.nativeType + count: 1 + path: src/TestSuite/TestCase.php + + - + message: '#^Parameter \#1 \$methods of method PHPUnit\\Framework\\MockObject\\MockBuilder\\:\:onlyMethods\(\) expects list\, array\, non\-falsy\-string\> given\.$#' + identifier: argument.type + count: 1 + path: src/TestSuite/TestCase.php + reportUnmatched: false + + - + message: '#^Parameter \#1 \$methods of method PHPUnit\\Framework\\MockObject\\TestDoubleBuilder\:\:onlyMethods\(\) expects list\, array\, non\-falsy\-string\> given\.$#' + identifier: argument.type + count: 1 + path: src/TestSuite/TestCase.php + reportUnmatched: false + + - + message: '#^Strict comparison using \=\=\= between null and null will always evaluate to true\.$#' + identifier: identical.alwaysTrue + count: 1 + path: src/TestSuite/TestCase.php + + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: src/TestSuite/TestCase.php + + - + message: '#^Unable to resolve the template type T in call to static method Cake\\Utility\\Hash\:\:insert\(\)$#' + identifier: argument.templateType + count: 1 + path: src/Utility/Hash.php + + - + message: '#^Parameter \#1 \$objectOrClass of class ReflectionEnum constructor expects class\-string\\|UnitEnum, class\-string given\.$#' + identifier: argument.type + count: 1 + path: src/Validation/Validation.php + + - + message: '#^Unsafe usage of new static\(\)\.$#' + identifier: new.static + count: 1 + path: src/View/Form/ContextFactory.php + + - + message: '#^Constructor of class Cake\\View\\Form\\NullContext has an unused parameter \$context\.$#' + identifier: constructor.unusedParameter + count: 1 + path: src/View/Form/NullContext.php + + - + message: '#^Call to an undefined method DateTimeInterface\:\:setTimezone\(\)\.$#' + identifier: method.notFound + count: 1 + path: src/View/Helper/TimeHelper.php + + - + message: '#^Call to function method_exists\(\) with \*NEVER\* and ''viewBuilder'' will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 1 + path: src/View/View.php + + - + message: '#^Strict comparison using \=\=\= between array and false will always evaluate to false\.$#' + identifier: identical.alwaysFalse + count: 1 + path: src/View/View.php + + - + message: '#^Cannot access constant class on Closure\|PDO\|resource\.$#' + identifier: classConstant.nonObject + count: 1 + path: src/View/ViewBuilder.php + + - + message: '#^Call to function assert\(\) with false and string will always evaluate to false\.$#' + identifier: function.impossibleType + count: 1 + path: src/ORM/EagerLoader.php diff --git a/phpstan.neon b/phpstan.neon deleted file mode 100644 index 0c5cd08180a..00000000000 --- a/phpstan.neon +++ /dev/null @@ -1,38 +0,0 @@ -parameters: - autoload_files: - - %rootDir%/../../../tests/bootstrap.php - ignoreErrors: - - '#Function wincache_ucache_[a-zA-Z0-9_]+ not found#' - - '#Function xcache_[a-zA-Z0-9_]+ not found#' - - '#Cake\\Database\\Type\\[a-zA-Z0-9_]+Type::__construct\(\) does not call parent constructor from Cake\\Database\\Type#' - - '#Constructor of class Cake\\[a-zA-Z0-9_\\]+ has an unused parameter#' - - '#Access to undefined constant Memcached::OPT_CLIENT_MODE#' - - '#Access to undefined constant Memcached::DYNAMIC_CLIENT_MODE#' - - '#Access to undefined constant PDO::SQLSRV_ATTR_ENCODING#' - - '#Access to undefined constant PDO::SQLSRV_ENCODING_BINARY#' - - '#Constant XC_TYPE_VAR not found#' - - '#Call to an undefined method Psr\\Http\\Message\\ResponseInterface::getCookies\(\)#' - - '#Access to an undefined property Psr\\Http\\Message\\UriInterface::\$webroot#' - - '#Access to an undefined property Psr\\Http\\Message\\UriInterface::\$base#' - - '#Result of method Cake\\Http\\Response::send\(\) \(void\) is used#' - - '#Method Cake\\View\\Form\\ContextInterface::val\(\) invoked with 2 parameters, 1 required#' - - '#Access to an undefined property Exception::\$queryString#' - - '#Access to an undefined property PHPUnit\\Framework\\Test::\$fixtureManager#' - - '#Method Redis::#' - - '#Call to an undefined method Traversable::getArrayCopy().#' - - '#Variable \$config in isset\(\) is never defined#' - - '#Call to static method id\(\) on an unknown class PHPUnit_Runner_Version#' - - '#Call to an undefined method DateTimeInterface::i18nFormat\(\)#' - - '#Call to an undefined method object::__toString\(\)#' - - '#Call to an undefined method object::toArray\(\)#' - - '#Call to an undefined method object::__debugInfo\(\)#' - earlyTerminatingMethodCalls: - Cake\Shell\Shell: - - abort - -services: - - - class: Cake\PHPStan\AssociationTableMixinClassReflectionExtension - tags: - - phpstan.broker.methodsClassReflectionExtension - - phpstan.broker.propertiesClassReflectionExtension diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 00000000000..0c9ada3f739 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,37 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 8 + phpVersion: + min: 80200 + max: 80599 + treatPhpDocTypesAsCertain: false + featureToggles: + internalTag: true + bootstrapFiles: + - tests/bootstrap.php + paths: + - src/ + ignoreErrors: + - identifier: missingType.iterableValue + - identifier: include.fileNotFound + - identifier: method.internalClass + - identifier: new.internalClass + - identifier: trait.unused + - identifier: method.templateTypeNotInParameter + - + identifier: generics.interfaceConflict + paths: + - src/Collection/Iterator/TreeIterator.php + - src/Collection/Iterator/TreePrinter.php + - + message: '#^Call to an undefined method PHPUnit\\Framework\\MockObject\\MockBuilder\\:\:addMethods\(\)\.$#' + reportUnmatched: false + +services: + - + class: Cake\PHPStan\AssociationTableMixinClassReflectionExtension + tags: + - phpstan.broker.methodsClassReflectionExtension + - phpstan.broker.propertiesClassReflectionExtension diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 28bc00ae289..5e633227bf7 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,57 +1,75 @@ - - - - - - - + failOnDeprecation="true" + displayDetailsOnTestsThatTriggerDeprecations="true" + cacheDirectory=".phpunit.cache"> - ./tests/TestCase/ + tests/TestCase/ - ./tests/TestCase/Database/ - ./tests/TestCase/ORM/ + tests/TestCase/Database/ + tests/TestCase/ORM/ + tests/TestCase/Collection/FunctionsGlobalTest.php + tests/TestCase/Core/FunctionsGlobalTest.php + tests/TestCase/Routing/FunctionsGlobalTest.php - ./tests/TestCase/DatabaseSuite.php + tests/TestCase/Database/ + tests/TestCase/ORM/ + + + ./vendor/http-interop/http-factory-tests/test + + + tests/TestCase/Collection/FunctionsGlobalTest.php + tests/TestCase/Core/FunctionsGlobalTest.php + tests/TestCase/Routing/FunctionsGlobalTest.php - - - - - - - - - - - - ./src/ - - + + + + + + src/ + + + + + + + + + - + + + + + + + + + diff --git a/psalm-baseline.xml b/psalm-baseline.xml new file mode 100644 index 00000000000..ad810f56aca --- /dev/null +++ b/psalm-baseline.xml @@ -0,0 +1,43 @@ + + + + + container === null) { + throw new CakeException('Container not set.'); + } + + return $this->container; + }]]> + container === null) { + throw new CakeException('Container not set.'); + } + + return $this->container; + }]]> + + + + + + _config]]> + _config]]> + + + + + _fields[$field]]]> + + + + + + + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 00000000000..7b40fa17225 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/rector.php b/rector.php new file mode 100644 index 00000000000..5052ba12fed --- /dev/null +++ b/rector.php @@ -0,0 +1,158 @@ +withPaths([ + __DIR__ . '/config', + __DIR__ . '/contrib', + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + + ->withCache( + cacheClass: FileCacheStorage::class, + cacheDirectory: $cacheDir, + ) + + ->withPhpSets() + ->withAttributesSets() + + ->withSets([ + SetList::CODE_QUALITY, + SetList::CODING_STYLE, + SetList::DEAD_CODE, + SetList::EARLY_RETURN, + SetList::INSTANCEOF, + SetList::TYPE_DECLARATION, + ]) + + ->withConfiguredRule(\Rector\CodeQuality\Rector\FunctionLike\SimplifyUselessVariableRector::class, [ + \Rector\CodeQuality\Rector\FunctionLike\SimplifyUselessVariableRector::ONLY_DIRECT_ASSIGN => true, + ]) + + ->withSkip([ + __DIR__ . '/tests/test_app/templates', + __DIR__ . '/tests/test_app/Plugin/TestPlugin/templates', + + \Rector\CodeQuality\Rector\Catch_\ThrowWithPreviousExceptionRector::class, + \Rector\CodeQuality\Rector\ClassMethod\ExplicitReturnNullRector::class, + \Rector\CodeQuality\Rector\ClassMethod\OptionalParametersAfterRequiredRector::class, + \Rector\CodeQuality\Rector\Class_\CompleteDynamicPropertiesRector::class, + \Rector\CodeQuality\Rector\Concat\JoinStringConcatRector::class, + \Rector\CodeQuality\Rector\Foreach_\ForeachToInArrayRector::class, + \Rector\CodeQuality\Rector\Foreach_\UnusedForeachValueToArrayKeysRector::class, + \Rector\CodeQuality\Rector\FuncCall\CompactToVariablesRector::class, + \Rector\CodeQuality\Rector\FuncCall\SimplifyRegexPatternRector::class, + \Rector\CodeQuality\Rector\Identical\FlipTypeControlToUseExclusiveTypeRector::class, + \Rector\CodeQuality\Rector\If_\ConsecutiveNullCompareReturnsToNullCoalesceQueueRector::class, + \Rector\CodeQuality\Rector\If_\ExplicitBoolCompareRector::class, + \Rector\CodeQuality\Rector\If_\SimplifyIfElseToTernaryRector::class, + \Rector\CodeQuality\Rector\If_\SimplifyIfReturnBoolRector::class, + \Rector\CodeQuality\Rector\Include_\AbsolutizeRequireAndIncludePathRector::class, + \Rector\CodeQuality\Rector\Isset_\IssetOnPropertyObjectToPropertyExistsRector::class, + \Rector\CodingStyle\Rector\Catch_\CatchExceptionNameMatchingTypeRector::class, + \Rector\CodingStyle\Rector\Catch_\CatchExceptionNameMatchingTypeRector::class, + \Rector\CodingStyle\Rector\ClassMethod\MakeInheritedMethodVisibilitySameAsParentRector::class, + \Rector\CodingStyle\Rector\ClassMethod\NewlineBeforeNewAssignSetRector::class, + \Rector\CodingStyle\Rector\Encapsed\EncapsedStringsToSprintfRector::class, + \Rector\CodingStyle\Rector\FuncCall\StrictArraySearchRector::class, + \Rector\CodingStyle\Rector\FuncCall\VersionCompareFuncCallToConstantRector::class, + \Rector\CodingStyle\Rector\FuncCall\FunctionFirstClassCallableRector::class, + \Rector\CodingStyle\Rector\If_\NullableCompareToNullRector::class, + \Rector\CodingStyle\Rector\Stmt\NewlineAfterStatementRector::class, + \Rector\CodingStyle\Rector\String_\UseClassKeywordForClassNameResolutionRector::class, + \Rector\DeadCode\Rector\Assign\RemoveDoubleAssignRector::class, + \Rector\DeadCode\Rector\Assign\RemoveUnusedVariableAssignRector::class, + \Rector\DeadCode\Rector\Cast\RecastingRemovalRector::class, + \Rector\DeadCode\Rector\ClassMethod\RemoveEmptyClassMethodRector::class, + \Rector\DeadCode\Rector\ClassMethod\RemoveNullTagValueNodeRector::class, + \Rector\DeadCode\Rector\ClassMethod\RemoveUnusedConstructorParamRector::class, + \Rector\DeadCode\Rector\ClassMethod\RemoveUnusedPrivateMethodRector::class, + \Rector\DeadCode\Rector\ClassMethod\RemoveUselessParamTagRector::class, + \Rector\DeadCode\Rector\ClassMethod\RemoveUselessReturnTagRector::class, + \Rector\DeadCode\Rector\ConstFetch\RemovePhpVersionIdCheckRector::class, + \Rector\DeadCode\Rector\Expression\RemoveDeadStmtRector::class, + \Rector\DeadCode\Rector\For_\RemoveDeadIfForeachForRector::class, + \Rector\DeadCode\Rector\For_\RemoveDeadLoopRector::class, + \Rector\DeadCode\Rector\If_\RemoveAlwaysTrueIfConditionRector::class, + \Rector\DeadCode\Rector\If_\RemoveDeadInstanceOfRector::class, + \Rector\DeadCode\Rector\If_\UnwrapFutureCompatibleIfPhpVersionRector::class, + \Rector\DeadCode\Rector\MethodCall\RemoveNullArgOnNullDefaultParamRector::class => [ + __DIR__ . '/tests/TestCase/Database/Expression/QueryExpressionTest.php', + ], + \Rector\DeadCode\Rector\Node\RemoveNonExistingVarAnnotationRector::class, + \Rector\DeadCode\Rector\Property\RemoveUselessVarTagRector::class, + \Rector\EarlyReturn\Rector\If_\ChangeOrIfContinueToMultiContinueRector::class, + \Rector\EarlyReturn\Rector\Return_\ReturnBinaryOrToEarlyReturnRector::class, + \Rector\Php56\Rector\FuncCall\PowToExpRector::class, + \Rector\Php73\Rector\FuncCall\ArrayKeyFirstLastRector::class, + \Rector\Php73\Rector\FuncCall\SetCookieRector::class, + \Rector\Php73\Rector\FuncCall\StringifyStrNeedlesRector::class, + \Rector\Php73\Rector\String_\SensitiveHereNowDocRector::class, + \Rector\Php74\Rector\Closure\ClosureToArrowFunctionRector::class, + \Rector\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromotionRector::class, + \Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector::class, + \Rector\Php81\Rector\Property\ReadOnlyPropertyRector::class, + \Rector\Strict\Rector\Empty_\DisallowedEmptyRuleFixerRector::class, + \Rector\TypeDeclaration\Rector\ArrowFunction\AddArrowFunctionReturnTypeRector::class, + \Rector\TypeDeclaration\Rector\BooleanAnd\BinaryOpNullableToInstanceofRector::class, + \Rector\CodingStyle\Rector\ClassLike\NewlineBetweenClassLikeStmtsRector::class, + \Rector\TypeDeclaration\Rector\ClassMethod\AddParamStringTypeFromSprintfUseRector::class, + \Rector\TypeDeclaration\Rector\ClassMethod\AddParamTypeBasedOnPHPUnitDataProviderRector::class, + \Rector\TypeDeclaration\Rector\ClassMethod\AddVoidReturnTypeWhereNoReturnRector::class, + \Rector\TypeDeclaration\Rector\ClassMethod\BoolReturnTypeFromBooleanConstReturnsRector::class, + \Rector\TypeDeclaration\Rector\ClassMethod\ParamTypeByMethodCallTypeRector::class, + \Rector\TypeDeclaration\Rector\ClassMethod\ReturnNeverTypeRector::class, + \Rector\TypeDeclaration\Rector\ClassMethod\ReturnTypeFromMockObjectRector::class, + \Rector\TypeDeclaration\Rector\ClassMethod\ReturnTypeFromStrictFluentReturnRector::class, + \Rector\TypeDeclaration\Rector\ClassMethod\ReturnTypeFromStrictTypedCallRector::class, + \Rector\TypeDeclaration\Rector\ClassMethod\ReturnUnionTypeRector::class, + \Rector\TypeDeclaration\Rector\ClassMethod\StrictArrayParamDimFetchRector::class, + \Rector\TypeDeclaration\Rector\ClassMethod\StrictStringParamConcatRector::class, + \Rector\TypeDeclaration\Rector\Class_\TypedPropertyFromCreateMockAssignRector::class, + \Rector\TypeDeclaration\Rector\Closure\AddClosureNeverReturnTypeRector::class, + \Rector\TypeDeclaration\Rector\Closure\ClosureReturnTypeRector::class, + \Rector\TypeDeclaration\Rector\Property\TypedPropertyFromAssignsRector::class, + \Rector\TypeDeclaration\Rector\Property\TypedPropertyFromStrictConstructorRector::class, + \Rector\TypeDeclaration\Rector\Property\TypedPropertyFromStrictSetUpRector::class, + \Rector\TypeDeclaration\Rector\While_\WhileNullableToInstanceofRector::class, + + // Manual - only appliable for part of the code + \Rector\CodeQuality\Rector\Equal\UseIdenticalOverEqualWithSameTypeRector::class, + \Rector\DeadCode\Rector\Expression\RemoveDeadStmtRector::class, + \Rector\Php55\Rector\String_\StringClassNameToClassConstantRector::class, + \Rector\Php73\Rector\FuncCall\ArrayKeyFirstLastRector::class, + \Rector\Php80\Rector\FuncCall\ClassOnObjectRector::class, + \Rector\CodeQuality\Rector\Ternary\SwitchNegatedTernaryRector::class, + + // Newly aggressive in rector 2.4 - keep the bump behavior-neutral: + // adds declare(strict_types=1) to test fixtures/config (out of scope here), + \Rector\TypeDeclaration\Rector\StmtsAwareInterface\SafeDeclareStrictTypesRector::class, + // and rewrites `$x ?: []` in ways that can change behavior on undefined/empty values. + \Rector\DeadCode\Rector\Ternary\RemoveUselessTernaryRector::class, + + // New in rector 2.5 - skipped to keep the version bump behavior-neutral. + // Together these touch ~226 files, mostly docblock removal. Whether to apply + // them is a separate decision from getting CI green again. + \Rector\CodeQuality\Rector\BooleanNot\NegatedAndsToPositiveOrsRector::class, + \Rector\CodeQuality\Rector\Property\FixClassCaseSensitivityVarDocblockRector::class, + \Rector\DeadCode\Rector\ClassMethod\RemoveDuplicatedReturnSelfDocblockRector::class, + \Rector\DeadCode\Rector\ClassMethod\RemoveMixedDocblockOverruledByNativeTypeRector::class, + \Rector\DeadCode\Rector\ClassMethod\RemoveParentDelegatingClassMethodRector::class, + \Rector\DeadCode\Rector\ClassMethod\RemoveReturnTagIncompatibleWithNativeTypeRector::class, + \Rector\DeadCode\Rector\ClassMethod\RemoveUselessUnionReturnDocblockRector::class, + \Rector\DeadCode\Rector\Property\RemoveDefaultValueFromAssignedPropertyRector::class, + \Rector\DeadCode\Rector\StmtsAwareInterface\RemoveDeadInstanceOfAssertRector::class, + \Rector\Php80\Rector\NotIdentical\MbStrContainsRector::class, + \Rector\TypeDeclaration\Rector\ClassMethod\ArrayParamTypeByMethodCallTypeRector::class, + \Rector\TypeDeclaration\Rector\ClassMethod\ScalarParamTypeByMethodCallTypeRector::class, + \Rector\TypeDeclaration\Rector\Closure\ClosureReturnTypeFromAssertInstanceOfRector::class, + \Rector\TypeDeclaration\Rector\FunctionLike\AddClosureParamTypeForArrayMapRector::class, + \Rector\TypeDeclaration\Rector\FunctionLike\AddClosureParamTypeFromVariableCallRector::class, + ]); diff --git a/src/Auth/AbstractPasswordHasher.php b/src/Auth/AbstractPasswordHasher.php deleted file mode 100644 index 3aef37caf42..00000000000 --- a/src/Auth/AbstractPasswordHasher.php +++ /dev/null @@ -1,79 +0,0 @@ -setConfig($config); - } - - /** - * Generates password hash. - * - * @param string|array $password Plain text password to hash or array of data - * required to generate password hash. - * @return string Password hash - */ - abstract public function hash($password); - - /** - * Check hash. Generate hash from user provided password string or data array - * and check against existing hash. - * - * @param string|array $password Plain text password to hash or data array. - * @param string $hashedPassword Existing hashed password. - * @return bool True if hashes match else false. - */ - abstract public function check($password, $hashedPassword); - - /** - * Returns true if the password need to be rehashed, due to the password being - * created with anything else than the passwords generated by this class. - * - * Returns true by default since the only implementation users should rely - * on is the one provided by default in php 5.5+ or any compatible library - * - * @param string $password The password to verify - * @return bool - */ - public function needsRehash($password) - { - return password_needs_rehash($password, PASSWORD_DEFAULT); - } -} diff --git a/src/Auth/BaseAuthenticate.php b/src/Auth/BaseAuthenticate.php deleted file mode 100644 index e93072eb9ee..00000000000 --- a/src/Auth/BaseAuthenticate.php +++ /dev/null @@ -1,260 +0,0 @@ - ['some_finder_option' => 'some_value']] - * - `passwordHasher` Password hasher class. Can be a string specifying class name - * or an array containing `className` key, any other keys will be passed as - * config to the class. Defaults to 'Default'. - * - Options `scope` and `contain` have been deprecated since 3.1. Use custom - * finder instead to modify the query to fetch user record. - * - * @var array - */ - protected $_defaultConfig = [ - 'fields' => [ - 'username' => 'username', - 'password' => 'password' - ], - 'userModel' => 'Users', - 'scope' => [], - 'finder' => 'all', - 'contain' => null, - 'passwordHasher' => 'Default' - ]; - - /** - * A Component registry, used to get more components. - * - * @var \Cake\Controller\ComponentRegistry - */ - protected $_registry; - - /** - * Password hasher instance. - * - * @var \Cake\Auth\AbstractPasswordHasher - */ - protected $_passwordHasher; - - /** - * Whether or not the user authenticated by this class - * requires their password to be rehashed with another algorithm. - * - * @var bool - */ - protected $_needsPasswordRehash = false; - - /** - * Constructor - * - * @param \Cake\Controller\ComponentRegistry $registry The Component registry used on this request. - * @param array $config Array of config to use. - */ - public function __construct(ComponentRegistry $registry, array $config = []) - { - $this->_registry = $registry; - $this->setConfig($config); - } - - /** - * Find a user record using the username and password provided. - * - * Input passwords will be hashed even when a user doesn't exist. This - * helps mitigate timing attacks that are attempting to find valid usernames. - * - * @param string $username The username/identifier. - * @param string|null $password The password, if not provided password checking is skipped - * and result of find is returned. - * @return bool|array Either false on failure, or an array of user data. - */ - protected function _findUser($username, $password = null) - { - $result = $this->_query($username)->first(); - - if (empty($result)) { - $hasher = $this->passwordHasher(); - $hasher->hash((string)$password); - - return false; - } - - $passwordField = $this->_config['fields']['password']; - if ($password !== null) { - $hasher = $this->passwordHasher(); - $hashedPassword = $result->get($passwordField); - if (!$hasher->check($password, $hashedPassword)) { - return false; - } - - $this->_needsPasswordRehash = $hasher->needsRehash($hashedPassword); - $result->unsetProperty($passwordField); - } - $hidden = $result->getHidden(); - if ($password === null && in_array($passwordField, $hidden)) { - $key = array_search($passwordField, $hidden); - unset($hidden[$key]); - $result->setHidden($hidden); - } - - return $result->toArray(); - } - - /** - * Get query object for fetching user from database. - * - * @param string $username The username/identifier. - * @return \Cake\ORM\Query - */ - protected function _query($username) - { - $config = $this->_config; - $table = TableRegistry::get($config['userModel']); - - $options = [ - 'conditions' => [$table->aliasField($config['fields']['username']) => $username] - ]; - - if (!empty($config['scope'])) { - $options['conditions'] = array_merge($options['conditions'], $config['scope']); - } - if (!empty($config['contain'])) { - $options['contain'] = $config['contain']; - } - - $finder = $config['finder']; - if (is_array($finder)) { - $options += current($finder); - $finder = key($finder); - } - - if (!isset($options['username'])) { - $options['username'] = $username; - } - - return $table->find($finder, $options); - } - - /** - * Return password hasher object - * - * @return \Cake\Auth\AbstractPasswordHasher Password hasher instance - * @throws \RuntimeException If password hasher class not found or - * it does not extend AbstractPasswordHasher - */ - public function passwordHasher() - { - if ($this->_passwordHasher) { - return $this->_passwordHasher; - } - - $passwordHasher = $this->_config['passwordHasher']; - - return $this->_passwordHasher = PasswordHasherFactory::build($passwordHasher); - } - - /** - * Returns whether or not the password stored in the repository for the logged in user - * requires to be rehashed with another algorithm - * - * @return bool - */ - public function needsPasswordRehash() - { - return $this->_needsPasswordRehash; - } - - /** - * Authenticate a user based on the request information. - * - * @param \Cake\Http\ServerRequest $request Request to get authentication information from. - * @param \Cake\Http\Response $response A response object that can have headers added. - * @return mixed Either false on failure, or an array of user data on success. - */ - abstract public function authenticate(ServerRequest $request, Response $response); - - /** - * Get a user based on information in the request. Primarily used by stateless authentication - * systems like basic and digest auth. - * - * @param \Cake\Http\ServerRequest $request Request object. - * @return mixed Either false or an array of user information - */ - public function getUser(ServerRequest $request) - { - return false; - } - - /** - * Handle unauthenticated access attempt. In implementation valid return values - * can be: - * - * - Null - No action taken, AuthComponent should return appropriate response. - * - Cake\Http\Response - A response object, which will cause AuthComponent to - * simply return that response. - * - * @param \Cake\Http\ServerRequest $request A request object. - * @param \Cake\Http\Response $response A response object. - * @return void - */ - public function unauthenticated(ServerRequest $request, Response $response) - { - } - - /** - * Returns a list of all events that this authenticate class will listen to. - * - * An authenticate class can listen to following events fired by AuthComponent: - * - * - `Auth.afterIdentify` - Fired after a user has been identified using one of - * configured authenticate class. The callback function should have signature - * like `afterIdentify(Event $event, array $user)` when `$user` is the - * identified user record. - * - * - `Auth.logout` - Fired when AuthComponent::logout() is called. The callback - * function should have signature like `logout(Event $event, array $user)` - * where `$user` is the user about to be logged out. - * - * @return array List of events this class listens to. Defaults to `[]`. - */ - public function implementedEvents() - { - return []; - } -} diff --git a/src/Auth/BaseAuthorize.php b/src/Auth/BaseAuthorize.php deleted file mode 100644 index d03092479f0..00000000000 --- a/src/Auth/BaseAuthorize.php +++ /dev/null @@ -1,66 +0,0 @@ -_registry = $registry; - $this->setConfig($config); - } - - /** - * Checks user authorization. - * - * @param array|\ArrayAccess $user Active user data - * @param \Cake\Http\ServerRequest $request Request instance. - * @return bool - */ - abstract public function authorize($user, ServerRequest $request); -} diff --git a/src/Auth/BasicAuthenticate.php b/src/Auth/BasicAuthenticate.php deleted file mode 100644 index 8b00038f316..00000000000 --- a/src/Auth/BasicAuthenticate.php +++ /dev/null @@ -1,113 +0,0 @@ - [ - * 'authenticate' => ['Basic'] - * ] - * ]; - * ``` - * - * You should also set `AuthComponent::$sessionKey = false;` in your AppController's - * beforeFilter() to prevent CakePHP from sending a session cookie to the client. - * - * Since HTTP Basic Authentication is stateless you don't need a login() action - * in your controller. The user credentials will be checked on each request. If - * valid credentials are not provided, required authentication headers will be sent - * by this authentication provider which triggers the login dialog in the browser/client. - * - * You may also want to use `$this->Auth->unauthorizedRedirect = false;`. - * By default, unauthorized users are redirected to the referrer URL, - * `AuthComponent::$loginAction`, or '/'. If unauthorizedRedirect is set to - * false, a ForbiddenException exception is thrown instead of redirecting. - */ -class BasicAuthenticate extends BaseAuthenticate -{ - - /** - * Authenticate a user using HTTP auth. Will use the configured User model and attempt a - * login using HTTP auth. - * - * @param \Cake\Http\ServerRequest $request The request to authenticate with. - * @param \Cake\Http\Response $response The response to add headers to. - * @return mixed Either false on failure, or an array of user data on success. - */ - public function authenticate(ServerRequest $request, Response $response) - { - return $this->getUser($request); - } - - /** - * Get a user based on information in the request. Used by cookie-less auth for stateless clients. - * - * @param \Cake\Http\ServerRequest $request Request object. - * @return mixed Either false or an array of user information - */ - public function getUser(ServerRequest $request) - { - $username = $request->getEnv('PHP_AUTH_USER'); - $pass = $request->getEnv('PHP_AUTH_PW'); - - if (!is_string($username) || $username === '' || !is_string($pass) || $pass === '') { - return false; - } - - return $this->_findUser($username, $pass); - } - - /** - * Handles an unauthenticated access attempt by sending appropriate login headers - * - * @param \Cake\Http\ServerRequest $request A request object. - * @param \Cake\Http\Response $response A response object. - * @return void - * @throws \Cake\Network\Exception\UnauthorizedException - */ - public function unauthenticated(ServerRequest $request, Response $response) - { - $Exception = new UnauthorizedException(); - $Exception->responseHeader([$this->loginHeaders($request)]); - throw $Exception; - } - - /** - * Generate the login headers - * - * @param \Cake\Http\ServerRequest $request Request object. - * @return string Headers for logging in. - */ - public function loginHeaders(ServerRequest $request) - { - $realm = $this->getConfig('realm') ?: $request->getEnv('SERVER_NAME'); - - return sprintf('WWW-Authenticate: Basic realm="%s"', $realm); - } -} diff --git a/src/Auth/ControllerAuthorize.php b/src/Auth/ControllerAuthorize.php deleted file mode 100644 index fc69fab68b5..00000000000 --- a/src/Auth/ControllerAuthorize.php +++ /dev/null @@ -1,95 +0,0 @@ -request->getParam('admin')) { - * return $user['role'] === 'admin'; - * } - * return !empty($user); - * } - * ``` - * - * The above is simple implementation that would only authorize users of the - * 'admin' role to access admin routing. - * - * @see \Cake\Controller\Component\AuthComponent::$authenticate - */ -class ControllerAuthorize extends BaseAuthorize -{ - - /** - * Controller for the request. - * - * @var \Cake\Controller\Controller - */ - protected $_Controller; - - /** - * {@inheritDoc} - */ - public function __construct(ComponentRegistry $registry, array $config = []) - { - parent::__construct($registry, $config); - $this->controller($registry->getController()); - } - - /** - * Get/set the controller this authorize object will be working with. Also - * checks that isAuthorized is implemented. - * - * @param \Cake\Controller\Controller|null $controller null to get, a controller to set. - * @return \Cake\Controller\Controller - * @throws \Cake\Core\Exception\Exception If controller does not have method `isAuthorized()`. - */ - public function controller(Controller $controller = null) - { - if ($controller) { - if (!method_exists($controller, 'isAuthorized')) { - throw new Exception(sprintf( - '%s does not implement an isAuthorized() method.', - get_class($controller) - )); - } - $this->_Controller = $controller; - } - - return $this->_Controller; - } - - /** - * Checks user authorization using a controller callback. - * - * @param array|\ArrayAccess $user Active user data - * @param \Cake\Http\ServerRequest $request Request instance. - * @return bool - */ - public function authorize($user, ServerRequest $request) - { - return (bool)$this->_Controller->isAuthorized($user); - } -} diff --git a/src/Auth/DefaultPasswordHasher.php b/src/Auth/DefaultPasswordHasher.php deleted file mode 100644 index dc9420cdd29..00000000000 --- a/src/Auth/DefaultPasswordHasher.php +++ /dev/null @@ -1,79 +0,0 @@ - PASSWORD_DEFAULT, - 'hashOptions' => [] - ]; - - /** - * Generates password hash. - * - * @param string $password Plain text password to hash. - * @return bool|string Password hash or false on failure - * @link https://book.cakephp.org/3.0/en/controllers/components/authentication.html#hashing-passwords - */ - public function hash($password) - { - return password_hash( - $password, - $this->_config['hashType'], - $this->_config['hashOptions'] - ); - } - - /** - * Check hash. Generate hash for user provided password and check against existing hash. - * - * @param string $password Plain text password to hash. - * @param string $hashedPassword Existing hashed password. - * @return bool True if hashes match else false. - */ - public function check($password, $hashedPassword) - { - return password_verify($password, $hashedPassword); - } - - /** - * Returns true if the password need to be rehashed, due to the password being - * created with anything else than the passwords generated by this class. - * - * @param string $password The password to verify - * @return bool - */ - public function needsRehash($password) - { - return password_needs_rehash($password, $this->_config['hashType'], $this->_config['hashOptions']); - } -} diff --git a/src/Auth/DigestAuthenticate.php b/src/Auth/DigestAuthenticate.php deleted file mode 100644 index e0f06e238c5..00000000000 --- a/src/Auth/DigestAuthenticate.php +++ /dev/null @@ -1,286 +0,0 @@ - [ - * 'authenticate' => ['Digest'] - * ] - * ]; - * ``` - * - * You should also set `AuthComponent::$sessionKey = false;` in your AppController's - * beforeFilter() to prevent CakePHP from sending a session cookie to the client. - * - * Since HTTP Digest Authentication is stateless you don't need a login() action - * in your controller. The user credentials will be checked on each request. If - * valid credentials are not provided, required authentication headers will be sent - * by this authentication provider which triggers the login dialog in the browser/client. - * - * You may also want to use `$this->Auth->unauthorizedRedirect = false;`. - * This causes AuthComponent to throw a ForbiddenException exception instead of - * redirecting to another page. - * - * ### Generating passwords compatible with Digest authentication. - * - * DigestAuthenticate requires a special password hash that conforms to RFC2617. - * You can generate this password using `DigestAuthenticate::password()` - * - * ``` - * $digestPass = DigestAuthenticate::password($username, $password, env('SERVER_NAME')); - * ``` - * - * If you wish to use digest authentication alongside other authentication methods, - * it's recommended that you store the digest authentication separately. For - * example `User.digest_pass` could be used for a digest password, while - * `User.password` would store the password hash for use with other methods like - * Basic or Form. - */ -class DigestAuthenticate extends BasicAuthenticate -{ - - /** - * Constructor - * - * Besides the keys specified in BaseAuthenticate::$_defaultConfig, - * DigestAuthenticate uses the following extra keys: - * - * - `secret` The secret to use for nonce validation. Defaults to Security::getSalt(). - * - `realm` The realm authentication is for, Defaults to the servername. - * - `qop` Defaults to 'auth', no other values are supported at this time. - * - `opaque` A string that must be returned unchanged by clients. - * Defaults to `md5($config['realm'])` - * - `nonceLifetime` The number of seconds that nonces are valid for. Defaults to 300. - * - * @param \Cake\Controller\ComponentRegistry $registry The Component registry - * used on this request. - * @param array $config Array of config to use. - */ - public function __construct(ComponentRegistry $registry, array $config = []) - { - $this->setConfig([ - 'nonceLifetime' => 300, - 'secret' => Security::getSalt(), - 'realm' => null, - 'qop' => 'auth', - 'opaque' => null, - ]); - - parent::__construct($registry, $config); - } - - /** - * Get a user based on information in the request. Used by cookie-less auth for stateless clients. - * - * @param \Cake\Http\ServerRequest $request Request object. - * @return mixed Either false or an array of user information - */ - public function getUser(ServerRequest $request) - { - $digest = $this->_getDigest($request); - if (empty($digest)) { - return false; - } - - $user = $this->_findUser($digest['username']); - if (empty($user)) { - return false; - } - - if (!$this->validNonce($digest['nonce'])) { - return false; - } - - $field = $this->_config['fields']['password']; - $password = $user[$field]; - unset($user[$field]); - - $hash = $this->generateResponseHash($digest, $password, $request->getEnv('ORIGINAL_REQUEST_METHOD')); - if (hash_equals($hash, $digest['response'])) { - return $user; - } - - return false; - } - - /** - * Gets the digest headers from the request/environment. - * - * @param \Cake\Http\ServerRequest $request Request object. - * @return array|bool Array of digest information. - */ - protected function _getDigest(ServerRequest $request) - { - $digest = $request->getEnv('PHP_AUTH_DIGEST'); - if (empty($digest) && function_exists('apache_request_headers')) { - $headers = apache_request_headers(); - if (!empty($headers['Authorization']) && substr($headers['Authorization'], 0, 7) === 'Digest ') { - $digest = substr($headers['Authorization'], 7); - } - } - if (empty($digest)) { - return false; - } - - return $this->parseAuthData($digest); - } - - /** - * Parse the digest authentication headers and split them up. - * - * @param string $digest The raw digest authentication headers. - * @return array|null An array of digest authentication headers - */ - public function parseAuthData($digest) - { - if (substr($digest, 0, 7) === 'Digest ') { - $digest = substr($digest, 7); - } - $keys = $match = []; - $req = ['nonce' => 1, 'nc' => 1, 'cnonce' => 1, 'qop' => 1, 'username' => 1, 'uri' => 1, 'response' => 1]; - preg_match_all('/(\w+)=([\'"]?)([a-zA-Z0-9\:\#\%\?\&@=\.\/_-]+)\2/', $digest, $match, PREG_SET_ORDER); - - foreach ($match as $i) { - $keys[$i[1]] = $i[3]; - unset($req[$i[1]]); - } - - if (empty($req)) { - return $keys; - } - - return null; - } - - /** - * Generate the response hash for a given digest array. - * - * @param array $digest Digest information containing data from DigestAuthenticate::parseAuthData(). - * @param string $password The digest hash password generated with DigestAuthenticate::password() - * @param string $method Request method - * @return string Response hash - */ - public function generateResponseHash($digest, $password, $method) - { - return md5( - $password . - ':' . $digest['nonce'] . ':' . $digest['nc'] . ':' . $digest['cnonce'] . ':' . $digest['qop'] . ':' . - md5($method . ':' . $digest['uri']) - ); - } - - /** - * Creates an auth digest password hash to store - * - * @param string $username The username to use in the digest hash. - * @param string $password The unhashed password to make a digest hash for. - * @param string $realm The realm the password is for. - * @return string the hashed password that can later be used with Digest authentication. - */ - public static function password($username, $password, $realm) - { - return md5($username . ':' . $realm . ':' . $password); - } - - /** - * Generate the login headers - * - * @param \Cake\Http\ServerRequest $request Request object. - * @return string Headers for logging in. - */ - public function loginHeaders(ServerRequest $request) - { - $realm = $this->_config['realm'] ?: $request->getEnv('SERVER_NAME'); - - $options = [ - 'realm' => $realm, - 'qop' => $this->_config['qop'], - 'nonce' => $this->generateNonce(), - 'opaque' => $this->_config['opaque'] ?: md5($realm) - ]; - - $digest = $this->_getDigest($request); - if ($digest && isset($digest['nonce']) && !$this->validNonce($digest['nonce'])) { - $options['stale'] = true; - } - - $opts = []; - foreach ($options as $k => $v) { - if (is_bool($v)) { - $v = $v ? 'true' : 'false'; - $opts[] = sprintf('%s=%s', $k, $v); - } else { - $opts[] = sprintf('%s="%s"', $k, $v); - } - } - - return 'WWW-Authenticate: Digest ' . implode(',', $opts); - } - - /** - * Generate a nonce value that is validated in future requests. - * - * @return string - */ - protected function generateNonce() - { - $expiryTime = microtime(true) + $this->getConfig('nonceLifetime'); - $secret = $this->getConfig('secret'); - $signatureValue = hash_hmac('sha256', $expiryTime . ':' . $secret, $secret); - $nonceValue = $expiryTime . ':' . $signatureValue; - - return base64_encode($nonceValue); - } - - /** - * Check the nonce to ensure it is valid and not expired. - * - * @param string $nonce The nonce value to check. - * @return bool - */ - protected function validNonce($nonce) - { - $value = base64_decode($nonce); - if ($value === false) { - return false; - } - $parts = explode(':', $value); - if (count($parts) !== 2) { - return false; - } - list($expires, $checksum) = $parts; - if ($expires < microtime(true)) { - return false; - } - $secret = $this->getConfig('secret'); - $check = hash_hmac('sha256', $expires . ':' . $secret, $secret); - - return hash_equals($check, $checksum); - } -} diff --git a/src/Auth/FallbackPasswordHasher.php b/src/Auth/FallbackPasswordHasher.php deleted file mode 100644 index 7f7e7cd1ba2..00000000000 --- a/src/Auth/FallbackPasswordHasher.php +++ /dev/null @@ -1,104 +0,0 @@ - [] - ]; - - /** - * Holds the list of password hasher objects that will be used - * - * @var array - */ - protected $_hashers = []; - - /** - * Constructor - * - * @param array $config configuration options for this object. Requires the - * `hashers` key to be present in the array with a list of other hashers to be - * used - */ - public function __construct(array $config = []) - { - parent::__construct($config); - foreach ($this->_config['hashers'] as $key => $hasher) { - if (is_array($hasher) && !isset($hasher['className'])) { - $hasher['className'] = $key; - } - $this->_hashers[] = PasswordHasherFactory::build($hasher); - } - } - - /** - * Generates password hash. - * - * Uses the first password hasher in the list to generate the hash - * - * @param string $password Plain text password to hash. - * @return string Password hash - */ - public function hash($password) - { - return $this->_hashers[0]->hash($password); - } - - /** - * Verifies that the provided password corresponds to its hashed version - * - * This will iterate over all configured hashers until one of them returns - * true. - * - * @param string $password Plain text password to hash. - * @param string $hashedPassword Existing hashed password. - * @return bool True if hashes match else false. - */ - public function check($password, $hashedPassword) - { - foreach ($this->_hashers as $hasher) { - if ($hasher->check($password, $hashedPassword)) { - return true; - } - } - - return false; - } - - /** - * Returns true if the password need to be rehashed, with the first hasher present - * in the list of hashers - * - * @param string $password The password to verify - * @return bool - */ - public function needsRehash($password) - { - return $this->_hashers[0]->needsRehash($password); - } -} diff --git a/src/Auth/FormAuthenticate.php b/src/Auth/FormAuthenticate.php deleted file mode 100644 index d2311c9a0dc..00000000000 --- a/src/Auth/FormAuthenticate.php +++ /dev/null @@ -1,81 +0,0 @@ -Auth->authenticate = [ - * 'Form' => [ - * 'finder' => ['auth' => ['some_finder_option' => 'some_value']] - * ] - * ] - * ``` - * - * When configuring FormAuthenticate you can pass in config to which fields, model and additional conditions - * are used. See FormAuthenticate::$_config for more information. - * - * @see \Cake\Controller\Component\AuthComponent::$authenticate - */ -class FormAuthenticate extends BaseAuthenticate -{ - - /** - * Checks the fields to ensure they are supplied. - * - * @param \Cake\Http\ServerRequest $request The request that contains login information. - * @param array $fields The fields to be checked. - * @return bool False if the fields have not been supplied. True if they exist. - */ - protected function _checkFields(ServerRequest $request, array $fields) - { - foreach ([$fields['username'], $fields['password']] as $field) { - $value = $request->getData($field); - if (empty($value) || !is_string($value)) { - return false; - } - } - - return true; - } - - /** - * Authenticates the identity contained in a request. Will use the `config.userModel`, and `config.fields` - * to find POST data that is used to find a matching record in the `config.userModel`. Will return false if - * there is no post data, either username or password is missing, or if the scope conditions have not been met. - * - * @param \Cake\Http\ServerRequest $request The request that contains login information. - * @param \Cake\Http\Response $response Unused response object. - * @return mixed False on login failure. An array of User data on success. - */ - public function authenticate(ServerRequest $request, Response $response) - { - $fields = $this->_config['fields']; - if (!$this->_checkFields($request, $fields)) { - return false; - } - - return $this->_findUser( - $request->getData($fields['username']), - $request->getData($fields['password']) - ); - } -} diff --git a/src/Auth/PasswordHasherFactory.php b/src/Auth/PasswordHasherFactory.php deleted file mode 100644 index aa163d7250f..00000000000 --- a/src/Auth/PasswordHasherFactory.php +++ /dev/null @@ -1,58 +0,0 @@ -_user; - } - - /** - * {@inheritDoc} - */ - public function write($user) - { - $this->_user = $user; - } - - /** - * {@inheritDoc} - */ - public function delete() - { - $this->_user = null; - } - - /** - * {@inheritDoc} - */ - public function redirectUrl($url = null) - { - if ($url === null) { - return $this->_redirectUrl; - } - - if ($url === false) { - $this->_redirectUrl = null; - - return null; - } - - $this->_redirectUrl = $url; - } -} diff --git a/src/Auth/Storage/SessionStorage.php b/src/Auth/Storage/SessionStorage.php deleted file mode 100644 index 27b31748771..00000000000 --- a/src/Auth/Storage/SessionStorage.php +++ /dev/null @@ -1,140 +0,0 @@ - 'Auth.User', - 'redirect' => 'Auth.redirect' - ]; - - /** - * Constructor. - * - * @param \Cake\Http\ServerRequest $request Request instance. - * @param \Cake\Http\Response $response Response instance. - * @param array $config Configuration list. - */ - public function __construct(ServerRequest $request, Response $response, array $config = []) - { - $this->_session = $request->getSession(); - $this->setConfig($config); - } - - /** - * Read user record from session. - * - * @return array|null User record if available else null. - */ - public function read() - { - if ($this->_user !== null) { - return $this->_user ?: null; - } - - $this->_user = $this->_session->read($this->_config['key']) ?: false; - - return $this->_user ?: null; - } - - /** - * Write user record to session. - * - * The session id is also renewed to help mitigate issues with session replays. - * - * @param array|\ArrayAccess $user User record. - * @return void - */ - public function write($user) - { - $this->_user = $user; - - $this->_session->renew(); - $this->_session->write($this->_config['key'], $user); - } - - /** - * Delete user record from session. - * - * The session id is also renewed to help mitigate issues with session replays. - * - * @return void - */ - public function delete() - { - $this->_user = false; - - $this->_session->delete($this->_config['key']); - $this->_session->renew(); - } - - /** - * {@inheritDoc} - */ - public function redirectUrl($url = null) - { - if ($url === null) { - return $this->_session->read($this->_config['redirect']); - } - - if ($url === false) { - $this->_session->delete($this->_config['redirect']); - - return null; - } - - $this->_session->write($this->_config['redirect'], $url); - } -} diff --git a/src/Auth/Storage/StorageInterface.php b/src/Auth/Storage/StorageInterface.php deleted file mode 100644 index c5de4f35740..00000000000 --- a/src/Auth/Storage/StorageInterface.php +++ /dev/null @@ -1,53 +0,0 @@ - null - ]; - - /** - * {@inheritDoc} - */ - public function __construct(array $config = []) - { - if (Configure::read('debug')) { - Debugger::checkSecurityKeys(); - } - - parent::__construct($config); - } - - /** - * Generates password hash. - * - * @param string $password Plain text password to hash. - * @return string Password hash - */ - public function hash($password) - { - return Security::hash($password, $this->_config['hashType'], true); - } - - /** - * Check hash. Generate hash for user provided password and check against existing hash. - * - * @param string $password Plain text password to hash. - * @param string $hashedPassword Existing hashed password. - * @return bool True if hashes match else false. - */ - public function check($password, $hashedPassword) - { - return $hashedPassword === $this->hash($password); - } -} diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 2c0d62b6e8e..73daadd7146 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -1,4 +1,6 @@ 'Cake\Cache\Engine\ApcEngine', + * 'className' => Cake\Cache\Engine\ApcuEngine::class, * 'prefix' => 'my_app_' * ]); * ``` * - * This would configure an APC cache engine to the 'shared' alias. You could then read and write + * This would configure an APCu cache engine to the 'shared' alias. You could then read and write * to that cache alias by using it for the `$config` parameter in the various Cache methods. * * In general all Cache operations are supported by all cache engines. * However, Cache::increment() and Cache::decrement() are not supported by File caching. * - * There are 5 built-in caching engines: + * There are 7 built-in caching engines: * + * - `ApcuEngine` - Uses the APCu object cache, one of the fastest caching engines. + * - `ArrayEngine` - Uses only memory to store all data, not actually a persistent engine. + * Can be useful in test or CLI environment. * - `FileEngine` - Uses simple files to store content. Poor performance, but good for * storing large objects, or things that are not IO sensitive. Well suited to development * as it is an easy cache to inspect and manually flush. - * - `ApcEngine` - Uses the APC object cache, one of the fastest caching engines. - * - `MemcacheEngine` - Uses the PECL::Memcache extension and Memcached for storage. - * Fast reads/writes, and benefits from memcache being distributed. - * - `XcacheEngine` - Uses the Xcache extension, an alternative to APC. - * - `WincacheEngine` - Uses Windows Cache Extension for PHP. Supports wincache 1.1.0 and higher. - * This engine is recommended to people deploying on windows with IIS. + * - `MemcachedEngine` - Uses the PECL::Memcached extension and Memcached for storage. + * Fast reads/writes, and benefits from memcached being distributed. * - `RedisEngine` - Uses redis and php-redis extension to store cache data. * * See Cache engine documentation for expected configuration keys. @@ -63,58 +66,60 @@ */ class Cache { - use StaticConfigTrait; /** - * An array mapping url schemes to fully qualified caching engine + * An array mapping URL schemes to fully qualified caching engine * class names. * - * @var array + * @var array */ - protected static $_dsnClassMap = [ - 'apc' => 'Cake\Cache\Engine\ApcEngine', - 'file' => 'Cake\Cache\Engine\FileEngine', - 'memcached' => 'Cake\Cache\Engine\MemcachedEngine', - 'null' => 'Cake\Cache\Engine\NullEngine', - 'redis' => 'Cake\Cache\Engine\RedisEngine', - 'wincache' => 'Cake\Cache\Engine\WincacheEngine', - 'xcache' => 'Cake\Cache\Engine\XcacheEngine', + protected static array $_dsnClassMap = [ + 'array' => Engine\ArrayEngine::class, + 'apcu' => Engine\ApcuEngine::class, + 'file' => Engine\FileEngine::class, + 'memcached' => Engine\MemcachedEngine::class, + 'null' => Engine\NullEngine::class, + 'redis' => Engine\RedisEngine::class, ]; /** - * Flag for tracking whether or not caching is enabled. + * Flag for tracking whether caching is enabled. * * @var bool */ - protected static $_enabled = true; + protected static bool $_enabled = true; + + /** + * Names of the pools currently being constructed, used to detect reentrant calls. + * + * @see \Cake\Cache\Cache::pool() + * @var array + */ + protected static array $building = []; /** * Group to Config mapping * - * @var array + * @var array */ - protected static $_groups = []; + protected static array $_groups = []; /** * Cache Registry used for creating and using cache adapters. * - * @var \Cake\Core\ObjectRegistry + * @var \Cake\Cache\CacheRegistry<\Cake\Cache\CacheEngine> */ - protected static $_registry; + protected static CacheRegistry $_registry; /** * Returns the Cache Registry instance used for creating and using cache adapters. * - * @return \Cake\Core\ObjectRegistry + * @return \Cake\Cache\CacheRegistry<\Cake\Cache\CacheEngine> */ - public static function getRegistry() + public static function getRegistry(): CacheRegistry { - if (!static::$_registry) { - static::$_registry = new CacheRegistry(); - } - - return static::$_registry; + return static::$_registry ??= new CacheRegistry(); } /** @@ -122,45 +127,29 @@ public static function getRegistry() * * Also allows for injecting of a new registry instance. * - * @param \Cake\Core\ObjectRegistry $registry Injectable registry object. + * @param \Cake\Cache\CacheRegistry<\Cake\Cache\CacheEngine> $registry Injectable registry object. * @return void */ - public static function setRegistry(ObjectRegistry $registry) + public static function setRegistry(CacheRegistry $registry): void { static::$_registry = $registry; } - /** - * Returns the Cache Registry instance used for creating and using cache adapters. - * Also allows for injecting of a new registry instance. - * - * @param \Cake\Core\ObjectRegistry|null $registry Injectable registry object. - * @return \Cake\Core\ObjectRegistry - * @deprecated Deprecated since 3.5. Use getRegistry() and setRegistry() instead. - */ - public static function registry(ObjectRegistry $registry = null) - { - if ($registry) { - static::setRegistry($registry); - } - - return static::getRegistry(); - } - /** * Finds and builds the instance of the required engine class. * * @param string $name Name of the config array that needs an engine instance built + * @throws \Cake\Cache\Exception\InvalidArgumentException When a cache engine cannot be created. + * @throws \RuntimeException If loading of the engine failed. * @return void - * @throws \InvalidArgumentException When a cache engine cannot be created. */ - protected static function _buildEngine($name) + protected static function _buildEngine(string $name): void { $registry = static::getRegistry(); if (empty(static::$_config[$name]['className'])) { throw new InvalidArgumentException( - sprintf('The "%s" cache configuration does not exist.', $name) + sprintf('The `%s` cache configuration does not exist.', $name), ); } @@ -176,13 +165,20 @@ protected static function _buildEngine($name) return; } + if ($config['fallback'] === false) { + throw $e; + } + if ($config['fallback'] === $name) { - throw new InvalidArgumentException( - sprintf('"%s" cache configuration cannot fallback to itself.', $name) - ); + throw new InvalidArgumentException(sprintf( + '`%s` cache configuration cannot fallback to itself.', + $name, + ), 0, $e); } - $fallbackEngine = clone static::engine($config['fallback']); + $fallbackEngine = clone static::pool($config['fallback']); + assert($fallbackEngine instanceof CacheEngine); + $newConfig = $config + ['groups' => [], 'prefix' => null]; $fallbackEngine->setConfig('groups', $newConfig['groups'], false); if ($newConfig['prefix']) { @@ -196,6 +192,7 @@ protected static function _buildEngine($name) } if (!empty($config['groups'])) { + /** @var string $group */ foreach ($config['groups'] as $group) { static::$_groups[$group][] = $name; static::$_groups[$group] = array_unique(static::$_groups[$group]); @@ -205,15 +202,12 @@ protected static function _buildEngine($name) } /** - * Fetch the engine attached to a specific configuration name. + * Get a SimpleCacheEngine object for the named cache pool. * - * If the cache engine & configuration are missing an error will be - * triggered. - * - * @param string $config The configuration name you want an engine for. - * @return \Cake\Cache\CacheEngine When caching is disabled a null engine will be returned. + * @param string $config The name of the configured cache backend. + * @return \Psr\SimpleCache\CacheInterface&\Cake\Cache\CacheEngineInterface */ - public static function engine($config) + public static function pool(string $config): CacheInterface&CacheEngineInterface { if (!static::$_enabled) { return new NullEngine(); @@ -221,28 +215,27 @@ public static function engine($config) $registry = static::getRegistry(); - if (isset($registry->{$config})) { - return $registry->{$config}; + if ($registry->has($config)) { + return $registry->get($config); } - static::_buildEngine($config); + if (isset(static::$building[$config])) { + // Reentered while this pool is still being constructed. An engine that cannot reach + // its backend may log that failure, and the logger may in turn ask for a cache pool - + // for example one that stores database schema metadata. Nothing is registered yet at + // that point, so without this check the two would call each other until the process + // runs out of memory. Degrade to a null engine instead. + return new NullEngine(); + } - return $registry->{$config}; - } + static::$building[$config] = true; + try { + static::_buildEngine($config); + } finally { + unset(static::$building[$config]); + } - /** - * Garbage collection - * - * Permanently remove all expired and deleted data - * - * @param string $config [optional] The config name you wish to have garbage collected. Defaults to 'default' - * @param int|null $expires [optional] An expires timestamp. Defaults to NULL - * @return void - */ - public static function gc($config = 'default', $expires = null) - { - $engine = static::engine($config); - $engine->gc($expires); + return $registry->get($config); } /** @@ -267,24 +260,21 @@ public static function gc($config = 'default', $expires = null) * @param string $config Optional string configuration name to write to. Defaults to 'default' * @return bool True if the data was successfully cached, false on failure */ - public static function write($key, $value, $config = 'default') + public static function write(string $key, mixed $value, string $config = 'default'): bool { - $engine = static::engine($config); if (is_resource($value)) { return false; } - $success = $engine->write($key, $value); + $backend = static::pool($config); + $success = $backend->set($key, $value); if ($success === false && $value !== '') { - trigger_error( - sprintf( - "%s cache was unable to write '%s' to %s cache", - $config, - $key, - get_class($engine) - ), - E_USER_WARNING - ); + throw new CacheWriteException(sprintf( + "%s cache was unable to write '%s' to %s cache", + $config, + $key, + $backend::class, + )); } return $success; @@ -307,27 +297,14 @@ public static function write($key, $value, $config = 'default') * Cache::writeMany(['cached_data_1' => 'data 1', 'cached_data_2' => 'data 2'], 'long_term'); * ``` * - * @param array $data An array of data to be stored in the cache + * @param iterable $data An array or Traversable of data to be stored in the cache * @param string $config Optional string configuration name to write to. Defaults to 'default' - * @return array of bools for each key provided, indicating true for success or false for fail - * @throws \RuntimeException + * @return bool True on success, false on failure + * @throws \Cake\Cache\Exception\InvalidArgumentException */ - public static function writeMany($data, $config = 'default') + public static function writeMany(iterable $data, string $config = 'default'): bool { - $engine = static::engine($config); - $return = $engine->writeMany($data); - foreach ($return as $key => $success) { - if ($success === false && $data[$key] !== '') { - throw new RuntimeException(sprintf( - '%s cache was unable to write \'%s\' to %s cache', - $config, - $key, - get_class($engine) - )); - } - } - - return $return; + return static::pool($config)->setMultiple($data); } /** @@ -349,13 +326,12 @@ public static function writeMany($data, $config = 'default') * * @param string $key Identifier for the data * @param string $config optional name of the configuration to use. Defaults to 'default' - * @return mixed The cached data, or false if the data doesn't exist, has expired, or if there was an error fetching it + * @return mixed The cached data, or null if the data doesn't exist, has expired, + * or if there was an error fetching it. */ - public static function read($key, $config = 'default') + public static function read(string $key, string $config = 'default'): mixed { - $engine = static::engine($config); - - return $engine->read($key); + return static::pool($config)->get($key); } /** @@ -375,16 +351,15 @@ public static function read($key, $config = 'default') * Cache::readMany(['my_data_1', 'my_data_2], 'long_term'); * ``` * - * @param array $keys an array of keys to fetch from the cache + * @param iterable $keys An array or Traversable of keys to fetch from the cache * @param string $config optional name of the configuration to use. Defaults to 'default' - * @return array An array containing, for each of the given $keys, the cached data or false if cached data could not be - * retrieved. + * @return iterable An array containing, for each of the given $keys, + * the cached data or false if cached data could not be retrieved. + * @throws \Cake\Cache\Exception\InvalidArgumentException */ - public static function readMany($keys, $config = 'default') + public static function readMany(iterable $keys, string $config = 'default'): iterable { - $engine = static::engine($config); - - return $engine->readMany($keys); + return static::pool($config)->getMultiple($keys); } /** @@ -393,17 +368,17 @@ public static function readMany($keys, $config = 'default') * @param string $key Identifier for the data * @param int $offset How much to add * @param string $config Optional string configuration name. Defaults to 'default' - * @return mixed new value, or false if the data doesn't exist, is not integer, + * @return int|false New value, or false if the data doesn't exist, is not integer, * or if there was an error fetching it. + * @throws \Cake\Cache\Exception\InvalidArgumentException When offset < 0 */ - public static function increment($key, $offset = 1, $config = 'default') + public static function increment(string $key, int $offset = 1, string $config = 'default'): int|false { - $engine = static::engine($config); - if (!is_int($offset) || $offset < 0) { - return false; + if ($offset < 0) { + throw new InvalidArgumentException('Offset cannot be less than `0`.'); } - return $engine->increment($key, $offset); + return static::pool($config)->increment($key, $offset); } /** @@ -412,17 +387,17 @@ public static function increment($key, $offset = 1, $config = 'default') * @param string $key Identifier for the data * @param int $offset How much to subtract * @param string $config Optional string configuration name. Defaults to 'default' - * @return mixed new value, or false if the data doesn't exist, is not integer, + * @return int|false New value, or false if the data doesn't exist, is not integer, * or if there was an error fetching it + * @throws \Cake\Cache\Exception\InvalidArgumentException when offset < 0 */ - public static function decrement($key, $offset = 1, $config = 'default') + public static function decrement(string $key, int $offset = 1, string $config = 'default'): int|false { - $engine = static::engine($config); - if (!is_int($offset) || $offset < 0) { - return false; + if ($offset < 0) { + throw new InvalidArgumentException('Offset cannot be less than `0`.'); } - return $engine->decrement($key, $offset); + return static::pool($config)->decrement($key, $offset); } /** @@ -446,11 +421,9 @@ public static function decrement($key, $offset = 1, $config = 'default') * @param string $config name of the configuration to use. Defaults to 'default' * @return bool True if the value was successfully deleted, false if it didn't exist or couldn't be removed */ - public static function delete($key, $config = 'default') + public static function delete(string $key, string $config = 'default'): bool { - $engine = static::engine($config); - - return $engine->delete($key); + return static::pool($config)->delete($key); } /** @@ -470,44 +443,38 @@ public static function delete($key, $config = 'default') * Cache::deleteMany(['my_data_1', 'my_data_2], 'long_term'); * ``` * - * @param array $keys Array of cache keys to be deleted + * @param iterable $keys Array or Traversable of cache keys to be deleted * @param string $config name of the configuration to use. Defaults to 'default' - * @return array of boolean values that are true if the value was successfully deleted, false if it didn't exist or - * couldn't be removed + * @return bool True on success, false on failure. + * @throws \Cake\Cache\Exception\InvalidArgumentException */ - public static function deleteMany($keys, $config = 'default') + public static function deleteMany(iterable $keys, string $config = 'default'): bool { - $engine = static::engine($config); - - return $engine->deleteMany($keys); + return static::pool($config)->deleteMultiple($keys); } /** * Delete all keys from the cache. * - * @param bool $check if true will check expiration, otherwise delete all * @param string $config name of the configuration to use. Defaults to 'default' * @return bool True if the cache was successfully cleared, false otherwise */ - public static function clear($check = false, $config = 'default') + public static function clear(string $config = 'default'): bool { - $engine = static::engine($config); - - return $engine->clear($check); + return static::pool($config)->clear(); } /** * Delete all keys from the cache from all configurations. * - * @param bool $check if true will check expiration, otherwise delete all - * @return array Status code. For each configuration, it reports the status of the operation + * @return array Status code. For each configuration, it reports the status of the operation */ - public static function clearAll($check = false) + public static function clearAll(): array { $status = []; foreach (self::configured() as $config) { - $status[$config] = self::clear($check, $config); + $status[$config] = self::clear($config); } return $status; @@ -520,11 +487,9 @@ public static function clearAll($check = false) * @param string $config name of the configuration to use. Defaults to 'default' * @return bool True if the cache group was successfully cleared, false otherwise */ - public static function clearGroup($group, $config = 'default') + public static function clearGroup(string $group, string $config = 'default'): bool { - $engine = static::engine($config); - - return $engine->clearGroup($group); + return static::pool($config)->clearGroup($group); } /** @@ -539,14 +504,14 @@ public static function clearGroup($group, $config = 'default') * $configs will equal to `['posts' => ['daily', 'weekly']]` * Calling this method will load all the configured engines. * - * @param string|null $group group name or null to retrieve all group mappings - * @return array map of group and all configuration that has the same group - * @throws \InvalidArgumentException + * @param string|null $group Group name or null to retrieve all group mappings + * @return array Map of group and all configuration that has the same group + * @throws \Cake\Cache\Exception\InvalidArgumentException */ - public static function groupConfigs($group = null) + public static function groupConfigs(?string $group = null): array { - foreach (array_keys(static::$_config) as $config) { - static::engine($config); + foreach (static::configured() as $config) { + static::pool($config); } if ($group === null) { return static::$_groups; @@ -556,7 +521,7 @@ public static function groupConfigs($group = null) return [$group => self::$_groups[$group]]; } - throw new InvalidArgumentException(sprintf('Invalid cache group %s', $group)); + throw new InvalidArgumentException(sprintf('Invalid cache group `%s`.', $group)); } /** @@ -566,7 +531,7 @@ public static function groupConfigs($group = null) * * @return void */ - public static function enable() + public static function enable(): void { static::$_enabled = true; } @@ -578,17 +543,17 @@ public static function enable() * * @return void */ - public static function disable() + public static function disable(): void { static::$_enabled = false; } /** - * Check whether or not caching is enabled. + * Check whether caching is enabled. * * @return bool */ - public static function enabled() + public static function enabled(): bool { return static::$_enabled; } @@ -596,8 +561,8 @@ public static function enabled() /** * Provides the ability to easily do read-through caching. * - * When called if the $key is not set in $config, the $callable function - * will be invoked. The results will then be stored into the cache config + * If the key is not set, the default callback is run to get the default value. + * The results will then be stored into the cache config * at key. * * Examples: @@ -606,26 +571,25 @@ public static function enabled() * * ``` * $results = Cache::remember('all_articles', function () { - * return $this->find('all'); + * return $this->find('all')->toArray(); * }); * ``` * * @param string $key The cache key to read/store data at. - * @param callable $callable The callable that provides data in the case when - * the cache key is empty. Can be any callable type supported by your PHP. + * @param \Closure $default The callback that provides data in the case when + * the cache key is empty. * @param string $config The cache configuration to use for this operation. * Defaults to default. - * @return mixed If the key is found: the cached data, false if the data - * missing/expired, or an error. If the key is not found: boolean of the - * success of the write + * @return mixed If the key is found: the cached data. + * If the key is not found the value returned by the default callback. */ - public static function remember($key, $callable, $config = 'default') + public static function remember(string $key, Closure $default, string $config = 'default'): mixed { $existing = self::read($key, $config); - if ($existing !== false) { + if ($existing !== null) { return $existing; } - $results = call_user_func($callable); + $results = $default(); self::write($key, $results, $config); return $results; @@ -654,13 +618,12 @@ public static function remember($key, $callable, $config = 'default') * @return bool True if the data was successfully cached, false on failure. * Or if the key existed already. */ - public static function add($key, $value, $config = 'default') + public static function add(string $key, mixed $value, string $config = 'default'): bool { - $engine = static::engine($config); if (is_resource($value)) { return false; } - return $engine->add($key, $value); + return static::pool($config)->add($key, $value); } } diff --git a/src/Cache/CacheEngine.php b/src/Cache/CacheEngine.php index b0117ac0990..7f631286e95 100644 --- a/src/Cache/CacheEngine.php +++ b/src/Cache/CacheEngine.php @@ -1,4 +1,6 @@ */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ 'duration' => 3600, 'groups' => [], 'prefix' => 'cake_', - 'probability' => 100 + 'warnOnWriteFailures' => true, ]; /** - * Contains the compiled string with all groups + * Contains the compiled string with all group * prefixes to be prepended to every key in this cache engine * * @var string */ - protected $_groupPrefix; + protected string $_groupPrefix = ''; /** * Initialize the cache engine @@ -62,10 +80,10 @@ abstract class CacheEngine * Called automatically by the cache frontend. Merge the runtime config with the defaults * before use. * - * @param array $config Associative array of parameters for the engine + * @param array $config Associative array of parameters for the engine * @return bool True if the engine has been successfully initialized, false if not */ - public function init(array $config = []) + public function init(array $config = []): bool { $this->setConfig($config); @@ -81,84 +99,179 @@ public function init(array $config = []) } /** - * Garbage collection - * - * Permanently remove all expired and deleted data + * Ensure the validity of the given cache key. * - * @param int|null $expires [optional] An expires timestamp, invalidating all data before. + * @param mixed $key Key to check. * @return void + * @throws \Cake\Cache\Exception\InvalidArgumentException When the key is not valid. */ - public function gc($expires = null) + protected function ensureValidKey(mixed $key): void { + if (!is_string($key) || $key === '') { + throw new InvalidArgumentException('A cache key must be a non-empty string.'); + } } /** - * Write value for a key into cache + * Ensure the validity of the argument type and cache keys. * - * @param string $key Identifier for the data - * @param mixed $value Data to be cached - * @return bool True if the data was successfully cached, false on failure + * @param iterable $iterable The iterable to check. + * @param string $check Whether to check keys or values. + * @return void + * @throws \Cake\Cache\Exception\InvalidArgumentException */ - abstract public function write($key, $value); + protected function ensureValidType(iterable $iterable, string $check = self::CHECK_VALUE): void + { + foreach ($iterable as $key => $value) { + if ($check === self::CHECK_VALUE) { + $this->ensureValidKey($value); + } else { + $this->ensureValidKey($key); + } + } + } /** - * Write data for many keys into cache + * Obtains multiple cache items by their unique keys. * - * @param array $data An array of data to be stored in the cache - * @return array of bools for each key provided, true if the data was successfully cached, false on failure + * @param iterable $keys A list of keys that can obtained in a single operation. + * @param mixed $default Default value to return for keys that do not exist. + * @return iterable A list of key value pairs. Cache keys that do not exist or are stale will have $default as value. + * @throws \Cake\Cache\Exception\InvalidArgumentException If $keys is neither an array nor a Traversable, + * or if any of the $keys are not a legal value. */ - public function writeMany($data) + public function getMultiple(iterable $keys, mixed $default = null): iterable { - $return = []; - foreach ($data as $key => $value) { - $return[$key] = $this->write($key, $value); + $this->ensureValidType($keys); + + $results = []; + foreach ($keys as $key) { + $results[$key] = $this->get($key, $default); } - return $return; + return $results; } /** - * Read a key from the cache + * Persists a set of key => value pairs in the cache, with an optional TTL. * - * @param string $key Identifier for the data - * @return mixed The cached data, or false if the data doesn't exist, has expired, or if there was an error fetching it + * @param iterable $values A list of key => value pairs for a multiple-set operation. + * @param \DateInterval|int|null $ttl Optional. The TTL value of this item. If no value is sent and + * the driver supports TTL then the library may set a default value + * for it or let the driver take care of that. + * @return bool True on success and false on failure. + * @throws \Cake\Cache\Exception\InvalidArgumentException If $values is neither an array nor a Traversable, + * or if any of the $values are not a legal value. */ - abstract public function read($key); + public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool + { + $this->ensureValidType($values, self::CHECK_KEY); + + $restore = null; + if ($ttl !== null) { + $restore = $this->getConfig('duration'); + $this->setConfig('duration', $ttl); + } + try { + foreach ($values as $key => $value) { + $success = $this->set($key, $value); + if ($success === false) { + return false; + } + } + + return true; + } finally { + if ($restore !== null) { + $this->setConfig('duration', $restore); + } + } + } /** - * Read multiple keys from the cache + * Deletes multiple cache items as a list + * + * This is a best effort attempt. If deleting an item would + * create an error it will be ignored, and all items will + * be attempted. * - * @param array $keys An array of identifiers for the data - * @return array For each cache key (given as the array key) the cache data associated or false if the data doesn't - * exist, has expired, or if there was an error fetching it + * @param iterable $keys A list of string-based keys to be deleted. + * @return bool True if the items were successfully removed. False if there was an error. + * @throws \Cake\Cache\Exception\InvalidArgumentException If $keys is neither an array nor a Traversable, + * or if any of the $keys are not a legal value. */ - public function readMany($keys) + public function deleteMultiple(iterable $keys): bool { - $return = []; + $this->ensureValidType($keys); + + $result = true; foreach ($keys as $key) { - $return[$key] = $this->read($key); + if (!$this->delete($key)) { + $result = false; + } } - return $return; + return $result; + } + + /** + * Determines whether an item is present in the cache. + * + * NOTE: It is recommended that has() is only to be used for cache warming type purposes + * and not to be used within your live applications operations for get/set, as this method + * is subject to a race condition where your has() will return true and immediately after, + * another script can remove it making the state of your app out of date. + * + * @param string $key The cache item key. + * @return bool + * @throws \Cake\Cache\Exception\InvalidArgumentException If the $key string is not a legal value. + */ + public function has(string $key): bool + { + return $this->get($key) !== null; } + /** + * Fetches the value for a given key from the cache. + * + * @param string $key The unique key of this item in the cache. + * @param mixed $default Default value to return if the key does not exist. + * @return mixed The value of the item from the cache, or $default in case of cache miss. + * @throws \Cake\Cache\Exception\InvalidArgumentException If the $key string is not a legal value. + */ + abstract public function get(string $key, mixed $default = null): mixed; + + /** + * Persists data in the cache, uniquely referenced by the given key with an optional expiration TTL time. + * + * @param string $key The key of the item to store. + * @param mixed $value The value of the item to store, must be serializable. + * @param \DateInterval|int|null $ttl Optional. The TTL value of this item. If no value is sent and + * the driver supports TTL then the library may set a default value + * for it or let the driver take care of that. + * @return bool True on success and false on failure. + * @throws \Cake\Cache\Exception\InvalidArgumentException + * MUST be thrown if the $key string is not a legal value. + */ + abstract public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool; + /** * Increment a number under the key and return incremented value * * @param string $key Identifier for the data * @param int $offset How much to add - * @return bool|int New incremented value, false otherwise + * @return int|false New incremented value, false otherwise */ - abstract public function increment($key, $offset = 1); + abstract public function increment(string $key, int $offset = 1): int|false; /** * Decrement a number under the key and return decremented value * * @param string $key Identifier for the data * @param int $offset How much to subtract - * @return bool|int New incremented value, false otherwise + * @return int|false New decremented value, false otherwise */ - abstract public function decrement($key, $offset = 1); + abstract public function decrement(string $key, int $offset = 1): int|false; /** * Delete a key from the cache @@ -166,32 +279,14 @@ abstract public function decrement($key, $offset = 1); * @param string $key Identifier for the data * @return bool True if the value was successfully deleted, false if it didn't exist or couldn't be removed */ - abstract public function delete($key); + abstract public function delete(string $key): bool; /** * Delete all keys from the cache * - * @param bool $check if true will check expiration, otherwise delete all * @return bool True if the cache was successfully cleared, false otherwise */ - abstract public function clear($check); - - /** - * Deletes keys from the cache - * - * @param array $keys An array of identifiers for the data - * @return array For each provided cache key (given back as the array key) true if the value was successfully deleted, - * false if it didn't exist or couldn't be removed - */ - public function deleteMany($keys) - { - $return = []; - foreach ($keys as $key) { - $return[$key] = $this->delete($key); - } - - return $return; - } + abstract public function clear(): bool; /** * Add a key to the cache if it does not already exist. @@ -203,12 +298,32 @@ public function deleteMany($keys) * @param mixed $value Data to be cached. * @return bool True if the data was successfully cached, false on failure. */ - public function add($key, $value) + public function add(string $key, mixed $value): bool { - $cachedValue = $this->read($key); - if ($cachedValue === false) { - return $this->write($key, $value); + $cachedValue = $this->get($key); + $prefixedKey = $this->_key($key); + $duration = $this->getConfig('duration'); + + $this->_eventClass = CacheBeforeAddEvent::class; + $this->dispatchEvent(CacheBeforeAddEvent::NAME, [ + 'key' => $prefixedKey, + 'value' => $value, + 'ttl' => $duration, + ]); + + if ($cachedValue === null) { + $success = $this->set($key, $value); + $this->_eventClass = CacheAfterAddEvent::class; + $this->dispatchEvent(CacheAfterAddEvent::NAME, [ + 'key' => $prefixedKey, 'value' => $value, 'success' => $success, 'ttl' => $duration, + ]); + + return $success; } + $this->_eventClass = CacheAfterAddEvent::class; + $this->dispatchEvent(CacheAfterAddEvent::NAME, [ + 'key' => $prefixedKey, 'value' => $value, 'success' => false, 'ttl' => $duration, + ]); return false; } @@ -221,59 +336,80 @@ public function add($key, $value) * @param string $group name of the group to be cleared * @return bool */ - public function clearGroup($group) - { - return false; - } + abstract public function clearGroup(string $group): bool; /** * Does whatever initialization for each group is required * and returns the `group value` for each of them, this is * the token representing each group in the cache key * - * @return array + * @return array */ - public function groups() + public function groups(): array { return $this->_config['groups']; } /** - * Generates a safe key for use with cache engine storage engines. + * Generates a key for cache backend usage. + * + * If the requested key is valid, the group prefix value and engine prefix are applied. + * Whitespace in keys will be replaced. * * @param string $key the key passed over - * @return bool|string string key or false + * @return string Prefixed key with potentially unsafe characters replaced. + * @throws \Cake\Cache\Exception\InvalidArgumentException If key's value is invalid. */ - public function key($key) + protected function _key(string $key): string { - if (!$key) { - return false; - } + $this->ensureValidKey($key); $prefix = ''; if ($this->_groupPrefix) { - $prefix = md5(implode('_', $this->groups())); + $prefix = hash('xxh128', implode('_', $this->groups())); } + $key = preg_replace('/[\s]+/', '_', $key); + + return $this->_config['prefix'] . $prefix . $key; + } - $key = preg_replace('/[\s]+/', '_', strtolower(trim(str_replace([DIRECTORY_SEPARATOR, '/', '.'], '_', (string)$key)))); + /** + * Cache Engines may trigger warnings if they encounter failures during operation, + * if option warnOnWriteFailures is set to true. + * + * @param string $message The warning message. + * @return void + */ + protected function warning(string $message): void + { + if ($this->getConfig('warnOnWriteFailures') !== true) { + return; + } - return $prefix . $key; + triggerWarning($message); } /** - * Generates a safe key, taking account of the configured key prefix + * Convert the various expressions of a TTL value into duration in seconds * - * @param string $key the key passed over - * @return mixed string $key or false - * @throws \InvalidArgumentException If key's value is empty + * @param \DateInterval|int|null $ttl The TTL value of this item. If null is sent, the + * driver's default duration will be used. + * @return int */ - protected function _key($key) + protected function duration(DateInterval|int|null $ttl): int { - $key = $this->key($key); - if ($key === false) { - throw new InvalidArgumentException('An empty value is not valid as a cache key'); + if ($ttl === null) { + return $this->_config['duration']; } + if (is_int($ttl)) { + return $ttl; + } + + /** @var \DateTime $datetime */ + $datetime = DateTime::createFromFormat('U', '0'); - return $this->_config['prefix'] . $key; + return (int)$datetime + ->add($ttl) + ->format('U'); } } diff --git a/src/Cache/CacheEngineInterface.php b/src/Cache/CacheEngineInterface.php new file mode 100644 index 00000000000..9f6f2ce52fa --- /dev/null +++ b/src/Cache/CacheEngineInterface.php @@ -0,0 +1,69 @@ + */ class CacheRegistry extends ObjectRegistry { - /** * Resolve a cache engine classname. * * Part of the template method for Cake\Core\ObjectRegistry::load() * * @param string $class Partial classname to resolve. - * @return string|false Either the correct classname or false. + * @return class-string|null Either the correct classname or null. */ - protected function _resolveClassName($class) + protected function _resolveClassName(string $class): ?string { - if (is_object($class)) { - return $class; - } - + /** @var class-string|null */ return App::className($class, 'Cache/Engine', 'Engine'); } @@ -50,13 +51,13 @@ protected function _resolveClassName($class) * Part of the template method for Cake\Core\ObjectRegistry::load() * * @param string $class The classname that is missing. - * @param string $plugin The plugin the cache is missing in. + * @param string|null $plugin The plugin the cache is missing in. * @return void * @throws \BadMethodCallException */ - protected function _throwMissingClassError($class, $plugin) + protected function _throwMissingClassError(string $class, ?string $plugin): void { - throw new BadMethodCallException(sprintf('Cache engine %s is not available.', $class)); + throw new BadMethodCallException(sprintf('Cache engine `%s` is not available.', $class)); } /** @@ -64,40 +65,32 @@ protected function _throwMissingClassError($class, $plugin) * * Part of the template method for Cake\Core\ObjectRegistry::load() * - * @param string|\Cake\Cache\CacheEngine $class The classname or object to make. + * @param TEngine|class-string $class The classname or object to make. * @param string $alias The alias of the object. - * @param array $config An array of settings to use for the cache engine. - * @return \Cake\Cache\CacheEngine The constructed CacheEngine class. - * @throws \RuntimeException when an object doesn't implement the correct interface. + * @param array $config An array of settings to use for the cache engine. + * @return TEngine The constructed CacheEngine class. + * @throws \Cake\Core\Exception\CakeException When the cache engine cannot be initialized. */ - protected function _create($class, $alias, $config) + protected function _create(object|string $class, string $alias, array $config): CacheEngine { if (is_object($class)) { $instance = $class; - } - - unset($config['className']); - if (!isset($instance)) { + } else { $instance = new $class($config); } + unset($config['className']); - if (!($instance instanceof CacheEngine)) { - throw new RuntimeException( - 'Cache engines must use Cake\Cache\CacheEngine as a base class.' - ); - } + assert($instance instanceof CacheEngine, 'Cache engines must extend `' . CacheEngine::class . '`.'); if (!$instance->init($config)) { - throw new RuntimeException( - sprintf('Cache engine %s is not properly configured.', get_class($instance)) + throw new CakeException( + sprintf( + 'Cache engine `%s` is not properly configured. Check error log for additional information.', + $instance::class, + ), ); } - $config = $instance->getConfig(); - if ($config['probability'] && time() % $config['probability'] === 0) { - $instance->gc(); - } - return $instance; } @@ -105,10 +98,12 @@ protected function _create($class, $alias, $config) * Remove a single adapter from the registry. * * @param string $name The adapter name. - * @return void + * @return $this */ - public function unload($name) + public function unload(string $name) { unset($this->_loaded[$name]); + + return $this; } } diff --git a/src/Cache/Engine/ApcEngine.php b/src/Cache/Engine/ApcEngine.php deleted file mode 100644 index 7800d0d2501..00000000000 --- a/src/Cache/Engine/ApcEngine.php +++ /dev/null @@ -1,238 +0,0 @@ -_key($key); - - $expires = 0; - $duration = $this->_config['duration']; - if ($duration) { - $expires = time() + $duration; - } - apcu_store($key . '_expires', $expires, $duration); - - return apcu_store($key, $value, $duration); - } - - /** - * Read a key from the cache - * - * @param string $key Identifier for the data - * @return mixed The cached data, or false if the data doesn't exist, - * has expired, or if there was an error fetching it - */ - public function read($key) - { - $key = $this->_key($key); - - $time = time(); - $cachetime = (int)apcu_fetch($key . '_expires'); - if ($cachetime !== 0 && ($cachetime < $time || ($time + $this->_config['duration']) < $cachetime)) { - return false; - } - - return apcu_fetch($key); - } - - /** - * Increments the value of an integer cached key - * - * @param string $key Identifier for the data - * @param int $offset How much to increment - * @return bool|int New incremented value, false otherwise - */ - public function increment($key, $offset = 1) - { - $key = $this->_key($key); - - return apcu_inc($key, $offset); - } - - /** - * Decrements the value of an integer cached key - * - * @param string $key Identifier for the data - * @param int $offset How much to subtract - * @return bool|int New decremented value, false otherwise - */ - public function decrement($key, $offset = 1) - { - $key = $this->_key($key); - - return apcu_dec($key, $offset); - } - - /** - * Delete a key from the cache - * - * @param string $key Identifier for the data - * @return bool True if the value was successfully deleted, false if it didn't exist or couldn't be removed - */ - public function delete($key) - { - $key = $this->_key($key); - - return apcu_delete($key); - } - - /** - * Delete all keys from the cache. This will clear every cache config using APC. - * - * @param bool $check If true, nothing will be cleared, as entries are removed - * from APC as they expired. This flag is really only used by FileEngine. - * @return bool True Returns true. - */ - public function clear($check) - { - if ($check) { - return true; - } - if (class_exists('APCuIterator', false)) { - $iterator = new APCuIterator( - '/^' . preg_quote($this->_config['prefix'], '/') . '/', - APC_ITER_NONE - ); - apcu_delete($iterator); - - return true; - } - $cache = apcu_cache_info(); - foreach ($cache['cache_list'] as $key) { - if (strpos($key['info'], $this->_config['prefix']) === 0) { - apcu_delete($key['info']); - } - } - - return true; - } - - /** - * Write data for key into cache if it doesn't exist already. - * If it already exists, it fails and returns false. - * - * @param string $key Identifier for the data. - * @param mixed $value Data to be cached. - * @return bool True if the data was successfully cached, false on failure. - * @link https://secure.php.net/manual/en/function.apc-add.php - */ - public function add($key, $value) - { - $key = $this->_key($key); - - $expires = 0; - $duration = $this->_config['duration']; - if ($duration) { - $expires = time() + $duration; - } - apcu_add($key . '_expires', $expires, $duration); - - return apcu_add($key, $value, $duration); - } - - /** - * Returns the `group value` for each of the configured groups - * If the group initial value was not found, then it initializes - * the group accordingly. - * - * @return array - */ - public function groups() - { - if (empty($this->_compiledGroupNames)) { - foreach ($this->_config['groups'] as $group) { - $this->_compiledGroupNames[] = $this->_config['prefix'] . $group; - } - } - - $groups = apcu_fetch($this->_compiledGroupNames); - if (count($groups) !== count($this->_config['groups'])) { - foreach ($this->_compiledGroupNames as $group) { - if (!isset($groups[$group])) { - apcu_store($group, 1); - $groups[$group] = 1; - } - } - ksort($groups); - } - - $result = []; - $groups = array_values($groups); - foreach ($this->_config['groups'] as $i => $group) { - $result[] = $group . $groups[$i]; - } - - return $result; - } - - /** - * Increments the group value to simulate deletion of all keys under a group - * old values will remain in storage until they expire. - * - * @param string $group The group to clear. - * @return bool success - */ - public function clearGroup($group) - { - $success = false; - apcu_inc($this->_config['prefix'] . $group, 1, $success); - - return $success; - } -} diff --git a/src/Cache/Engine/ApcuEngine.php b/src/Cache/Engine/ApcuEngine.php new file mode 100644 index 00000000000..9256dae193f --- /dev/null +++ b/src/Cache/Engine/ApcuEngine.php @@ -0,0 +1,314 @@ + + */ + protected array $_compiledGroupNames = []; + + /** + * Initialize the Cache Engine + * + * Called automatically by the cache frontend + * + * @param array $config array of setting for the engine + * @return bool True if the engine has been successfully initialized, false if not + */ + public function init(array $config = []): bool + { + if (!extension_loaded('apcu')) { + throw new CakeException('The `apcu` extension must be enabled to use ApcuEngine.'); + } + + return parent::init($config); + } + + /** + * Write data for key into cache + * + * @param string $key Identifier for the data + * @param mixed $value Data to be cached + * @param \DateInterval|int|null $ttl Optional. The TTL value of this item. If no value is sent and + * the driver supports TTL then the library may set a default value + * for it or let the driver take care of that. + * @return bool True on success and false on failure. + * @link https://secure.php.net/manual/en/function.apcu-store.php + */ + public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool + { + $key = $this->_key($key); + $duration = $this->duration($ttl); + + $this->_eventClass = CacheBeforeSetEvent::class; + $this->dispatchEvent(CacheBeforeSetEvent::NAME, ['key' => $key, 'value' => $value, 'ttl' => $duration]); + + $success = apcu_store($key, $value, $duration); + + $this->_eventClass = CacheAfterSetEvent::class; + $this->dispatchEvent(CacheAfterSetEvent::NAME, [ + 'key' => $key, 'value' => $value, 'success' => $success, 'ttl' => $duration, + ]); + + return $success; + } + + /** + * Read a key from the cache + * + * @param string $key Identifier for the data + * @param mixed $default Default value in case the cache misses. + * @return mixed The cached data, or default if the data doesn't exist, + * has expired, or if there was an error fetching it + * @link https://secure.php.net/manual/en/function.apcu-fetch.php + */ + public function get(string $key, mixed $default = null): mixed + { + $key = $this->_key($key); + $this->_eventClass = CacheBeforeGetEvent::class; + $this->dispatchEvent(CacheBeforeGetEvent::NAME, ['key' => $key, 'default' => $default]); + + $value = apcu_fetch($key, $success); + + $this->_eventClass = CacheAfterGetEvent::class; + $this->dispatchEvent(CacheAfterGetEvent::NAME, ['key' => $key, 'value' => $value, 'success' => $success]); + if ($success === false) { + return $default; + } + + return $value; + } + + /** + * Increments the value of an integer cached key + * + * @param string $key Identifier for the data + * @param int $offset How much to increment + * @return int|false New incremented value, false otherwise + * @link https://secure.php.net/manual/en/function.apcu-inc.php + */ + public function increment(string $key, int $offset = 1): int|false + { + $key = $this->_key($key); + $this->_eventClass = CacheBeforeIncrementEvent::class; + $this->dispatchEvent(CacheBeforeIncrementEvent::NAME, ['key' => $key, 'offset' => $offset]); + + $value = apcu_inc($key, $offset); + + $this->_eventClass = CacheAfterIncrementEvent::class; + $this->dispatchEvent(CacheAfterIncrementEvent::NAME, [ + 'key' => $key, 'offset' => $offset, 'success' => $value !== false, 'value' => $value, + ]); + + return $value; + } + + /** + * Decrements the value of an integer cached key + * + * @param string $key Identifier for the data + * @param int $offset How much to subtract + * @return int|false New decremented value, false otherwise + * @link https://secure.php.net/manual/en/function.apcu-dec.php + */ + public function decrement(string $key, int $offset = 1): int|false + { + $key = $this->_key($key); + $this->_eventClass = CacheBeforeDecrementEvent::class; + $this->dispatchEvent(CacheBeforeDecrementEvent::NAME, ['key' => $key, 'offset' => $offset]); + + $result = apcu_dec($key, $offset); + + $this->_eventClass = CacheAfterDecrementEvent::class; + $this->dispatchEvent(CacheAfterDecrementEvent::NAME, [ + 'key' => $key, 'offset' => $offset, 'success' => $result !== false, 'value' => $result, + ]); + + return $result; + } + + /** + * Delete a key from the cache + * + * @param string $key Identifier for the data + * @return bool True if the value was successfully deleted, false if it didn't exist or couldn't be removed + * @link https://secure.php.net/manual/en/function.apcu-delete.php + */ + public function delete(string $key): bool + { + $key = $this->_key($key); + $this->_eventClass = CacheBeforeDeleteEvent::class; + $this->dispatchEvent(CacheBeforeDeleteEvent::NAME, ['key' => $key]); + + $result = apcu_delete($key); + + $this->_eventClass = CacheAfterDeleteEvent::class; + $this->dispatchEvent(CacheAfterDeleteEvent::NAME, ['key' => $key, 'success' => $result]); + + return $result; + } + + /** + * Delete all keys from the cache. This will clear every cache config using APCu. + * + * @return bool True on success. + * @link https://secure.php.net/manual/en/function.apcu-cache-info.php + * @link https://secure.php.net/manual/en/function.apcu-delete.php + */ + public function clear(): bool + { + if (class_exists(APCUIterator::class, false)) { + $iterator = new APCUIterator( + '/^' . preg_quote($this->_config['prefix'], '/') . '/', + APC_ITER_NONE, + ); + apcu_delete($iterator); + $this->_eventClass = CacheClearedEvent::class; + $this->dispatchEvent(CacheClearedEvent::NAME); + + return true; + } + + $cache = apcu_cache_info(); // Raises warning by itself already + foreach ($cache['cache_list'] as $key) { + if (str_starts_with($key['info'], $this->_config['prefix'])) { + apcu_delete($key['info']); + } + } + + $this->_eventClass = CacheClearedEvent::class; + $this->dispatchEvent(CacheClearedEvent::NAME); + + return true; + } + + /** + * Write data for key into cache if it doesn't exist already. + * If it already exists, it fails and returns false. + * + * @param string $key Identifier for the data. + * @param mixed $value Data to be cached. + * @return bool True if the data was successfully cached, false on failure. + * @link https://secure.php.net/manual/en/function.apcu-add.php + */ + public function add(string $key, mixed $value): bool + { + $key = $this->_key($key); + $duration = $this->_config['duration']; + $this->_eventClass = CacheBeforeAddEvent::class; + $this->dispatchEvent(CacheBeforeAddEvent::NAME, [ + 'key' => $key, 'value' => $value, 'ttl' => $duration, + ]); + + $result = apcu_add($key, $value, $duration); + + $this->_eventClass = CacheAfterAddEvent::class; + $this->dispatchEvent(CacheAfterAddEvent::NAME, [ + 'key' => $key, 'value' => $value, 'success' => $result, 'ttl' => $duration, + ]); + + return $result; + } + + /** + * Returns the `group value` for each of the configured groups + * If the group initial value was not found, then it initializes + * the group accordingly. + * + * @return array + * @link https://secure.php.net/manual/en/function.apcu-fetch.php + * @link https://secure.php.net/manual/en/function.apcu-store.php + */ + public function groups(): array + { + if (!$this->_compiledGroupNames) { + foreach ($this->_config['groups'] as $group) { + $this->_compiledGroupNames[] = $this->_config['prefix'] . $group; + } + } + + $success = false; + $groups = apcu_fetch($this->_compiledGroupNames, $success); + if ($success && count($groups) !== count($this->_config['groups'])) { + foreach ($this->_compiledGroupNames as $group) { + if (!isset($groups[$group])) { + $value = 1; + if (apcu_store($group, $value) === false) { + $this->warning( + sprintf('Failed to store key `%s` with value `%s` into APCu cache.', $group, $value), + ); + } + $groups[$group] = $value; + } + } + ksort($groups); + } + + $result = []; + $groups = array_values($groups); + foreach ($this->_config['groups'] as $i => $group) { + $result[] = $group . $groups[$i]; + } + + return $result; + } + + /** + * Increments the group value to simulate deletion of all keys under a group + * old values will remain in storage until they expire. + * + * @param string $group The group to clear. + * @return bool success + * @link https://secure.php.net/manual/en/function.apcu-inc.php + */ + public function clearGroup(string $group): bool + { + $success = false; + apcu_inc($this->_config['prefix'] . $group, 1, $success); + $this->_eventClass = CacheGroupClearEvent::class; + $this->dispatchEvent(CacheGroupClearEvent::NAME, ['group' => $group]); + + return $success; + } +} diff --git a/src/Cache/Engine/ArrayEngine.php b/src/Cache/Engine/ArrayEngine.php new file mode 100644 index 00000000000..5992796fed1 --- /dev/null +++ b/src/Cache/Engine/ArrayEngine.php @@ -0,0 +1,246 @@ + [exp => expiration, val => value]] + * + * @var array + */ + protected array $data = []; + + /** + * Write data for key into cache + * + * @param string $key Identifier for the data + * @param mixed $value Data to be cached + * @param \DateInterval|int|null $ttl Optional. The TTL value of this item. If no value is sent and + * the driver supports TTL then the library may set a default value + * for it or let the driver take care of that. + * @return bool True on success and false on failure. + */ + public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool + { + $key = $this->_key($key); + $expires = time() + $this->duration($ttl); + + $this->_eventClass = CacheBeforeSetEvent::class; + $this->dispatchEvent(CacheBeforeSetEvent::NAME, [ + 'key' => $key, 'value' => $value, 'ttl' => $this->duration($ttl), + ]); + + $this->data[$key] = ['exp' => $expires, 'val' => $value]; + + $this->_eventClass = CacheAfterSetEvent::class; + $this->dispatchEvent(CacheAfterSetEvent::NAME, [ + 'key' => $key, 'value' => $value, 'success' => true, 'ttl' => $this->duration($ttl), + ]); + + return true; + } + + /** + * Read a key from the cache + * + * @param string $key Identifier for the data + * @param mixed $default Default value to return if the key does not exist. + * @return mixed The cached data, or default value if the data doesn't exist, has + * expired, or if there was an error fetching it. + */ + public function get(string $key, mixed $default = null): mixed + { + $key = $this->_key($key); + $this->_eventClass = CacheBeforeGetEvent::class; + $this->dispatchEvent(CacheBeforeGetEvent::NAME, ['key' => $key, 'default' => $default]); + + $this->_eventClass = CacheAfterGetEvent::class; + if (!isset($this->data[$key])) { + $this->dispatchEvent(CacheAfterGetEvent::NAME, ['key' => $key, 'value' => null, 'success' => false]); + + return $default; + } + $data = $this->data[$key]; + + // Check expiration + $now = time(); + if ($data['exp'] <= $now) { + unset($this->data[$key]); + $this->dispatchEvent(CacheAfterGetEvent::NAME, ['key' => $key, 'value' => null, 'success' => false]); + + return $default; + } + + $this->dispatchEvent(CacheAfterGetEvent::NAME, ['key' => $key, 'value' => $data['val'], 'success' => true]); + + return $data['val']; + } + + /** + * Increments the value of an integer cached key + * + * @param string $key Identifier for the data + * @param int $offset How much to increment + * @return int|false New incremented value, false otherwise + */ + public function increment(string $key, int $offset = 1): int|false + { + if ($this->get($key) === null) { + $this->set($key, 0); + } + $key = $this->_key($key); + $this->_eventClass = CacheBeforeIncrementEvent::class; + $this->dispatchEvent(CacheBeforeIncrementEvent::NAME, ['key' => $key, 'offset' => $offset]); + + $this->data[$key]['val'] += $offset; + $val = $this->data[$key]['val']; + + $this->_eventClass = CacheAfterIncrementEvent::class; + $this->dispatchEvent('Cache.afterIncrement', [ + 'key' => $key, 'offset' => $offset, 'success' => true, 'value' => $val, + ]); + + return $val; + } + + /** + * Decrements the value of an integer cached key + * + * @param string $key Identifier for the data + * @param int $offset How much to subtract + * @return int|false New decremented value, false otherwise + */ + public function decrement(string $key, int $offset = 1): int|false + { + if ($this->get($key) === null) { + $this->set($key, 0); + } + $key = $this->_key($key); + $this->_eventClass = CacheBeforeDecrementEvent::class; + $this->dispatchEvent(CacheBeforeDecrementEvent::NAME, ['key' => $key, 'offset' => $offset]); + + $this->data[$key]['val'] -= $offset; + + $this->_eventClass = CacheAfterDecrementEvent::class; + $this->dispatchEvent(CacheAfterDecrementEvent::NAME, [ + 'key' => $key, 'offset' => $offset, 'success' => true, 'value' => $this->data[$key]['val'], + ]); + + return $this->data[$key]['val']; + } + + /** + * Delete a key from the cache + * + * @param string $key Identifier for the data + * @return bool True if the value was successfully deleted, false if it didn't exist or couldn't be removed + */ + public function delete(string $key): bool + { + $key = $this->_key($key); + $this->_eventClass = CacheBeforeDeleteEvent::class; + $this->dispatchEvent(CacheBeforeDeleteEvent::NAME, ['key' => $key]); + + unset($this->data[$key]); + + $this->_eventClass = CacheAfterDeleteEvent::class; + $this->dispatchEvent(CacheAfterDeleteEvent::NAME, ['key' => $key, 'success' => true]); + + return true; + } + + /** + * Delete all keys from the cache. + * + * @return bool True on success. + */ + public function clear(): bool + { + $this->data = []; + $this->_eventClass = CacheClearedEvent::class; + $this->dispatchEvent(CacheClearedEvent::NAME); + + return true; + } + + /** + * Returns the `group value` for each of the configured groups + * If the group initial value was not found, then it initializes + * the group accordingly. + * + * @return array + */ + public function groups(): array + { + $result = []; + foreach ($this->_config['groups'] as $group) { + $key = $this->_config['prefix'] . $group; + $this->data[$key] ??= ['exp' => PHP_INT_MAX, 'val' => 1]; + $value = $this->data[$key]['val']; + $result[] = $group . $value; + } + + return $result; + } + + /** + * Increments the group value to simulate deletion of all keys under a group + * old values will remain in storage until they expire. + * + * @param string $group The group to clear. + * @return bool success + */ + public function clearGroup(string $group): bool + { + $key = $this->_config['prefix'] . $group; + if (isset($this->data[$key])) { + $this->data[$key]['val'] += 1; + } + $this->_eventClass = CacheGroupClearEvent::class; + $this->dispatchEvent(CacheGroupClearEvent::NAME, ['group' => $group]); + + return true; + } +} diff --git a/src/Cache/Engine/FileEngine.php b/src/Cache/Engine/FileEngine.php index a5367f373c7..ae464c9f3ef 100644 --- a/src/Cache/Engine/FileEngine.php +++ b/src/Cache/Engine/FileEngine.php @@ -1,4 +1,6 @@ */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ 'duration' => 3600, 'groups' => [], - 'isWindows' => false, 'lock' => true, 'mask' => 0664, + 'dirMask' => 0777, 'path' => null, 'prefix' => 'cake_', - 'probability' => 100, - 'serialize' => true + 'serialize' => true, ]; /** @@ -75,26 +83,21 @@ class FileEngine extends CacheEngine * * @var bool */ - protected $_init = true; + protected bool $_init = true; /** * Initialize File Cache Engine * * Called automatically by the cache frontend. * - * @param array $config array of setting for the engine + * @param array $config array of setting for the engine * @return bool True if the engine has been successfully initialized, false if not */ - public function init(array $config = []) + public function init(array $config = []): bool { parent::init($config); - if ($this->_config['path'] === null) { - $this->_config['path'] = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'cake_cache' . DIRECTORY_SEPARATOR; - } - if (DIRECTORY_SEPARATOR === '\\') { - $this->_config['isWindows'] = true; - } + $this->_config['path'] ??= sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'cake_cache' . DIRECTORY_SEPARATOR; if (substr($this->_config['path'], -1) !== DIRECTORY_SEPARATOR) { $this->_config['path'] .= DIRECTORY_SEPARATOR; } @@ -105,53 +108,43 @@ public function init(array $config = []) return $this->_active(); } - /** - * Garbage collection. Permanently remove all expired and deleted data - * - * @param int|null $expires [optional] An expires timestamp, invalidating all data before. - * @return bool True if garbage collection was successful, false on failure - */ - public function gc($expires = null) - { - return $this->clear(true); - } - /** * Write data for key into cache * * @param string $key Identifier for the data - * @param mixed $data Data to be cached - * @return bool True if the data was successfully cached, false on failure + * @param mixed $value Data to be cached + * @param \DateInterval|int|null $ttl Optional. The TTL value of this item. If no value is sent and + * the driver supports TTL then the library may set a default value + * for it or let the driver take care of that. + * @return bool True on success and false on failure. */ - public function write($key, $data) + public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool { - if ($data === '' || !$this->_init) { + if ($value === '' || !$this->_init) { return false; } + $duration = $this->duration($ttl); $key = $this->_key($key); + $this->_eventClass = CacheBeforeSetEvent::class; + $this->dispatchEvent(CacheBeforeSetEvent::NAME, ['key' => $key, 'value' => $value, 'ttl' => $duration]); + $this->_eventClass = CacheAfterSetEvent::class; if ($this->_setKey($key, true) === false) { - return false; - } - - $lineBreak = "\n"; + $this->dispatchEvent(CacheAfterSetEvent::NAME, [ + 'key' => $key, 'value' => $value, 'success' => false, 'ttl' => $duration, + ]); - if ($this->_config['isWindows']) { - $lineBreak = "\r\n"; + return false; } + $origValue = $value; if (!empty($this->_config['serialize'])) { - if ($this->_config['isWindows']) { - $data = str_replace('\\', '\\\\\\\\', serialize($data)); - } else { - $data = serialize($data); - } + $value = serialize($value); } - $duration = $this->_config['duration']; $expires = time() + $duration; - $contents = implode([$expires, $lineBreak, $data, $lineBreak]); + $contents = implode('', [$expires, PHP_EOL, $value, PHP_EOL]); if ($this->_config['lock']) { $this->_File->flock(LOCK_EX); @@ -165,7 +158,11 @@ public function write($key, $data) if ($this->_config['lock']) { $this->_File->flock(LOCK_UN); } - $this->_File = null; + unset($this->_File); + + $this->dispatchEvent(CacheAfterSetEvent::NAME, [ + 'key' => $key, 'value' => $origValue, 'success' => $success, 'ttl' => $duration, + ]); return $success; } @@ -174,15 +171,21 @@ public function write($key, $data) * Read a key from the cache * * @param string $key Identifier for the data - * @return mixed The cached data, or false if the data doesn't exist, has + * @param mixed $default Default value to return if the key does not exist. + * @return mixed The cached data, or default value if the data doesn't exist, has * expired, or if there was an error fetching it */ - public function read($key) + public function get(string $key, mixed $default = null): mixed { $key = $this->_key($key); + $this->_eventClass = CacheBeforeGetEvent::class; + $this->dispatchEvent(CacheBeforeGetEvent::NAME, ['key' => $key, 'default' => $default]); + $this->_eventClass = CacheAfterGetEvent::class; if (!$this->_init || $this->_setKey($key) === false) { - return false; + $this->dispatchEvent(CacheAfterGetEvent::NAME, ['key' => $key, 'value' => null, 'success' => false]); + + return $default; } if ($this->_config['lock']) { @@ -193,12 +196,13 @@ public function read($key) $time = time(); $cachetime = (int)$this->_File->current(); - if ($cachetime < $time || ($time + $this->_config['duration']) < $cachetime) { + if ($cachetime < $time) { if ($this->_config['lock']) { $this->_File->flock(LOCK_UN); } + $this->dispatchEvent(CacheAfterGetEvent::NAME, ['key' => $key, 'value' => null, 'success' => false]); - return false; + return $default; } $data = ''; @@ -215,12 +219,14 @@ public function read($key) $data = trim($data); if ($data !== '' && !empty($this->_config['serialize'])) { - if ($this->_config['isWindows']) { - $data = str_replace('\\\\\\\\', '\\', $data); - } - $data = unserialize((string)$data); + $data = unserialize($data); + $this->dispatchEvent(CacheAfterGetEvent::NAME, ['key' => $key, 'value' => $data, 'success' => true]); + + return $data; } + $this->dispatchEvent(CacheAfterGetEvent::NAME, ['key' => $key, 'value' => $data, 'success' => true]); + return $data; } @@ -231,61 +237,87 @@ public function read($key) * @return bool True if the value was successfully deleted, false if it didn't * exist or couldn't be removed */ - public function delete($key) + public function delete(string $key): bool { $key = $this->_key($key); + $this->_eventClass = CacheBeforeDeleteEvent::class; + $this->dispatchEvent(CacheBeforeDeleteEvent::NAME, ['key' => $key]); + $this->_eventClass = CacheAfterDeleteEvent::class; if ($this->_setKey($key) === false || !$this->_init) { + $this->dispatchEvent(CacheAfterDeleteEvent::NAME, ['key' => $key, 'success' => false]); + return false; } $path = $this->_File->getRealPath(); - $this->_File = null; + unset($this->_File); + + if ($path === false) { + $this->dispatchEvent(CacheAfterDeleteEvent::NAME, ['key' => $key, 'success' => false]); + + return false; + } - //@codingStandardsIgnoreStart + $this->dispatchEvent(CacheAfterDeleteEvent::NAME, ['key' => $key, 'success' => true]); + + // phpcs:disable return @unlink($path); - //@codingStandardsIgnoreEnd + // phpcs:enable } /** * Delete all values from the cache * - * @param bool $check Optional - only delete expired cache items * @return bool True if the cache was successfully cleared, false otherwise */ - public function clear($check) + public function clear(): bool { if (!$this->_init) { return false; } - $this->_File = null; - - $threshold = $now = false; - if ($check) { - $now = time(); - $threshold = $now - $this->_config['duration']; - } + unset($this->_File); - $this->_clearDirectory($this->_config['path'], $now, $threshold); + $this->_clearDirectory($this->_config['path']); - $directory = new RecursiveDirectoryIterator($this->_config['path']); - $contents = new RecursiveIteratorIterator( + $directory = new RecursiveDirectoryIterator( + $this->_config['path'], + FilesystemIterator::SKIP_DOTS, + ); + /** @var iterable<\SplFileInfo> $iterator */ + $iterator = new RecursiveIteratorIterator( $directory, - RecursiveIteratorIterator::SELF_FIRST + RecursiveIteratorIterator::SELF_FIRST, ); $cleared = []; - foreach ($contents as $path) { - if ($path->isFile()) { + foreach ($iterator as $fileInfo) { + if ($fileInfo->isFile()) { + unset($fileInfo); + continue; + } + + $realPath = $fileInfo->getRealPath(); + if (!$realPath) { + unset($fileInfo); continue; } - $path = $path->getRealPath() . DIRECTORY_SEPARATOR; - if (!in_array($path, $cleared)) { - $this->_clearDirectory($path, $now, $threshold); + $path = $realPath . DIRECTORY_SEPARATOR; + if (!in_array($path, $cleared, true)) { + $this->_clearDirectory($path); $cleared[] = $path; } + + // possible inner iterators need to be unset too in order for locks on parents to be released + unset($fileInfo); } + // unsetting iterators helps releasing possible locks in certain environments, + // which could otherwise make `rmdir()` fail + unset($directory, $iterator); + $this->_eventClass = CacheClearedEvent::class; + $this->dispatchEvent(CacheClearedEvent::NAME); + return true; } @@ -293,18 +325,21 @@ public function clear($check) * Used to clear a directory of matching files. * * @param string $path The path to search. - * @param int $now The current timestamp - * @param int $threshold Any file not modified after this value will be deleted. * @return void */ - protected function _clearDirectory($path, $now, $threshold) + protected function _clearDirectory(string $path): void { if (!is_dir($path)) { return; } - $prefixLength = strlen($this->_config['prefix']); $dir = dir($path); + if (!$dir) { + return; + } + + $prefixLength = strlen($this->_config['prefix']); + while (($entry = $dir->read()) !== false) { if (substr($entry, 0, $prefixLength) !== $this->_config['prefix']) { continue; @@ -312,30 +347,23 @@ protected function _clearDirectory($path, $now, $threshold) try { $file = new SplFileObject($path . $entry, 'r'); - } catch (Exception $e) { + } catch (Exception) { continue; } - if ($threshold) { - $mtime = $file->getMTime(); - if ($mtime > $threshold) { - continue; - } - - $expires = (int)$file->current(); - if ($expires > $now) { - continue; - } - } if ($file->isFile()) { $filePath = $file->getRealPath(); - $file = null; + unset($file); - //@codingStandardsIgnoreStart - @unlink($filePath); - //@codingStandardsIgnoreEnd + if ($filePath !== false) { + // phpcs:disable + @unlink($filePath); + // phpcs:enable + } } } + + $dir->close(); } /** @@ -343,10 +371,10 @@ protected function _clearDirectory($path, $now, $threshold) * * @param string $key The key to decrement * @param int $offset The number to offset - * @return void + * @return int|false * @throws \LogicException */ - public function decrement($key, $offset = 1) + public function decrement(string $key, int $offset = 1): int|false { throw new LogicException('Files cannot be atomically decremented.'); } @@ -356,10 +384,10 @@ public function decrement($key, $offset = 1) * * @param string $key The key to increment * @param int $offset The number to offset - * @return void + * @return int|false * @throws \LogicException */ - public function increment($key, $offset = 1) + public function increment(string $key, int $offset = 1): int|false { throw new LogicException('Files cannot be atomically incremented.'); } @@ -372,7 +400,7 @@ public function increment($key, $offset = 1) * @param bool $createKey Whether the key should be created if it doesn't exists, or not * @return bool true if the cache key could be set, false otherwise */ - protected function _setKey($key, $createKey = false) + protected function _setKey(string $key, bool $createKey = false): bool { $groups = null; if ($this->_groupPrefix) { @@ -381,7 +409,7 @@ protected function _setKey($key, $createKey = false) $dir = $this->_config['path'] . $groups; if (!is_dir($dir)) { - mkdir($dir, 0775, true); + mkdir($dir, $this->_config['dirMask'] ^ umask(), true); } $path = new SplFileInfo($dir . $key); @@ -389,8 +417,12 @@ protected function _setKey($key, $createKey = false) if (!$createKey && !$path->isFile()) { return false; } - if (empty($this->_File) || $this->_File->getBasename() !== $key) { - $exists = file_exists($path->getPathname()); + if ( + !isset($this->_File) || + $this->_File->getBasename() !== $key || + $this->_File->valid() === false + ) { + $exists = is_file($path->getPathname()); try { $this->_File = $path->openFile('c+'); } catch (Exception $e) { @@ -402,9 +434,9 @@ protected function _setKey($key, $createKey = false) if (!$exists && !chmod($this->_File->getPathname(), (int)$this->_config['mask'])) { trigger_error(sprintf( - 'Could not apply permission mask "%s" on cache file "%s"', + 'Could not apply permission mask `%s` on cache file `%s`', + $this->_config['mask'], $this->_File->getPathname(), - $this->_config['mask'] ), E_USER_WARNING); } } @@ -413,19 +445,19 @@ protected function _setKey($key, $createKey = false) } /** - * Determine is cache directory is writable + * Determine if cache directory is writable * * @return bool */ - protected function _active() + protected function _active(): bool { $dir = new SplFileInfo($this->_config['path']); $path = $dir->getPathname(); $success = true; if (!is_dir($path)) { - //@codingStandardsIgnoreStart - $success = @mkdir($path, 0775, true); - //@codingStandardsIgnoreEnd + // phpcs:disable + $success = @mkdir($path, $this->_config['dirMask'] ^ umask(), true); + // phpcs:enable } $isWritableDir = ($dir->isDir() && $dir->isWritable()); @@ -433,7 +465,7 @@ protected function _active() $this->_init = false; trigger_error(sprintf( '%s is not writable', - $this->_config['path'] + $this->_config['path'], ), E_USER_WARNING); } @@ -441,24 +473,13 @@ protected function _active() } /** - * Generates a safe key for use with cache engine storage engines. - * - * @param string $key the key passed over - * @return mixed string $key or false + * @inheritDoc */ - public function key($key) + protected function _key(string $key): string { - if (empty($key)) { - return false; - } - - $key = Inflector::underscore(str_replace( - [DIRECTORY_SEPARATOR, '/', '.', '<', '>', '?', ':', '|', '*', '"'], - '_', - (string)$key - )); + $key = parent::_key($key); - return $key; + return rawurlencode($key); } /** @@ -467,29 +488,49 @@ public function key($key) * @param string $group The group to clear. * @return bool success */ - public function clearGroup($group) + public function clearGroup(string $group): bool { - $this->_File = null; + unset($this->_File); + + $prefix = (string)$this->_config['prefix']; + $directoryIterator = new RecursiveDirectoryIterator($this->_config['path']); $contents = new RecursiveIteratorIterator( $directoryIterator, - RecursiveIteratorIterator::CHILD_FIRST + RecursiveIteratorIterator::CHILD_FIRST, ); - foreach ($contents as $object) { - $containsGroup = strpos($object->getPathname(), DIRECTORY_SEPARATOR . $group . DIRECTORY_SEPARATOR) !== false; - $hasPrefix = true; - if (strlen($this->_config['prefix']) !== 0) { - $hasPrefix = strpos($object->getBasename(), $this->_config['prefix']) === 0; - } - if ($object->isFile() && $containsGroup && $hasPrefix) { - $path = $object->getPathname(); - $object = null; - //@codingStandardsIgnoreStart - @unlink($path); - //@codingStandardsIgnoreEnd - } + /** @var iterable<\SplFileInfo> $filtered */ + $filtered = new CallbackFilterIterator( + $contents, + function (SplFileInfo $current) use ($group, $prefix) { + if (!$current->isFile()) { + return false; + } + + $hasPrefix = $prefix === '' || str_starts_with($current->getBasename(), $prefix); + if ($hasPrefix === false) { + return false; + } + + return str_contains( + $current->getPathname(), + DIRECTORY_SEPARATOR . $group . DIRECTORY_SEPARATOR, + ); + }, + ); + foreach ($filtered as $object) { + $path = $object->getPathname(); + unset($object); + // phpcs:ignore + @unlink($path); } + // unsetting iterators helps releasing possible locks in certain environments, + // which could otherwise make `rmdir()` fail + unset($directoryIterator, $contents, $filtered); + $this->_eventClass = CacheGroupClearEvent::class; + $this->dispatchEvent(CacheGroupClearEvent::NAME, ['group' => $group]); + return true; } } diff --git a/src/Cache/Engine/MemcachedEngine.php b/src/Cache/Engine/MemcachedEngine.php index 5872cd6613f..e87ca25b7f8 100644 --- a/src/Cache/Engine/MemcachedEngine.php +++ b/src/Cache/Engine/MemcachedEngine.php @@ -1,4 +1,6 @@ value. * Use the \Memcached::OPT_* constants as keys. * - * @var array + * @var array */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ 'compress' => false, 'duration' => 3600, 'groups' => [], 'host' => null, 'username' => null, 'password' => null, - 'persistent' => false, + 'persistent' => null, 'port' => null, 'prefix' => 'cake_', - 'probability' => 100, 'serialize' => 'php', 'servers' => ['127.0.0.1'], 'options' => [], @@ -82,39 +95,39 @@ class MemcachedEngine extends CacheEngine /** * List of available serializer engines * - * Memcached must be compiled with json and igbinary support to use these engines + * Memcached must be compiled with JSON and igbinary support to use these engines * - * @var array + * @var array */ - protected $_serializers = []; + protected array $_serializers = []; /** - * @var string[] + * @var array */ - protected $_compiledGroupNames = []; + protected array $_compiledGroupNames = []; /** * Initialize the Cache Engine * * Called automatically by the cache frontend * - * @param array $config array of setting for the engine + * @param array $config array of setting for the engine * @return bool True if the engine has been successfully initialized, false if not - * @throws \InvalidArgumentException When you try use authentication without + * @throws \Cake\Cache\Exception\InvalidArgumentException When you try use authentication without * Memcached compiled with SASL support */ - public function init(array $config = []) + public function init(array $config = []): bool { if (!extension_loaded('memcached')) { - return false; + throw new CakeException('The `memcached` extension must be enabled to use MemcachedEngine.'); } $this->_serializers = [ 'igbinary' => Memcached::SERIALIZER_IGBINARY, 'json' => Memcached::SERIALIZER_JSON, - 'php' => Memcached::SERIALIZER_PHP + 'php' => Memcached::SERIALIZER_PHP, ]; - if (defined('Memcached::HAVE_MSGPACK') && Memcached::HAVE_MSGPACK) { + if (defined('Memcached::HAVE_MSGPACK')) { $this->_serializers['msgpack'] = Memcached::SERIALIZER_MSGPACK; } @@ -141,13 +154,26 @@ public function init(array $config = []) } if ($this->_config['persistent']) { - $this->_Memcached = new Memcached((string)$this->_config['persistent']); + $this->_Memcached = new Memcached($this->_config['persistent']); } else { $this->_Memcached = new Memcached(); } $this->_setOptions(); - if (count($this->_Memcached->getServerList())) { + $serverList = $this->_Memcached->getServerList(); + if ($serverList) { + if ($this->_Memcached->isPersistent()) { + foreach ($serverList as $server) { + if (!in_array($server['host'] . ':' . $server['port'], $this->_config['servers'], true)) { + throw new InvalidArgumentException( + 'Invalid cache configuration. Multiple persistent cache configurations are detected' . + ' with different `servers` values. `servers` values for persistent cache configurations' . + ' must be the same when using the same persistence id.', + ); + } + } + } + return true; } @@ -168,20 +194,21 @@ public function init(array $config = []) if (empty($this->_config['username']) && !empty($this->_config['login'])) { throw new InvalidArgumentException( - 'Please pass "username" instead of "login" for connecting to Memcached' + 'Please pass "username" instead of "login" for connecting to Memcached', ); } if ($this->_config['username'] !== null && $this->_config['password'] !== null) { + // @phpstan-ignore function.alreadyNarrowedType (check kept for SASL support detection) if (!method_exists($this->_Memcached, 'setSaslAuthData')) { throw new InvalidArgumentException( - 'Memcached extension is not built with SASL support' + 'Memcached extension is not built with SASL support', ); } $this->_Memcached->setOption(Memcached::OPT_BINARY_PROTOCOL, true); $this->_Memcached->setSaslAuthData( $this->_config['username'], - $this->_config['password'] + $this->_config['password'], ); } @@ -189,49 +216,48 @@ public function init(array $config = []) } /** - * Settings the memcached instance + * Set the memcached instance options * * @return void - * @throws \InvalidArgumentException When the Memcached extension is not built + * @throws \Cake\Cache\Exception\InvalidArgumentException When the Memcached extension is not built * with the desired serializer engine. */ - protected function _setOptions() + protected function _setOptions(): void { $this->_Memcached->setOption(Memcached::OPT_LIBKETAMA_COMPATIBLE, true); $serializer = strtolower($this->_config['serialize']); if (!isset($this->_serializers[$serializer])) { throw new InvalidArgumentException( - sprintf('%s is not a valid serializer engine for Memcached', $serializer) + sprintf('`%s` is not a valid serializer engine for Memcached.', $serializer), ); } - if ($serializer !== 'php' && + if ( + $serializer !== 'php' && !constant('Memcached::HAVE_' . strtoupper($serializer)) ) { throw new InvalidArgumentException( - sprintf('Memcached extension is not compiled with %s support', $serializer) + sprintf('Memcached extension is not compiled with `%s` support.', $serializer), ); } $this->_Memcached->setOption( Memcached::OPT_SERIALIZER, - $this->_serializers[$serializer] + $this->_serializers[$serializer], ); // Check for Amazon ElastiCache instance - if (defined('Memcached::OPT_CLIENT_MODE') && + if ( + defined('Memcached::OPT_CLIENT_MODE') && defined('Memcached::DYNAMIC_CLIENT_MODE') ) { - $this->_Memcached->setOption( - Memcached::OPT_CLIENT_MODE, - Memcached::DYNAMIC_CLIENT_MODE - ); + $this->_Memcached->setOption(Memcached::OPT_CLIENT_MODE, Memcached::DYNAMIC_CLIENT_MODE); } $this->_Memcached->setOption( Memcached::OPT_COMPRESSION, - (bool)$this->_config['compress'] + (bool)$this->_config['compress'], ); } @@ -242,13 +268,13 @@ protected function _setOptions() * @param string $server The server address string. * @return array Array containing host, port */ - public function parseServerString($server) + public function parseServerString(string $server): array { $socketTransport = 'unix://'; - if (strpos($server, $socketTransport) === 0) { + if (str_starts_with($server, $socketTransport)) { return [substr($server, strlen($socketTransport)), 0]; } - if (substr($server, 0, 1) === '[') { + if (str_starts_with($server, '[')) { $position = strpos($server, ']:'); if ($position !== false) { $position++; @@ -266,25 +292,14 @@ public function parseServerString($server) return [$host, (int)$port]; } - /** - * Backwards compatible alias of parseServerString - * - * @param string $server The server address string. - * @return array Array containing host, port - * @deprecated 3.4.13 Will be removed in 4.0.0 - */ - protected function _parseServerString($server) - { - return $this->parseServerString($server); - } - /** * Read an option value from the memcached connection. * - * @param string $name The option name to read. - * @return string|int|null|bool + * @param int $name The option name to read. + * @return string|int|bool|null + * @see https://secure.php.net/manual/en/memcached.getoption.php */ - public function getOption($name) + public function getOption(int $name): string|int|bool|null { return $this->_Memcached->getOption($name); } @@ -293,82 +308,104 @@ public function getOption($name) * Write data for key into cache. When using memcached as your cache engine * remember that the Memcached pecl extension does not support cache expiry * times greater than 30 days in the future. Any duration greater than 30 days - * will be treated as never expiring. + * will be treated as real Unix time value rather than an offset from current time. * * @param string $key Identifier for the data * @param mixed $value Data to be cached + * @param \DateInterval|int|null $ttl Optional. The TTL value of this item. If no value is sent and + * the driver supports TTL then the library may set a default value + * for it or let the driver take care of that. * @return bool True if the data was successfully cached, false on failure - * @see https://secure.php.net/manual/en/memcache.set.php + * @see https://www.php.net/manual/en/memcached.set.php */ - public function write($key, $value) + public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool { - $duration = $this->_config['duration']; - if ($duration > 30 * DAY) { - $duration = 0; - } - $key = $this->_key($key); + $duration = $this->duration($ttl); + $this->_eventClass = CacheBeforeSetEvent::class; + $this->dispatchEvent(CacheBeforeSetEvent::NAME, ['key' => $key, 'value' => $value, 'ttl' => $duration]); - return $this->_Memcached->set($key, $value, $duration); + $success = $this->_Memcached->set($key, $value, $duration); + + $this->_eventClass = CacheAfterSetEvent::class; + $this->dispatchEvent(CacheAfterSetEvent::NAME, [ + 'key' => $key, 'value' => $value, 'success' => $success, 'ttl' => $duration, + ]); + + return $success; } /** * Write many cache entries to the cache at once * - * @param array $data An array of data to be stored in the cache - * @return array of bools for each key provided, true if the data was - * successfully cached, false on failure + * @param iterable $values An array of data to be stored in the cache + * @param \DateInterval|int|null $ttl Optional. The TTL value of this item. If no value is sent and + * the driver supports TTL then the library may set a default value + * for it or let the driver take care of that. + * @return bool Whether the write was successful or not. */ - public function writeMany($data) + public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool { $cacheData = []; - foreach ($data as $key => $value) { + foreach ($values as $key => $value) { $cacheData[$this->_key($key)] = $value; } + $duration = $this->duration($ttl); - $success = $this->_Memcached->setMulti($cacheData); - - $return = []; - foreach (array_keys($data) as $key) { - $return[$key] = $success; - } - - return $return; + return $this->_Memcached->setMulti($cacheData, $duration); } /** * Read a key from the cache * * @param string $key Identifier for the data - * @return mixed The cached data, or false if the data doesn't exist, has + * @param mixed $default Default value to return if the key does not exist. + * @return mixed The cached data, or default value if the data doesn't exist, has * expired, or if there was an error fetching it. */ - public function read($key) + public function get(string $key, mixed $default = null): mixed { $key = $this->_key($key); + $this->_eventClass = CacheBeforeGetEvent::class; + $this->dispatchEvent(CacheBeforeGetEvent::NAME, ['key' => $key, 'default' => $default]); + + $value = $this->_Memcached->get($key); + + $this->_eventClass = CacheAfterGetEvent::class; + if ($this->_Memcached->getResultCode() === Memcached::RES_NOTFOUND) { + $this->dispatchEvent(CacheAfterGetEvent::NAME, ['key' => $key, 'value' => null, 'success' => false]); - return $this->_Memcached->get($key); + return $default; + } + + $this->dispatchEvent(CacheAfterGetEvent::NAME, ['key' => $key, 'value' => $value, 'success' => true]); + + return $value; } /** * Read many keys from the cache at once * - * @param array $keys An array of identifiers for the data - * @return array An array containing, for each of the given $keys, the cached data or - * false if cached data could not be retrieved. + * @param iterable $keys An array of identifiers for the data + * @param mixed $default Default value to return for keys that do not exist. + * @return iterable An array containing, for each of the given $keys, the cached data or + * `$default` if cached data could not be retrieved. */ - public function readMany($keys) + public function getMultiple(iterable $keys, mixed $default = null): iterable { $cacheKeys = []; foreach ($keys as $key) { - $cacheKeys[] = $this->_key($key); + $cacheKeys[$key] = $this->_key($key); } $values = $this->_Memcached->getMulti($cacheKeys); + if ($values === false) { + return array_fill_keys(array_keys($cacheKeys), $default); + } + $return = []; - foreach ($keys as &$key) { - $return[$key] = array_key_exists($this->_key($key), $values) ? - $values[$this->_key($key)] : false; + foreach ($cacheKeys as $original => $prefixed) { + $return[$original] = array_key_exists($prefixed, $values) ? $values[$prefixed] : $default; } return $return; @@ -379,13 +416,22 @@ public function readMany($keys) * * @param string $key Identifier for the data * @param int $offset How much to increment - * @return bool|int New incremented value, false otherwise + * @return int|false New incremented value, false otherwise */ - public function increment($key, $offset = 1) + public function increment(string $key, int $offset = 1): int|false { $key = $this->_key($key); + $this->_eventClass = CacheBeforeIncrementEvent::class; + $this->dispatchEvent(CacheBeforeIncrementEvent::NAME, ['key' => $key, 'offset' => $offset]); + + $value = $this->_Memcached->increment($key, $offset); - return $this->_Memcached->increment($key, $offset); + $this->_eventClass = CacheAfterIncrementEvent::class; + $this->dispatchEvent(CacheAfterIncrementEvent::NAME, [ + 'key' => $key, 'offset' => $offset, 'success' => $value !== false, 'value' => $value, + ]); + + return $value; } /** @@ -393,13 +439,22 @@ public function increment($key, $offset = 1) * * @param string $key Identifier for the data * @param int $offset How much to subtract - * @return bool|int New decremented value, false otherwise + * @return int|false New decremented value, false otherwise */ - public function decrement($key, $offset = 1) + public function decrement(string $key, int $offset = 1): int|false { $key = $this->_key($key); + $this->_eventClass = CacheBeforeDecrementEvent::class; + $this->dispatchEvent(CacheBeforeDecrementEvent::NAME, ['key' => $key, 'offset' => $offset]); + + $value = $this->_Memcached->decrement($key, $offset); - return $this->_Memcached->decrement($key, $offset); + $this->_eventClass = CacheAfterDecrementEvent::class; + $this->dispatchEvent(CacheAfterDecrementEvent::NAME, [ + 'key' => $key, 'offset' => $offset, 'success' => $value !== false, 'value' => $value, + ]); + + return $value; } /** @@ -409,59 +464,63 @@ public function decrement($key, $offset = 1) * @return bool True if the value was successfully deleted, false if it didn't * exist or couldn't be removed. */ - public function delete($key) + public function delete(string $key): bool { $key = $this->_key($key); + $this->_eventClass = CacheBeforeDeleteEvent::class; + $this->dispatchEvent(CacheBeforeDeleteEvent::NAME, ['key' => $key]); + + $success = $this->_Memcached->delete($key); - return $this->_Memcached->delete($key); + $this->_eventClass = CacheAfterDeleteEvent::class; + $this->dispatchEvent(CacheAfterDeleteEvent::NAME, ['key' => $key, 'success' => $success]); + + return $success; } /** * Delete many keys from the cache at once * - * @param array $keys An array of identifiers for the data - * @return array of boolean values that are true if the key was successfully + * @param iterable $keys An array of identifiers for the data + * @return bool of boolean values that are true if the key was successfully * deleted, false if it didn't exist or couldn't be removed. */ - public function deleteMany($keys) + public function deleteMultiple(iterable $keys): bool { $cacheKeys = []; + $this->_eventClass = CacheBeforeDeleteEvent::class; foreach ($keys as $key) { $cacheKeys[] = $this->_key($key); + $this->dispatchEvent(CacheBeforeDeleteEvent::NAME, ['key' => $key]); } - - $success = $this->_Memcached->deleteMulti($cacheKeys); - - $return = []; - foreach ($keys as $key) { - $return[$key] = $success; + $success = (bool)$this->_Memcached->deleteMulti($cacheKeys); + $this->_eventClass = CacheAfterDeleteEvent::class; + foreach ($cacheKeys as $key) { + $this->dispatchEvent(CacheAfterDeleteEvent::NAME, ['key' => $key, 'success' => $success]); } - return $return; + return $success; } /** * Delete all keys from the cache * - * @param bool $check If true will check expiration, otherwise delete all. * @return bool True if the cache was successfully cleared, false otherwise */ - public function clear($check) + public function clear(): bool { - if ($check) { - return true; - } - $keys = $this->_Memcached->getAllKeys(); if ($keys === false) { return false; } foreach ($keys as $key) { - if (strpos($key, $this->_config['prefix']) === 0) { + if (str_starts_with($key, $this->_config['prefix'])) { $this->_Memcached->delete($key); } } + $this->_eventClass = CacheClearedEvent::class; + $this->dispatchEvent(CacheClearedEvent::NAME); return true; } @@ -473,16 +532,22 @@ public function clear($check) * @param mixed $value Data to be cached. * @return bool True if the data was successfully cached, false on failure. */ - public function add($key, $value) + public function add(string $key, mixed $value): bool { $duration = $this->_config['duration']; - if ($duration > 30 * DAY) { - $duration = 0; - } - $key = $this->_key($key); - return $this->_Memcached->add($key, $value, $duration); + $this->_eventClass = CacheBeforeAddEvent::class; + $this->dispatchEvent(CacheBeforeAddEvent::NAME, ['key' => $key, 'value' => $value, 'ttl' => $duration]); + + $success = $this->_Memcached->add($key, $value, $duration); + + $this->_eventClass = CacheAfterAddEvent::class; + $this->dispatchEvent(CacheAfterAddEvent::NAME, [ + 'key' => $key, 'value' => $value, 'success' => $success, 'ttl' => $duration, + ]); + + return $success; } /** @@ -490,17 +555,17 @@ public function add($key, $value) * If the group initial value was not found, then it initializes * the group accordingly. * - * @return array + * @return array */ - public function groups() + public function groups(): array { - if (empty($this->_compiledGroupNames)) { + if (!$this->_compiledGroupNames) { foreach ($this->_config['groups'] as $group) { $this->_compiledGroupNames[] = $this->_config['prefix'] . $group; } } - $groups = $this->_Memcached->getMulti($this->_compiledGroupNames); + $groups = $this->_Memcached->getMulti($this->_compiledGroupNames) ?: []; if (count($groups) !== count($this->_config['groups'])) { foreach ($this->_compiledGroupNames as $group) { if (!isset($groups[$group])) { @@ -527,8 +592,12 @@ public function groups() * @param string $group name of the group to be cleared * @return bool success */ - public function clearGroup($group) + public function clearGroup(string $group): bool { - return (bool)$this->_Memcached->increment($this->_config['prefix'] . $group); + $result = (bool)$this->_Memcached->increment($this->_config['prefix'] . $group); + $this->_eventClass = CacheGroupClearEvent::class; + $this->dispatchEvent(CacheGroupClearEvent::NAME, ['group' => $group]); + + return $result; } } diff --git a/src/Cache/Engine/NullEngine.php b/src/Cache/Engine/NullEngine.php index 83ba3cde2b6..4fc878d932d 100644 --- a/src/Cache/Engine/NullEngine.php +++ b/src/Cache/Engine/NullEngine.php @@ -1,4 +1,6 @@ :`, like: + * [ + * ':', + * ':', + * ':', + * ] + * - `failover` Failover mode (distribute,distribute_slaves,error,none). Cluster mode only. + * - `clearUsesFlushDb` Enable clear() and clearBlocking() to use FLUSHDB. This will be + * faster than standard clear()/clearBlocking() but will ignore prefixes and will + * cause dataloss if other applications are sharing a redis database. + * - `allowedClasses` Controls the `allowed_classes` option passed to `unserialize()` + * when reading values back. Set to `false` to disallow all object unserialization + * (safest when the cache only stores scalar/array values), or provide an array of + * fully qualified class names to allow only those classes. Useful for hardening + * against PHP object injection when a cache backend is shared across applications. + * Defaults to `true` (allow all) for backwards compatibility. * - * @var array + * @var array */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ + 'clusterName' => null, 'database' => 0, 'duration' => 3600, 'groups' => [], 'password' => false, 'persistent' => true, 'port' => 6379, + 'tls' => false, 'prefix' => 'cake_', - 'probability' => 100, 'host' => null, 'server' => '127.0.0.1', 'timeout' => 0, 'unix_socket' => false, + 'scanCount' => 10, + 'readTimeout' => 0, + 'nodes' => [], + 'failover' => null, + 'clearUsesFlushDb' => false, + 'allowedClasses' => true, ]; /** @@ -72,13 +120,13 @@ class RedisEngine extends CacheEngine * * Called automatically by the cache frontend * - * @param array $config array of setting for the engine + * @param array $config array of setting for the engine * @return bool True if the engine has been successfully initialized, false if not */ - public function init(array $config = []) + public function init(array $config = []): bool { if (!extension_loaded('redis')) { - return false; + throw new CakeException('The `redis` extension must be enabled to use RedisEngine.'); } if (!empty($config['host'])) { @@ -95,73 +143,274 @@ public function init(array $config = []) * * @return bool True if Redis server was connected */ - protected function _connect() + protected function _connect(): bool { + if (!empty($this->_config['nodes']) || !empty($this->_config['clusterName'])) { + return $this->connectRedisCluster(); + } + + return $this->connectRedis(); + } + + /** + * Connects to a Redis cluster server + * + * @return bool True if Redis server was connected + */ + protected function connectRedisCluster(): bool + { + $connected = false; + + if (empty($this->_config['nodes'])) { + // @codeCoverageIgnoreStart + if (class_exists(Log::class)) { + Log::error('RedisEngine requires one or more nodes in cluster mode'); + } + // @codeCoverageIgnoreEnd + + return false; + } + + // @codeCoverageIgnoreStart + $ssl = []; + if ($this->_config['tls']) { + $map = [ + 'ssl_ca' => 'cafile', + 'ssl_key' => 'local_pk', + 'ssl_cert' => 'local_cert', + 'verify_peer' => 'verify_peer', + 'verify_peer_name' => 'verify_peer_name', + 'allow_self_signed' => 'allow_self_signed', + ]; + + foreach ($map as $configKey => $sslOption) { + if (array_key_exists($configKey, $this->_config)) { + $ssl[$sslOption] = $this->_config[$configKey]; + } + } + } + // @codeCoverageIgnoreEnd + try { - $this->_Redis = new Redis(); + $this->_Redis = new RedisCluster( + $this->_config['clusterName'], + $this->_config['nodes'], + (float)$this->_config['timeout'], + (float)$this->_config['readTimeout'], + $this->_config['persistent'], + $this->_config['password'], + $this->_config['tls'] ? ['ssl' => $ssl] : null, // @codeCoverageIgnore + ); + + $connected = true; + } catch (RedisClusterException $e) { + $connected = false; + + // @codeCoverageIgnoreStart + if (class_exists(Log::class)) { + Log::error('RedisEngine could not connect to the redis cluster. Got error: ' . $e->getMessage()); + } + // @codeCoverageIgnoreEnd + } + + $failover = match ($this->_config['failover']) { + RedisCluster::FAILOVER_DISTRIBUTE, 'distribute' => RedisCluster::FAILOVER_DISTRIBUTE, + RedisCluster::FAILOVER_DISTRIBUTE_SLAVES, 'distribute_slaves' => RedisCluster::FAILOVER_DISTRIBUTE_SLAVES, + RedisCluster::FAILOVER_ERROR, 'error' => RedisCluster::FAILOVER_ERROR, + RedisCluster::FAILOVER_NONE, 'none' => RedisCluster::FAILOVER_NONE, + default => null, + }; + + if ($failover !== null) { + $this->_Redis->setOption(RedisCluster::OPT_SLAVE_FAILOVER, $failover); + } + + return $connected; + } + + /** + * Connects to a Redis server + * + * @return bool True if Redis server was connected + */ + protected function connectRedis(): bool + { + $tls = $this->_config['tls'] === true ? 'tls://' : ''; + + $map = [ + 'ssl_ca' => 'cafile', + 'ssl_key' => 'local_pk', + 'ssl_cert' => 'local_cert', + ]; + + $ssl = []; + foreach ($map as $key => $context) { + if (!empty($this->_config[$key])) { + $ssl[$context] = $this->_config[$key]; + } + } + + try { + $this->_Redis = $this->_createRedisInstance(); if (!empty($this->_config['unix_socket'])) { $return = $this->_Redis->connect($this->_config['unix_socket']); } elseif (empty($this->_config['persistent'])) { - $return = $this->_Redis->connect($this->_config['server'], $this->_config['port'], $this->_config['timeout']); + $return = $this->_connectTransient($tls . $this->_config['server'], $ssl); } else { - $persistentId = $this->_config['port'] . $this->_config['timeout'] . $this->_config['database']; - $return = $this->_Redis->pconnect($this->_config['server'], $this->_config['port'], $this->_config['timeout'], $persistentId); + $return = $this->_connectPersistent($tls . $this->_config['server'], $ssl); } } catch (RedisException $e) { + if (class_exists(Log::class)) { + Log::error('RedisEngine could not connect. Got error: ' . $e->getMessage()); + } + return false; } + if ($return && $this->_config['password']) { $return = $this->_Redis->auth($this->_config['password']); } if ($return) { - $return = $this->_Redis->select($this->_config['database']); + return $this->_Redis->select((int)$this->_config['database']); } return $return; } + /** + * Connects to a Redis server using a new connection. + * + * @param string $server Server to connect to. + * @param array $ssl SSL context options. + * @throws \RedisException + * @return bool True if Redis server was connected + */ + protected function _connectTransient(string $server, array $ssl): bool + { + if ($ssl === []) { + return $this->_Redis->connect( + $server, + (int)$this->_config['port'], + (int)$this->_config['timeout'], + ); + } + + return $this->_Redis->connect( + $server, + (int)$this->_config['port'], + (int)$this->_config['timeout'], + null, + 0, + 0.0, + ['ssl' => $ssl], + ); + } + + /** + * Connects to a Redis server using a persistent connection. + * + * @param string $server Server to connect to. + * @param array $ssl SSL context options. + * @throws \RedisException + * @return bool True if Redis server was connected + */ + protected function _connectPersistent(string $server, array $ssl): bool + { + $persistentId = $this->_config['port'] . $this->_config['timeout'] . $this->_config['database']; + + if ($ssl === []) { + return $this->_Redis->pconnect( + $server, + (int)$this->_config['port'], + (int)$this->_config['timeout'], + $persistentId, + ); + } + + return $this->_Redis->pconnect( + $server, + (int)$this->_config['port'], + (int)$this->_config['timeout'], + $persistentId, + 0, + 0.0, + ['ssl' => $ssl], + ); + } + /** * Write data for key into cache. * * @param string $key Identifier for the data * @param mixed $value Data to be cached + * @param \DateInterval|int|null $ttl Optional. The TTL value of this item. If no value is sent and + * the driver supports TTL then the library may set a default value + * for it or let the driver take care of that. * @return bool True if the data was successfully cached, false on failure */ - public function write($key, $value) + public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool { $key = $this->_key($key); + $value = $this->serialize($value); + $duration = $this->duration($ttl); + $this->_eventClass = CacheBeforeSetEvent::class; + $this->dispatchEvent(CacheBeforeSetEvent::NAME, ['key' => $key, 'value' => $value, 'ttl' => $duration]); - if (!is_int($value)) { - $value = serialize($value); - } - - $duration = $this->_config['duration']; + $this->_eventClass = CacheAfterSetEvent::class; if ($duration === 0) { - return $this->_Redis->set($key, $value); + $success = $this->_Redis->set($key, $value); + $this->dispatchEvent(CacheAfterSetEvent::NAME, [ + 'key' => $key, 'value' => $value, 'success' => $success, 'ttl' => $duration, + ]); + + return $success; } - return $this->_Redis->setex($key, $duration, $value); + $success = $this->_Redis->setEx($key, $duration, $value); + $this->dispatchEvent(CacheAfterSetEvent::NAME, [ + 'key' => $key, 'value' => $value, 'success' => $success, 'ttl' => $duration, + ]); + + return $success; } /** * Read a key from the cache * * @param string $key Identifier for the data - * @return mixed The cached data, or false if the data doesn't exist, has expired, or if there was an error fetching it + * @param mixed $default Default value to return if the key does not exist. + * @return mixed The cached data, or the default if the data doesn't exist, has + * expired, or if there was an error fetching it */ - public function read($key) + public function get(string $key, mixed $default = null): mixed { $key = $this->_key($key); + $this->_eventClass = CacheBeforeGetEvent::class; + $this->dispatchEvent(CacheBeforeGetEvent::NAME, ['key' => $key, 'default' => $default]); $value = $this->_Redis->get($key); - if (preg_match('/^[-]?\d+$/', $value)) { - return (int)$value; - } - if ($value !== false && is_string($value)) { - return unserialize($value); + + $this->_eventClass = CacheAfterGetEvent::class; + if ($value === false) { + $this->dispatchEvent(CacheAfterGetEvent::NAME, ['key' => $key, 'value' => null, 'success' => false]); + + return $default; } - return $value; + $data = $this->unserialize($value); + $this->dispatchEvent(CacheAfterGetEvent::NAME, ['key' => $key, 'value' => $value, 'success' => true]); + + return $data; + } + + /** + * @inheritDoc + */ + public function has(string $key): bool + { + $res = $this->_Redis->exists($this->_key($key)); + + return is_int($res) ? $res > 0 : $res === true; } /** @@ -169,16 +418,24 @@ public function read($key) * * @param string $key Identifier for the data * @param int $offset How much to increment - * @return bool|int New incremented value, false otherwise + * @return int|false New incremented value, false otherwise */ - public function increment($key, $offset = 1) + public function increment(string $key, int $offset = 1): int|false { $duration = $this->_config['duration']; $key = $this->_key($key); - $value = (int)$this->_Redis->incrBy($key, $offset); + $this->_eventClass = CacheBeforeIncrementEvent::class; + $this->dispatchEvent(CacheBeforeIncrementEvent::NAME, ['key' => $key, 'offset' => $offset]); + + $value = $this->_Redis->incrBy($key, $offset); + + $this->_eventClass = CacheAfterIncrementEvent::class; + $this->dispatchEvent(CacheAfterIncrementEvent::NAME, [ + 'key' => $key, 'offset' => $offset, 'success' => $value !== false, 'value' => $value, + ]); if ($duration > 0) { - $this->_Redis->setTimeout($key, $duration); + $this->_Redis->expire($key, $duration); } return $value; @@ -189,16 +446,23 @@ public function increment($key, $offset = 1) * * @param string $key Identifier for the data * @param int $offset How much to subtract - * @return bool|int New decremented value, false otherwise + * @return int|false New decremented value, false otherwise */ - public function decrement($key, $offset = 1) + public function decrement(string $key, int $offset = 1): int|false { $duration = $this->_config['duration']; $key = $this->_key($key); + $this->_eventClass = CacheBeforeDecrementEvent::class; + $this->dispatchEvent(CacheBeforeDecrementEvent::NAME, ['key' => $key, 'offset' => $offset]); + + $value = $this->_Redis->decrBy($key, $offset); - $value = (int)$this->_Redis->decrBy($key, $offset); + $this->_eventClass = CacheAfterDecrementEvent::class; + $this->dispatchEvent(CacheAfterDecrementEvent::NAME, [ + 'key' => $key, 'offset' => $offset, 'success' => $value !== false, 'value' => $value, + ]); if ($duration > 0) { - $this->_Redis->setTimeout($key, $duration); + $this->_Redis->expire($key, $duration); } return $value; @@ -210,32 +474,99 @@ public function decrement($key, $offset = 1) * @param string $key Identifier for the data * @return bool True if the value was successfully deleted, false if it didn't exist or couldn't be removed */ - public function delete($key) + public function delete(string $key): bool { $key = $this->_key($key); + $this->_eventClass = CacheBeforeDeleteEvent::class; + $this->dispatchEvent(CacheBeforeDeleteEvent::NAME, ['key' => $key]); - return $this->_Redis->delete($key) > 0; + $success = (int)$this->_Redis->del($key) > 0; + + $this->_eventClass = CacheAfterDeleteEvent::class; + $this->dispatchEvent(CacheAfterDeleteEvent::NAME, ['key' => $key, 'success' => $success]); + + return $success; + } + + /** + * Delete a key from the cache asynchronously + * + * Just unlink a key from the cache. The actual removal will happen later asynchronously. + * + * @param string $key Identifier for the data + * @return bool True if the value was successfully deleted, false if it didn't exist or couldn't be removed + */ + public function deleteAsync(string $key): bool + { + $key = $this->_key($key); + $this->_eventClass = CacheBeforeDeleteEvent::class; + $this->dispatchEvent(CacheBeforeDeleteEvent::NAME, ['key' => $key]); + + $result = $this->_Redis->unlink($key); + $success = is_int($result) && $result > 0; + + $this->_eventClass = CacheAfterDeleteEvent::class; + $this->dispatchEvent(CacheAfterDeleteEvent::NAME, ['key' => $key, 'success' => $success]); + + return $success; } /** * Delete all keys from the cache * - * @param bool $check If true will check expiration, otherwise delete all. * @return bool True if the cache was successfully cleared, false otherwise */ - public function clear($check) + public function clear(): bool { - if ($check) { + if ($this->getConfig('clearUsesFlushDb')) { + $this->flushDB(true); + $this->_eventClass = CacheClearedEvent::class; + $this->dispatchEvent(CacheClearedEvent::NAME); + return true; } - $keys = $this->_Redis->getKeys($this->_config['prefix'] . '*'); - $result = []; - foreach ($keys as $key) { - $result[] = $this->_Redis->delete($key) > 0; + $isAllDeleted = true; + $pattern = $this->_config['prefix'] . '*'; + + foreach ($this->scanKeys($pattern) as $key) { + $result = $this->_Redis->unlink($key); + $isDeleted = is_int($result) && $result > 0; + $isAllDeleted = $isAllDeleted && $isDeleted; + } + $this->_eventClass = CacheClearedEvent::class; + $this->dispatchEvent(CacheClearedEvent::NAME); + + return $isAllDeleted; + } + + /** + * Delete all keys from the cache by a blocking operation + * + * @return bool True if the cache was successfully cleared, false otherwise + */ + public function clearBlocking(): bool + { + if ($this->getConfig('clearUsesFlushDb')) { + $this->flushDB(false); + $this->_eventClass = CacheClearedEvent::class; + $this->dispatchEvent(CacheClearedEvent::NAME); + + return true; + } + + $isAllDeleted = true; + $pattern = $this->_config['prefix'] . '*'; + + foreach ($this->scanKeys($pattern) as $key) { + // Blocking delete + $isDeleted = ((int)$this->_Redis->del($key) > 0); + $isAllDeleted = $isAllDeleted && $isDeleted; } + $this->_eventClass = CacheClearedEvent::class; + $this->dispatchEvent(CacheClearedEvent::NAME); - return !in_array(false, $result); + return $isAllDeleted; } /** @@ -245,21 +576,31 @@ public function clear($check) * @param string $key Identifier for the data. * @param mixed $value Data to be cached. * @return bool True if the data was successfully cached, false on failure. - * @link https://github.com/phpredis/phpredis#setnx + * @link https://github.com/phpredis/phpredis#set */ - public function add($key, $value) + public function add(string $key, mixed $value): bool { $duration = $this->_config['duration']; $key = $this->_key($key); + $origValue = $value; + $value = $this->serialize($value); - if (!is_int($value)) { - $value = serialize($value); - } + $this->_eventClass = CacheBeforeAddEvent::class; + $this->dispatchEvent(CacheBeforeAddEvent::NAME, [ + 'key' => $key, 'value' => $origValue, 'ttl' => $duration, + ]); - // setnx() doesn't have an expiry option, so follow up with an expiry - if ($this->_Redis->setnx($key, $value)) { - return $this->_Redis->setTimeout($key, $duration); + $this->_eventClass = CacheAfterAddEvent::class; + if ($this->_Redis->set($key, $value, ['nx', 'ex' => $duration])) { + $this->dispatchEvent(CacheAfterAddEvent::NAME, [ + 'key' => $key, 'value' => $origValue, 'success' => true, 'ttl' => $duration, + ]); + + return true; } + $this->dispatchEvent(CacheAfterAddEvent::NAME, [ + 'key' => $key, 'value' => $origValue, 'success' => false, 'ttl' => $duration, + ]); return false; } @@ -269,15 +610,15 @@ public function add($key, $value) * If the group initial value was not found, then it initializes * the group accordingly. * - * @return array + * @return array */ - public function groups() + public function groups(): array { $result = []; foreach ($this->_config['groups'] as $group) { $value = $this->_Redis->get($this->_config['prefix'] . $group); if (!$value) { - $value = 1; + $value = $this->serialize(1); $this->_Redis->set($this->_config['prefix'] . $group, $value); } $result[] = $group . $value; @@ -293,9 +634,120 @@ public function groups() * @param string $group name of the group to be cleared * @return bool success */ - public function clearGroup($group) + public function clearGroup(string $group): bool + { + $success = (bool)$this->_Redis->incr($this->_config['prefix'] . $group); + $this->_eventClass = CacheGroupClearEvent::class; + $this->dispatchEvent(CacheGroupClearEvent::NAME, ['group' => $group]); + + return $success; + } + + /** + * Serialize value for saving to Redis. + * + * This is needed instead of using Redis' in built serialization feature + * as it creates problems incrementing/decrementing initially set integer value. + * + * @param mixed $value Value to serialize. + * @return string + * @link https://github.com/phpredis/phpredis/issues/81 + */ + protected function serialize(mixed $value): string + { + if (is_int($value)) { + return (string)$value; + } + + return serialize($value); + } + + /** + * Unserialize string value fetched from Redis. + * + * @param string $value Value to unserialize. + * @return mixed + */ + protected function unserialize(string $value): mixed + { + if (preg_match('/^[-]?\d+$/', $value)) { + return (int)$value; + } + + $allowedClasses = $this->getConfig('allowedClasses'); + if ($allowedClasses !== true) { + return unserialize($value, ['allowed_classes' => $allowedClasses]); + } + + return unserialize($value); + } + + /** + * Create new Redis instance. + * + * @return \Redis + */ + protected function _createRedisInstance(): Redis + { + return new Redis(); + } + + /** + * Unifies Redis and RedisCluster scan() calls and simplifies its use. + * + * @param string $pattern Pattern to scan + * @return \Generator + */ + private function scanKeys(string $pattern): Generator + { + $this->_Redis->setOption(Redis::OPT_SCAN, (string)Redis::SCAN_RETRY); + + if ($this->_Redis instanceof RedisCluster) { + foreach ($this->_Redis->_masters() as $node) { + $iterator = null; + while (true) { + $keys = $this->_Redis->scan($iterator, $node, $pattern, (int)$this->_config['scanCount']); + if ($keys === false) { + break; + } + + if (is_array($keys)) { + foreach ($keys as $key) { + yield $key; + } + } + } + } + } else { + $iterator = null; + while (true) { + $keys = $this->_Redis->scan($iterator, $pattern, (int)$this->_config['scanCount']); + if ($keys === false) { + break; + } + + foreach ($keys as $key) { + yield $key; + } + } + } + } + + /** + * Flushes DB + * + * @param bool $async Whether to use asynchronous mode + * @return void + */ + private function flushDB(bool $async): void { - return (bool)$this->_Redis->incr($this->_config['prefix'] . $group); + if ($this->_Redis instanceof RedisCluster) { + foreach ($this->_Redis->_masters() as $node) { + $this->_Redis->flushDB($node, $async); + } + } else { + $this->_Redis->flushDB($async); + } } /** @@ -303,7 +755,7 @@ public function clearGroup($group) */ public function __destruct() { - if (empty($this->_config['persistent']) && $this->_Redis instanceof Redis) { + if (isset($this->_Redis) && !($this->_config['persistent'] ?? true)) { $this->_Redis->close(); } } diff --git a/src/Cache/Engine/WincacheEngine.php b/src/Cache/Engine/WincacheEngine.php deleted file mode 100644 index afd70b02f5c..00000000000 --- a/src/Cache/Engine/WincacheEngine.php +++ /dev/null @@ -1,212 +0,0 @@ -_key($key); - - $duration = $this->_config['duration']; - $expires = time() + $duration; - - $data = [ - $key . '_expires' => $expires, - $key => $value - ]; - $result = wincache_ucache_set($data, null, $duration); - - return empty($result); - } - - /** - * Read a key from the cache - * - * @param string $key Identifier for the data - * @return mixed The cached data, or false if the data doesn't exist, - * has expired, or if there was an error fetching it - */ - public function read($key) - { - $key = $this->_key($key); - - $time = time(); - $cachetime = (int)wincache_ucache_get($key . '_expires'); - if ($cachetime < $time || ($time + $this->_config['duration']) < $cachetime) { - return false; - } - - return wincache_ucache_get($key); - } - - /** - * Increments the value of an integer cached key - * - * @param string $key Identifier for the data - * @param int $offset How much to increment - * @return bool|int New incremented value, false otherwise - */ - public function increment($key, $offset = 1) - { - $key = $this->_key($key); - - return wincache_ucache_inc($key, $offset); - } - - /** - * Decrements the value of an integer cached key - * - * @param string $key Identifier for the data - * @param int $offset How much to subtract - * @return bool|int New decremented value, false otherwise - */ - public function decrement($key, $offset = 1) - { - $key = $this->_key($key); - - return wincache_ucache_dec($key, $offset); - } - - /** - * Delete a key from the cache - * - * @param string $key Identifier for the data - * @return bool True if the value was successfully deleted, false if it didn't exist or couldn't be removed - */ - public function delete($key) - { - $key = $this->_key($key); - - return wincache_ucache_delete($key); - } - - /** - * Delete all keys from the cache. This will clear every - * item in the cache matching the cache config prefix. - * - * @param bool $check If true, nothing will be cleared, as entries will - * naturally expire in wincache.. - * @return bool True Returns true. - */ - public function clear($check) - { - if ($check) { - return true; - } - $info = wincache_ucache_info(); - $cacheKeys = $info['ucache_entries']; - unset($info); - foreach ($cacheKeys as $key) { - if (strpos($key['key_name'], $this->_config['prefix']) === 0) { - wincache_ucache_delete($key['key_name']); - } - } - - return true; - } - - /** - * Returns the `group value` for each of the configured groups - * If the group initial value was not found, then it initializes - * the group accordingly. - * - * @return array - */ - public function groups() - { - if (empty($this->_compiledGroupNames)) { - foreach ($this->_config['groups'] as $group) { - $this->_compiledGroupNames[] = $this->_config['prefix'] . $group; - } - } - - $groups = wincache_ucache_get($this->_compiledGroupNames); - if (count($groups) !== count($this->_config['groups'])) { - foreach ($this->_compiledGroupNames as $group) { - if (!isset($groups[$group])) { - wincache_ucache_set($group, 1); - $groups[$group] = 1; - } - } - ksort($groups); - } - - $result = []; - $groups = array_values($groups); - foreach ($this->_config['groups'] as $i => $group) { - $result[] = $group . $groups[$i]; - } - - return $result; - } - - /** - * Increments the group value to simulate deletion of all keys under a group - * old values will remain in storage until they expire. - * - * @param string $group The group to clear. - * @return bool success - */ - public function clearGroup($group) - { - $success = false; - wincache_ucache_inc($this->_config['prefix'] . $group, 1, $success); - - return $success; - } -} diff --git a/src/Cache/Engine/XcacheEngine.php b/src/Cache/Engine/XcacheEngine.php deleted file mode 100644 index a6132ba2429..00000000000 --- a/src/Cache/Engine/XcacheEngine.php +++ /dev/null @@ -1,255 +0,0 @@ - 3600, - 'groups' => [], - 'prefix' => null, - 'probability' => 100, - 'PHP_AUTH_USER' => 'user', - 'PHP_AUTH_PW' => 'password' - ]; - - /** - * Initialize the Cache Engine - * - * Called automatically by the cache frontend - * - * @param array $config array of setting for the engine - * @return bool True if the engine has been successfully initialized, false if not - */ - public function init(array $config = []) - { - if (!extension_loaded('xcache')) { - return false; - } - - parent::init($config); - - return true; - } - - /** - * Write data for key into cache - * - * @param string $key Identifier for the data - * @param mixed $value Data to be cached - * @return bool True if the data was successfully cached, false on failure - */ - public function write($key, $value) - { - $key = $this->_key($key); - - if (!is_numeric($value)) { - $value = serialize($value); - } - - $duration = $this->_config['duration']; - $expires = time() + $duration; - xcache_set($key . '_expires', $expires, $duration); - - return xcache_set($key, $value, $duration); - } - - /** - * Read a key from the cache - * - * @param string $key Identifier for the data - * @return mixed The cached data, or false if the data doesn't exist, - * has expired, or if there was an error fetching it - */ - public function read($key) - { - $key = $this->_key($key); - - if (xcache_isset($key)) { - $time = time(); - $cachetime = (int)xcache_get($key . '_expires'); - if ($cachetime < $time || ($time + $this->_config['duration']) < $cachetime) { - return false; - } - - $value = xcache_get($key); - if (is_string($value) && !is_numeric($value)) { - $value = unserialize($value); - } - - return $value; - } - - return false; - } - - /** - * Increments the value of an integer cached key - * If the cache key is not an integer it will be treated as 0 - * - * @param string $key Identifier for the data - * @param int $offset How much to increment - * @return bool|int New incremented value, false otherwise - */ - public function increment($key, $offset = 1) - { - $key = $this->_key($key); - - return xcache_inc($key, $offset); - } - - /** - * Decrements the value of an integer cached key. - * If the cache key is not an integer it will be treated as 0 - * - * @param string $key Identifier for the data - * @param int $offset How much to subtract - * @return bool|int New decremented value, false otherwise - */ - public function decrement($key, $offset = 1) - { - $key = $this->_key($key); - - return xcache_dec($key, $offset); - } - - /** - * Delete a key from the cache - * - * @param string $key Identifier for the data - * @return bool True if the value was successfully deleted, false if it didn't exist or couldn't be removed - */ - public function delete($key) - { - $key = $this->_key($key); - - return xcache_unset($key); - } - - /** - * Delete all keys from the cache - * - * @param bool $check If true no deletes will occur and instead CakePHP will rely - * on key TTL values. - * Unused for Xcache engine. - * @return bool True if the cache was successfully cleared, false otherwise - */ - public function clear($check) - { - $this->_auth(); - $max = xcache_count(XC_TYPE_VAR); - for ($i = 0; $i < $max; $i++) { - xcache_clear_cache(XC_TYPE_VAR, $i); - } - $this->_auth(true); - - return true; - } - - /** - * Returns the `group value` for each of the configured groups - * If the group initial value was not found, then it initializes - * the group accordingly. - * - * @return array - */ - public function groups() - { - $result = []; - foreach ($this->_config['groups'] as $group) { - $value = xcache_get($this->_config['prefix'] . $group); - if (!$value) { - $value = 1; - xcache_set($this->_config['prefix'] . $group, $value, 0); - } - $result[] = $group . $value; - } - - return $result; - } - - /** - * Increments the group value to simulate deletion of all keys under a group - * old values will remain in storage until they expire. - * - * @param string $group The group to clear. - * @return bool success - */ - public function clearGroup($group) - { - return (bool)xcache_inc($this->_config['prefix'] . $group, 1); - } - - /** - * Populates and reverses $_SERVER authentication values - * Makes necessary changes (and reverting them back) in $_SERVER - * - * This has to be done because xcache_clear_cache() needs to pass Basic Http Auth - * (see xcache.admin configuration config) - * - * @param bool $reverse Revert changes - * @return void - */ - protected function _auth($reverse = false) - { - static $backup = []; - $keys = ['PHP_AUTH_USER' => 'user', 'PHP_AUTH_PW' => 'password']; - foreach ($keys as $key => $value) { - if ($reverse) { - if (isset($backup[$key])) { - $_SERVER[$key] = $backup[$key]; - unset($backup[$key]); - } else { - unset($_SERVER[$key]); - } - } else { - $value = env($key); - if (!empty($value)) { - $backup[$key] = $value; - } - if (!empty($this->_config[$value])) { - $_SERVER[$key] = $this->_config[$value]; - } elseif (!empty($this->_config[$key])) { - $_SERVER[$key] = $this->_config[$key]; - } else { - $_SERVER[$key] = $value; - } - } - } - } -} diff --git a/src/Cache/Event/CacheAfterAddEvent.php b/src/Cache/Event/CacheAfterAddEvent.php new file mode 100644 index 00000000000..d920b0bacd5 --- /dev/null +++ b/src/Cache/Event/CacheAfterAddEvent.php @@ -0,0 +1,119 @@ + + */ +class CacheAfterAddEvent extends Event +{ + public const NAME = 'Cache.afterAdd'; + + protected string $key; + + protected mixed $value = null; + + protected DateInterval|int|null $ttl = null; + + /** + * Constructor + * + * @param string $name Name of the event + * @param TEngine $subject The Cache engine instance this event applies to. + * @param array $data Any value you wish to be transported with this event to it can be read by listeners. + */ + public function __construct(string $name, CacheEngine $subject, array $data = []) + { + if (isset($data['key'])) { + $this->key = $data['key']; + unset($data['key']); + } + if (isset($data['value'])) { + $this->value = $data['value']; + unset($data['value']); + } + if (isset($data['success'])) { + $this->result = $data['success']; + unset($data['success']); + } + if (isset($data['ttl'])) { + $this->ttl = $data['ttl']; + unset($data['ttl']); + } + + parent::__construct($name, $subject, $data); + } + + /** + * The result value of the event listeners + * + * @return bool|null + */ + public function getResult(): ?bool + { + return $this->result; + } + + /** + * Listeners can attach a result value to the event. + * + * @param mixed $value The value to set. + * @return $this + */ + public function setResult(mixed $value = null) + { + if ($value !== null && !is_bool($value)) { + throw new InvalidArgumentException( + 'The result for CacheEngine events must be a `bool`.', + ); + } + + return parent::setResult($value); + } + + /** + * @return string + */ + public function getKey(): string + { + return $this->key; + } + + /** + * @return mixed + */ + public function getValue(): mixed + { + return $this->value; + } + + /** + * @return \DateInterval|int|null + */ + public function getTtl(): DateInterval|int|null + { + return $this->ttl; + } +} diff --git a/src/Cache/Event/CacheAfterDecrementEvent.php b/src/Cache/Event/CacheAfterDecrementEvent.php new file mode 100644 index 00000000000..fd14dcce4c5 --- /dev/null +++ b/src/Cache/Event/CacheAfterDecrementEvent.php @@ -0,0 +1,124 @@ + + */ +class CacheAfterDecrementEvent extends Event +{ + public const NAME = 'Cache.afterDecrement'; + + protected string $key; + + protected int $offset; + + protected mixed $value; + + /** + * Constructor + * + * @param string $name Name of the event + * @param TEngine $subject The Cache engine instance this event applies to. + * @param array $data Any value you wish to be transported with this event to it can be read by listeners. + */ + public function __construct(string $name, CacheEngine $subject, array $data = []) + { + if (isset($data['key'])) { + $this->key = $data['key']; + unset($data['key']); + } + if (isset($data['offset'])) { + $this->offset = $data['offset']; + unset($data['offset']); + } + if (isset($data['value'])) { + $this->value = $data['value']; + unset($data['value']); + } + if (isset($data['success'])) { + $this->result = $data['success']; + unset($data['success']); + } + + parent::__construct($name, $subject, $data); + } + + /** + * The result value of the event listeners + * + * @return bool|null + */ + public function getResult(): ?bool + { + return $this->result; + } + + /** + * Listeners can attach a result value to the event. + * + * @param mixed $value The value to set. + * @return $this + */ + public function setResult(mixed $value = null) + { + if ($value !== null && !is_bool($value)) { + throw new InvalidArgumentException( + 'The result for CacheEngine events must be a `bool`.', + ); + } + + return parent::setResult($value); + } + + /** + * Get the cache key. + * + * @return string + */ + public function getKey(): string + { + return $this->key; + } + + /** + * Get the decrement offset. + * + * @return int + */ + public function getOffset(): int + { + return $this->offset; + } + + /** + * Get the new value after decrement. + * + * @return mixed + */ + public function getValue(): mixed + { + return $this->value; + } +} diff --git a/src/Cache/Event/CacheAfterDeleteEvent.php b/src/Cache/Event/CacheAfterDeleteEvent.php new file mode 100644 index 00000000000..05cf27a44d2 --- /dev/null +++ b/src/Cache/Event/CacheAfterDeleteEvent.php @@ -0,0 +1,90 @@ + + */ +class CacheAfterDeleteEvent extends Event +{ + public const NAME = 'Cache.afterDelete'; + + protected string $key; + + /** + * Constructor + * + * @param string $name Name of the event + * @param TEngine $subject The Cache engine instance this event applies to. + * @param array $data Any value you wish to be transported with this event to it can be read by listeners. + */ + public function __construct(string $name, CacheEngine $subject, array $data = []) + { + if (isset($data['key'])) { + $this->key = $data['key']; + unset($data['key']); + } + if (isset($data['success'])) { + $this->result = $data['success']; + unset($data['success']); + } + + parent::__construct($name, $subject, $data); + } + + /** + * The result value of the event listeners + * + * @return bool|null + */ + public function getResult(): ?bool + { + return $this->result; + } + + /** + * Listeners can attach a result value to the event. + * + * @param mixed $value The value to set. + * @return $this + */ + public function setResult(mixed $value = null) + { + if ($value !== null && !is_bool($value)) { + throw new InvalidArgumentException( + 'The result for CacheEngine events must be a `bool`.', + ); + } + + return parent::setResult($value); + } + + /** + * @return string + */ + public function getKey(): string + { + return $this->key; + } +} diff --git a/src/Cache/Event/CacheAfterGetEvent.php b/src/Cache/Event/CacheAfterGetEvent.php new file mode 100644 index 00000000000..6dfe3da5a14 --- /dev/null +++ b/src/Cache/Event/CacheAfterGetEvent.php @@ -0,0 +1,104 @@ + + */ +class CacheAfterGetEvent extends Event +{ + public const NAME = 'Cache.afterGet'; + + protected string $key; + + protected mixed $value = null; + + /** + * Constructor + * + * @param string $name Name of the event + * @param TEngine $subject The Cache engine instance this event applies to. + * @param array $data Any value you wish to be transported with this event to it can be read by listeners. + */ + public function __construct(string $name, CacheEngine $subject, array $data = []) + { + if (isset($data['key'])) { + $this->key = $data['key']; + unset($data['key']); + } + if (isset($data['value'])) { + $this->value = $data['value']; + unset($data['value']); + } + if (isset($data['success'])) { + $this->result = $data['success']; + unset($data['success']); + } + + parent::__construct($name, $subject, $data); + } + + /** + * The result value of the event listeners + * + * @return bool|null + */ + public function getResult(): ?bool + { + return $this->result; + } + + /** + * Listeners can attach a result value to the event. + * + * @param mixed $value The value to set. + * @return $this + */ + public function setResult(mixed $value = null) + { + if ($value !== null && !is_bool($value)) { + throw new InvalidArgumentException( + 'The result for CacheEngine events must be a `bool`.', + ); + } + + return parent::setResult($value); + } + + /** + * @return string + */ + public function getKey(): string + { + return $this->key; + } + + /** + * @return mixed + */ + public function getValue(): mixed + { + return $this->value; + } +} diff --git a/src/Cache/Event/CacheAfterIncrementEvent.php b/src/Cache/Event/CacheAfterIncrementEvent.php new file mode 100644 index 00000000000..fb7b36a8a51 --- /dev/null +++ b/src/Cache/Event/CacheAfterIncrementEvent.php @@ -0,0 +1,124 @@ + + */ +class CacheAfterIncrementEvent extends Event +{ + public const NAME = 'Cache.afterIncrement'; + + protected string $key; + + protected int $offset; + + protected mixed $value; + + /** + * Constructor + * + * @param string $name Name of the event + * @param TEngine $subject The Cache engine instance this event applies to. + * @param array $data Any value you wish to be transported with this event to it can be read by listeners. + */ + public function __construct(string $name, CacheEngine $subject, array $data = []) + { + if (isset($data['key'])) { + $this->key = $data['key']; + unset($data['key']); + } + if (isset($data['offset'])) { + $this->offset = $data['offset']; + unset($data['offset']); + } + if (isset($data['value'])) { + $this->value = $data['value']; + unset($data['value']); + } + if (isset($data['success'])) { + $this->result = $data['success']; + unset($data['success']); + } + + parent::__construct($name, $subject, $data); + } + + /** + * The result value of the event listeners + * + * @return bool|null + */ + public function getResult(): ?bool + { + return $this->result; + } + + /** + * Listeners can attach a result value to the event. + * + * @param mixed $value The value to set. + * @return $this + */ + public function setResult(mixed $value = null) + { + if ($value !== null && !is_bool($value)) { + throw new InvalidArgumentException( + 'The result for CacheEngine events must be a `bool`.', + ); + } + + return parent::setResult($value); + } + + /** + * Get the cache key. + * + * @return string + */ + public function getKey(): string + { + return $this->key; + } + + /** + * Get the increment offset. + * + * @return int + */ + public function getOffset(): int + { + return $this->offset; + } + + /** + * Get the new value after increment. + * + * @return mixed + */ + public function getValue(): mixed + { + return $this->value; + } +} diff --git a/src/Cache/Event/CacheAfterSetEvent.php b/src/Cache/Event/CacheAfterSetEvent.php new file mode 100644 index 00000000000..0e7b73653e3 --- /dev/null +++ b/src/Cache/Event/CacheAfterSetEvent.php @@ -0,0 +1,119 @@ + + */ +class CacheAfterSetEvent extends Event +{ + public const NAME = 'Cache.afterSet'; + + protected string $key; + + protected mixed $value = null; + + protected DateInterval|int|null $ttl = null; + + /** + * Constructor + * + * @param string $name Name of the event + * @param TEngine $subject The Cache engine instance this event applies to. + * @param array $data Any value you wish to be transported with this event to it can be read by listeners. + */ + public function __construct(string $name, CacheEngine $subject, array $data = []) + { + if (isset($data['key'])) { + $this->key = $data['key']; + unset($data['key']); + } + if (isset($data['value'])) { + $this->value = $data['value']; + unset($data['value']); + } + if (isset($data['success'])) { + $this->result = $data['success']; + unset($data['success']); + } + if (isset($data['ttl'])) { + $this->ttl = $data['ttl']; + unset($data['ttl']); + } + + parent::__construct($name, $subject, $data); + } + + /** + * The result value of the event listeners + * + * @return bool|null + */ + public function getResult(): ?bool + { + return $this->result; + } + + /** + * Listeners can attach a result value to the event. + * + * @param mixed $value The value to set. + * @return $this + */ + public function setResult(mixed $value = null) + { + if ($value !== null && !is_bool($value)) { + throw new InvalidArgumentException( + 'The result for CacheEngine events must be a `bool`.', + ); + } + + return parent::setResult($value); + } + + /** + * @return string + */ + public function getKey(): string + { + return $this->key; + } + + /** + * @return mixed + */ + public function getValue(): mixed + { + return $this->value; + } + + /** + * @return \DateInterval|int|null + */ + public function getTtl(): DateInterval|int|null + { + return $this->ttl; + } +} diff --git a/src/Cache/Event/CacheBeforeAddEvent.php b/src/Cache/Event/CacheBeforeAddEvent.php new file mode 100644 index 00000000000..6762045e128 --- /dev/null +++ b/src/Cache/Event/CacheBeforeAddEvent.php @@ -0,0 +1,87 @@ + + */ +class CacheBeforeAddEvent extends Event +{ + public const NAME = 'Cache.beforeAdd'; + + protected string $key; + + protected mixed $value = null; + + protected DateInterval|int|null $ttl = null; + + /** + * Constructor + * + * @param string $name Name of the event + * @param TEngine $subject The Cache engine instance this event applies to. + * @param array $data Any value you wish to be transported with this event to it can be read by listeners. + */ + public function __construct(string $name, CacheEngine $subject, array $data = []) + { + if (isset($data['key'])) { + $this->key = $data['key']; + unset($data['key']); + } + if (isset($data['value'])) { + $this->value = $data['value']; + unset($data['value']); + } + if (isset($data['ttl'])) { + $this->ttl = $data['ttl']; + unset($data['ttl']); + } + + parent::__construct($name, $subject, $data); + } + + /** + * @return string + */ + public function getKey(): string + { + return $this->key; + } + + /** + * @return mixed + */ + public function getValue(): mixed + { + return $this->value; + } + + /** + * @return \DateInterval|int|null + */ + public function getTtl(): DateInterval|int|null + { + return $this->ttl; + } +} diff --git a/src/Cache/Event/CacheBeforeDecrementEvent.php b/src/Cache/Event/CacheBeforeDecrementEvent.php new file mode 100644 index 00000000000..2857b76e336 --- /dev/null +++ b/src/Cache/Event/CacheBeforeDecrementEvent.php @@ -0,0 +1,76 @@ + + */ +class CacheBeforeDecrementEvent extends Event +{ + public const NAME = 'Cache.beforeDecrement'; + + protected string $key; + + protected int $offset; + + /** + * Constructor + * + * @param string $name Name of the event + * @param TEngine $subject The Cache engine instance this event applies to. + * @param array $data Any value you wish to be transported with this event to it can be read by listeners. + */ + public function __construct(string $name, CacheEngine $subject, array $data = []) + { + if (isset($data['key'])) { + $this->key = $data['key']; + unset($data['key']); + } + if (isset($data['offset'])) { + $this->offset = $data['offset']; + unset($data['offset']); + } + + parent::__construct($name, $subject, $data); + } + + /** + * Get the cache key. + * + * @return string + */ + public function getKey(): string + { + return $this->key; + } + + /** + * Get the decrement offset. + * + * @return int + */ + public function getOffset(): int + { + return $this->offset; + } +} diff --git a/src/Cache/Event/CacheBeforeDeleteEvent.php b/src/Cache/Event/CacheBeforeDeleteEvent.php new file mode 100644 index 00000000000..0998d9199dc --- /dev/null +++ b/src/Cache/Event/CacheBeforeDeleteEvent.php @@ -0,0 +1,58 @@ + + */ +class CacheBeforeDeleteEvent extends Event +{ + public const NAME = 'Cache.beforeDelete'; + + protected string $key; + + /** + * Constructor + * + * @param string $name Name of the event + * @param TEngine $subject The Cache engine instance this event applies to. + * @param array $data Any value you wish to be transported with this event to it can be read by listeners. + */ + public function __construct(string $name, CacheEngine $subject, array $data = []) + { + if (isset($data['key'])) { + $this->key = $data['key']; + unset($data['key']); + } + + parent::__construct($name, $subject, $data); + } + + /** + * @return string + */ + public function getKey(): string + { + return $this->key; + } +} diff --git a/src/Cache/Event/CacheBeforeGetEvent.php b/src/Cache/Event/CacheBeforeGetEvent.php new file mode 100644 index 00000000000..fa8cb693957 --- /dev/null +++ b/src/Cache/Event/CacheBeforeGetEvent.php @@ -0,0 +1,72 @@ + + */ +class CacheBeforeGetEvent extends Event +{ + public const NAME = 'Cache.beforeGet'; + + protected string $key; + + protected mixed $default = null; + + /** + * Constructor + * + * @param string $name Name of the event + * @param TEngine $subject The Cache engine instance this event applies to. + * @param array $data Any value you wish to be transported with this event to it can be read by listeners. + */ + public function __construct(string $name, CacheEngine $subject, array $data = []) + { + if (isset($data['key'])) { + $this->key = $data['key']; + unset($data['key']); + } + if (isset($data['default'])) { + $this->default = $data['default']; + unset($data['default']); + } + + parent::__construct($name, $subject, $data); + } + + /** + * @return string + */ + public function getKey(): string + { + return $this->key; + } + + /** + * @return mixed + */ + public function getDefault(): mixed + { + return $this->default; + } +} diff --git a/src/Cache/Event/CacheBeforeIncrementEvent.php b/src/Cache/Event/CacheBeforeIncrementEvent.php new file mode 100644 index 00000000000..e5c2ef4818d --- /dev/null +++ b/src/Cache/Event/CacheBeforeIncrementEvent.php @@ -0,0 +1,76 @@ + + */ +class CacheBeforeIncrementEvent extends Event +{ + public const NAME = 'Cache.beforeIncrement'; + + protected string $key; + + protected int $offset; + + /** + * Constructor + * + * @param string $name Name of the event + * @param TEngine $subject The Cache engine instance this event applies to. + * @param array $data Any value you wish to be transported with this event to it can be read by listeners. + */ + public function __construct(string $name, CacheEngine $subject, array $data = []) + { + if (isset($data['key'])) { + $this->key = $data['key']; + unset($data['key']); + } + if (isset($data['offset'])) { + $this->offset = $data['offset']; + unset($data['offset']); + } + + parent::__construct($name, $subject, $data); + } + + /** + * Get the cache key. + * + * @return string + */ + public function getKey(): string + { + return $this->key; + } + + /** + * Get the increment offset. + * + * @return int + */ + public function getOffset(): int + { + return $this->offset; + } +} diff --git a/src/Cache/Event/CacheBeforeSetEvent.php b/src/Cache/Event/CacheBeforeSetEvent.php new file mode 100644 index 00000000000..c3f50391bba --- /dev/null +++ b/src/Cache/Event/CacheBeforeSetEvent.php @@ -0,0 +1,87 @@ + + */ +class CacheBeforeSetEvent extends Event +{ + public const NAME = 'Cache.beforeSet'; + + protected string $key; + + protected mixed $value = null; + + protected DateInterval|int|null $ttl = null; + + /** + * Constructor + * + * @param string $name Name of the event + * @param TEngine $subject The Cache engine instance this event applies to. + * @param array $data Any value you wish to be transported with this event to it can be read by listeners. + */ + public function __construct(string $name, CacheEngine $subject, array $data = []) + { + if (isset($data['key'])) { + $this->key = $data['key']; + unset($data['key']); + } + if (isset($data['value'])) { + $this->value = $data['value']; + unset($data['value']); + } + if (isset($data['ttl'])) { + $this->ttl = $data['ttl']; + unset($data['ttl']); + } + + parent::__construct($name, $subject, $data); + } + + /** + * @return string + */ + public function getKey(): string + { + return $this->key; + } + + /** + * @return mixed + */ + public function getValue(): mixed + { + return $this->value; + } + + /** + * @return \DateInterval|int|null + */ + public function getTtl(): DateInterval|int|null + { + return $this->ttl; + } +} diff --git a/src/Cache/Event/CacheClearedEvent.php b/src/Cache/Event/CacheClearedEvent.php new file mode 100644 index 00000000000..200cb3b06c2 --- /dev/null +++ b/src/Cache/Event/CacheClearedEvent.php @@ -0,0 +1,30 @@ + + */ +class CacheClearedEvent extends Event +{ + public const NAME = 'Cache.cleared'; +} diff --git a/src/Cache/Event/CacheGroupClearEvent.php b/src/Cache/Event/CacheGroupClearEvent.php new file mode 100644 index 00000000000..a6991e87fd5 --- /dev/null +++ b/src/Cache/Event/CacheGroupClearEvent.php @@ -0,0 +1,60 @@ + + */ +class CacheGroupClearEvent extends Event +{ + public const NAME = 'Cache.clearedGroup'; + + protected string $group; + + /** + * Constructor + * + * @param string $name Name of the event + * @param TEngine $subject The Cache engine instance this event applies to. + * @param array $data Any value you wish to be transported with this event to it can be read by listeners. + */ + public function __construct(string $name, CacheEngine $subject, array $data = []) + { + if (isset($data['group'])) { + $this->group = $data['group']; + unset($data['group']); + } + + parent::__construct($name, $subject, $data); + } + + /** + * Get the group name + * + * @return string + */ + public function getGroup(): string + { + return $this->group; + } +} diff --git a/src/Cache/Exception/CacheWriteException.php b/src/Cache/Exception/CacheWriteException.php new file mode 100644 index 00000000000..241ef653942 --- /dev/null +++ b/src/Cache/Exception/CacheWriteException.php @@ -0,0 +1,27 @@ + 'Cake\Cache\Engine\ApcEngine', + 'className' => \Cake\Cache\Engine\ApcuEngine::class, 'duration' => '+1 week', 'prefix' => 'my_app_' ]); @@ -39,7 +38,7 @@ $object = new FileEngine($config); Cache::config('other', $object); ``` -You can now read a write from the cache: +You can now read and write from the cache: ```php $data = Cache::remember('my_cache_key', function () { @@ -52,6 +51,6 @@ the callback will be executed and the returned data will be cached for future ca ## Documentation -Please make sure you check the [official documentation](https://book.cakephp.org/3.0/en/core-libraries/caching.html) +Please make sure you check the [official documentation](https://book.cakephp.org/5/en/core-libraries/caching.html) diff --git a/src/Cache/composer.json b/src/Cache/composer.json index 3e101a04f2d..a13951d0c42 100644 --- a/src/Cache/composer.json +++ b/src/Cache/composer.json @@ -22,12 +22,24 @@ "source": "https://github.com/cakephp/cache" }, "require": { - "php": ">=5.6.0", - "cakephp/core": "^3.0.0" + "php": ">=8.2", + "cakephp/core": "^5.4.0", + "cakephp/event": "^5.4.0", + "psr/simple-cache": "^2.0 || ^3.0" + }, + "provide": { + "psr/simple-cache-implementation": "^3.0" }, "autoload": { "psr-4": { "Cake\\Cache\\": "." } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" + } } } diff --git a/src/Collection/Collection.php b/src/Collection/Collection.php index fc78c7da0d3..9480197e299 100644 --- a/src/Collection/Collection.php +++ b/src/Collection/Collection.php @@ -1,4 +1,6 @@ > + * @implements \Cake\Collection\CollectionInterface */ -class Collection extends IteratorIterator implements CollectionInterface, Serializable +class Collection extends IteratorIterator implements CollectionInterface { - + /** @use \Cake\Collection\CollectionTrait */ use CollectionTrait; + /** + * Whether or not the items in this collection are an array. + * + * @var bool + */ + protected bool $innerIsArray = false; + /** * Constructor. You can provide an array or any traversable object * - * @param array|\Traversable $items Items. + * @param iterable $items Items. * @throws \InvalidArgumentException If passed incorrect type for items. */ - public function __construct($items) + public function __construct(iterable $items) { if (is_array($items)) { $items = new ArrayIterator($items); } - if (!($items instanceof Traversable)) { - $msg = 'Only an array or \Traversable is allowed for Collection'; - throw new InvalidArgumentException($msg); - } + $this->innerIsArray = $items instanceof ArrayIterator || $items instanceof SplFixedArray; parent::__construct($items); } /** - * Returns a string representation of this object that can be used - * to reconstruct it - * - * @return string - */ - public function serialize() - { - return serialize($this->buffered()); - } - - /** - * Unserializes the passed string and rebuilds the Collection instance + * Returns an array for serializing this object. * - * @param string $collection The serialized collection - * @return void + * @return array */ - public function unserialize($collection) + public function __serialize(): array { - $this->__construct(unserialize($collection)); + return $this->buffered()->toArray(); } /** - * Throws an exception. - * - * Issuing a count on a Collection can have many side effects, some making the - * Collection unusable after the count operation. + * Rebuilds the Collection instance. * + * @param array $data Data array. * @return void - * @throws \LogicException */ - public function count() + public function __unserialize(array $data): void { - throw new LogicException('You cannot issue a count on a Collection.'); + /** @phpstan-ignore argument.type (unserialize rebuilds from array) */ + $this->__construct($data); } /** * Returns an array that can be used to describe the internal state of this * object. * - * @return array + * @return array */ - public function __debugInfo() + public function __debugInfo(): array { + if ($this->innerIsArray) { + $index = $this->key(); + $items = $this->toArray(); + + $this->rewind(); + while ($this->key() !== $index) { + $this->next(); + } + + return [ + 'count' => count($items), + 'items' => $items, + ]; + } + return [ - 'count' => iterator_count($this), + 'innerIterator' => $this->unwrap(), ]; } } diff --git a/src/Collection/CollectionInterface.php b/src/Collection/CollectionInterface.php index 08694ecc90d..d748f5a4733 100644 --- a/src/Collection/CollectionInterface.php +++ b/src/Collection/CollectionInterface.php @@ -1,4 +1,6 @@ keys() Returns a new collection containing only the keys of the elements. + * @method \Cake\Collection\CollectionInterface values() Returns a new collection containing only the values, re-indexed with consecutive integers. + * @method string implode(string $glue, callable|string|null $path = null) Concatenates all elements into a string using the provided glue. + * @method \Cake\Collection\CollectionInterface when(mixed $condition, callable $callback) Applies callback if condition is truthy. + * @method \Cake\Collection\CollectionInterface unless(mixed $condition, callable $callback) Applies callback if condition is falsy. + * @template TKey + * @template-covariant TValue + * @template-extends \Iterator + * @method bool any(callable $callback) */ -interface CollectionInterface extends Iterator, JsonSerializable +interface CollectionInterface extends Iterator, JsonSerializable, Countable { - /** - * Executes the passed callable for each of the elements in this collection - * and passes both the value and key for them on each step. - * Returns the same collection for chaining. + * Applies a callback to the elements in this collection. * * ### Example: * @@ -38,11 +49,11 @@ interface CollectionInterface extends Iterator, JsonSerializable * }); * ``` * - * @param callable $c callable function that will receive each of the elements - * in this collection - * @return \Cake\Collection\CollectionInterface + * @param callable $callback Callback to run for each element in collection. + * Receives `($value, $key)` as parameters. + * @return $this */ - public function each(callable $c); + public function each(callable $callback); /** * Looks through each value in the collection, and returns another collection with @@ -64,12 +75,12 @@ public function each(callable $c); * }); * ``` * - * @param callable|null $c the method that will receive each of the elements and - * returns true whether or not they should be in the resulting collection. + * @param callable|null $callback A callback receiving `($value, $key, $iterator)` that + * returns true if the element should be included in the resulting collection. * If left null, a callback that filters out falsey values will be used. - * @return \Cake\Collection\CollectionInterface + * @return \Cake\Collection\CollectionInterface */ - public function filter(callable $c = null); + public function filter(?callable $callback = null): CollectionInterface; /** * Looks through each value in the collection, and returns another collection with @@ -90,19 +101,32 @@ public function filter(callable $c = null); * }); * ``` * - * @param callable $c the method that will receive each of the elements and - * returns true whether or not they should be out of the resulting collection. - * @return \Cake\Collection\CollectionInterface + * @param callable|null $callback A callback receiving `($value, $key, $iterator)` that + * returns true if the element should be excluded from the resulting collection. + * If left null, a callback that filters out truthy values will be used. + * @return \Cake\Collection\CollectionInterface + */ + public function reject(?callable $callback = null): CollectionInterface; + + /** + * Loops through each value in the collection and returns a new collection + * with only unique values based on the value returned by the callback. + * + * The callback is passed the value as the first argument and the key as the + * second argument. + * + * @param callable|null $callback A callback receiving `($value, $key)` that returns + * the value used to determine uniqueness. If left null, the element values themselves are used. + * @return \Cake\Collection\CollectionInterface */ - public function reject(callable $c); + public function unique(?callable $callback = null): CollectionInterface; /** * Returns true if all values in this collection pass the truth test provided * in the callback. * - * Each time the callback is executed it will receive the value of the element - * in the current iteration and the key of the element as arguments, in that - * order. + * The callback is passed the value and key of the element being tested and should + * return true if the test passed. * * ### Example: * @@ -112,35 +136,38 @@ public function reject(callable $c); * }); * ``` * - * Empty collections always return true because it is a vacuous truth. + * Empty collections always return true. * - * @param callable $c a callback function - * @return bool true if for all elements in this collection the provided + * @param callable $callback A callback receiving `($value, $key)` that returns + * true if the test passed, false otherwise. + * @return bool True if for all elements in this collection the provided * callback returns true, false otherwise. */ - public function every(callable $c); + public function every(callable $callback): bool; /** * Returns true if any of the values in this collection pass the truth test * provided in the callback. * - * Each time the callback is executed it will receive the value of the element - * in the current iteration and the key of the element as arguments, in that - * order. + * The callback is passed the value and key of the element being tested and should + * return true if the test passed. + * + * Alias of ``Collection::any()``. * * ### Example: * * ``` - * $hasYoungPeople = (new Collection([24, 45, 15]))->every(function ($value, $key) { + * $hasYoungPeople = (new Collection([24, 45, 15]))->some(function ($value, $key) { * return $value < 21; * }); * ``` * - * @param callable $c a callback function - * @return bool true if the provided callback returns true for any element in this - * collection, false otherwise + * @param callable $callback A callback receiving `($value, $key)` that returns + * true if the test passed, false otherwise. + * @return bool True if the provided callback returns true for any element in this + * collection, false otherwise */ - public function some(callable $c); + public function some(callable $callback): bool; /** * Returns true if $value is present in this collection. Comparisons are made @@ -149,7 +176,7 @@ public function some(callable $c); * @param mixed $value The value to check for * @return bool true if $value is present in this collection */ - public function contains($value); + public function contains(mixed $value): bool; /** * Returns another collection after modifying each of the values in this one using @@ -169,29 +196,30 @@ public function contains($value); * }); * ``` * - * @param callable $c the method that will receive each of the elements and - * returns the new value for the key that is being iterated - * @return \Cake\Collection\CollectionInterface + * @param callable $callback A callback receiving `($value, $key, $iterator)` that + * returns the transformed value for each element. + * @return \Cake\Collection\CollectionInterface */ - public function map(callable $c); + public function map(callable $callback): CollectionInterface; /** * Folds the values in this collection to a single value, as the result of - * applying the callback function to all elements. $zero is the initial state + * applying the callback function to all elements. $initial is the initial state * of the reduction, and each successive step of it should be returned * by the callback function. - * If $zero is omitted the first value of the collection will be used in its place + * If $initial is omitted the first value of the collection will be used in its place * and reduction will start from the second item. * - * @param callable $c The callback function to be called - * @param mixed $zero The state of reduction + * @param callable $callback A callback receiving `($accumulator, $value, $key, $iterator)` + * that returns the updated accumulator for each iteration. + * @param mixed $initial The initial state of reduction * @return mixed */ - public function reduce(callable $c, $zero = null); + public function reduce(callable $callback, mixed $initial = null): mixed; /** * Returns a new collection containing the column or property value found in each - * of the elements, as requested in the $matcher param. + * of the elements. * * The matcher can be a string with a property name to extract or a dot separated * path of properties that should be followed to get the last one in the path. @@ -205,8 +233,8 @@ public function reduce(callable $c, $zero = null); * * ``` * $items = [ - * ['comment' => ['body' => 'cool', 'user' => ['name' => 'Mark']], - * ['comment' => ['body' => 'very cool', 'user' => ['name' => 'Renan']] + * ['comment' => ['body' => 'cool', 'user' => ['name' => 'Mark']]], + * ['comment' => ['body' => 'very cool', 'user' => ['name' => 'Renan']]], * ]; * $extracted = (new Collection($items))->extract('comment.user.name'); * @@ -219,7 +247,7 @@ public function reduce(callable $c, $zero = null); * ``` * $items = [ * ['comment' => ['votes' => [['value' => 1], ['value' => 2], ['value' => 3]]], - * ['comment' => ['votes' => [['value' => 4]] + * ['comment' => ['votes' => [['value' => 4]], * ]; * $extracted = (new Collection($items))->extract('comment.votes.{*}.value'); * @@ -227,15 +255,16 @@ public function reduce(callable $c, $zero = null); * [1, 2, 3, 4] * ``` * - * @param string $matcher a dot separated string symbolizing the path to follow - * inside the hierarchy of each value so that the column can be extracted. - * @return \Cake\Collection\CollectionInterface + * @param callable|string $path A dot separated path of column to follow + * so that the final one can be returned or a callable that will take care + * of doing that. + * @return \Cake\Collection\CollectionInterface */ - public function extract($matcher); + public function extract(callable|string $path): CollectionInterface; /** * Returns the top element in this collection after being sorted by a property. - * Check the sortBy method for information on the callback and $type parameters + * Check the sortBy method for information on the callback and $sort parameters * * ### Examples: * @@ -251,17 +280,16 @@ public function extract($matcher); * echo $max->name; * ``` * - * @param callable|string $callback the callback or column name to use for sorting - * @param int $type the type of comparison to perform, either SORT_STRING - * SORT_NUMERIC or SORT_NATURAL - * @see \Cake\Collection\CollectionIterface::sortBy() + * @param callable|string $path The column name to use for sorting or callback that returns the value. + * @param int $sort The sort type, one of SORT_STRING, SORT_NUMERIC or SORT_NATURAL + * @see \Cake\Collection\CollectionInterface::sortBy() * @return mixed The value of the top element in the collection */ - public function max($callback, $type = SORT_NUMERIC); + public function max(callable|string $path, int $sort = SORT_NUMERIC): mixed; /** * Returns the bottom element in this collection after being sorted by a property. - * Check the sortBy method for information on the callback and $type parameters + * Check the sortBy method for information on the callback and $sort parameters * * ### Examples: * @@ -277,16 +305,15 @@ public function max($callback, $type = SORT_NUMERIC); * echo $min->name; * ``` * - * @param callable|string $callback the callback or column name to use for sorting - * @param int $type the type of comparison to perform, either SORT_STRING - * SORT_NUMERIC or SORT_NATURAL + * @param callable|string $path The column name to use for sorting or callback that returns the value. + * @param int $sort The sort type, one of SORT_STRING, SORT_NUMERIC or SORT_NATURAL * @see \Cake\Collection\CollectionInterface::sortBy() * @return mixed The value of the bottom element in the collection */ - public function min($callback, $type = SORT_NUMERIC); + public function min(callable|string $path, int $sort = SORT_NUMERIC): mixed; /** - * Returns the average of all the values extracted with $matcher + * Returns the average of all the values extracted with $path * or of this collection. * * ### Example: @@ -305,15 +332,18 @@ public function min($callback, $type = SORT_NUMERIC); * // Total: 2 * ``` * - * @param string|callable|null $matcher The property name to sum or a function + * The average of an empty set or 0 rows is `null`. Collections with `null` + * values are not considered empty. + * + * @param callable|string|null $path The property name to compute the average or a function * If no value is passed, an identity function will be used. - * that will return the value of the property to sum. + * that will return the value of the property to compute the average. * @return float|int|null */ - public function avg($matcher = null); + public function avg(callable|string|null $path = null): float|int|null; /** - * Returns the median of all the values extracted with $matcher + * Returns the median of all the values extracted with $path * or of this collection. * * ### Example: @@ -335,18 +365,20 @@ public function avg($matcher = null); * // Total: 2.5 * ``` * - * @param string|callable|null $matcher The property name to sum or a function + * The median of an empty set or 0 rows is `null`. Collections with `null` + * values are not considered empty. + * + * @param callable|string|null $path The property name to compute the median or a function * If no value is passed, an identity function will be used. - * that will return the value of the property to sum. + * that will return the value of the property to compute the median. * @return float|int|null */ - public function median($matcher = null); + public function median(callable|string|null $path = null): float|int|null; /** * Returns a sorted iterator out of the elements in this collection, - * ranked in ascending order by the results of running each value through a - * callback. $callback can also be a string representing the column or property - * name. + * ranked based on the results of applying a callback function to each value. + * The parameter $path can also be a string representing the column or property name. * * The callback will receive as its first argument each of the elements in $items, * the value returned by the callback will be used as the value for sorting such @@ -372,13 +404,16 @@ public function median($matcher = null); * } * ``` * - * @param callable|string $callback the callback or column name to use for sorting - * @param int $dir either SORT_DESC or SORT_ASC - * @param int $type the type of comparison to perform, either SORT_STRING - * SORT_NUMERIC or SORT_NATURAL - * @return \Cake\Collection\CollectionInterface + * @param callable|string $path The column name to use for sorting or callback that returns the value. + * @param int $order The sort order, either SORT_DESC or SORT_ASC + * @param int $sort The sort type, one of SORT_STRING, SORT_NUMERIC or SORT_NATURAL + * @return \Cake\Collection\CollectionInterface */ - public function sortBy($callback, $dir = SORT_DESC, $type = SORT_NUMERIC); + public function sortBy( + callable|string $path, + int $order = SORT_DESC, + int $sort = SORT_NUMERIC, + ): CollectionInterface; /** * Splits a collection into sets, grouped by the result of running each value @@ -417,11 +452,11 @@ public function sortBy($callback, $dir = SORT_DESC, $type = SORT_NUMERIC); * ]; * ``` * - * @param callable|string $callback the callback or column name to use for grouping + * @param callable|string $path The column name to use for grouping or callback that returns the value. * or a function returning the grouping key out of the provided element - * @return \Cake\Collection\CollectionInterface + * @return \Cake\Collection\CollectionInterface */ - public function groupBy($callback); + public function groupBy(callable|string $path): CollectionInterface; /** * Given a list and a callback function that returns a key for each element @@ -456,11 +491,11 @@ public function groupBy($callback); * ]; * ``` * - * @param callable|string $callback the callback or column name to use for indexing + * @param callable|string $path The column name to use for indexing or callback that returns the value. * or a function returning the indexing key out of the provided element - * @return \Cake\Collection\CollectionInterface + * @return \Cake\Collection\CollectionInterface */ - public function indexBy($callback); + public function indexBy(callable|string $path): CollectionInterface; /** * Sorts a list into groups and returns a count for the number of elements @@ -494,11 +529,11 @@ public function indexBy($callback); * ]; * ``` * - * @param callable|string $callback the callback or column name to use for indexing + * @param callable|string $path The column name to use for indexing or callback that returns the value. * or a function returning the indexing key out of the provided element - * @return \Cake\Collection\CollectionInterface + * @return \Cake\Collection\CollectionInterface */ - public function countBy($callback); + public function countBy(callable|string $path): CollectionInterface; /** * Returns the total sum of all the values extracted with $matcher @@ -509,7 +544,7 @@ public function countBy($callback); * ``` * $items = [ * ['invoice' => ['total' => 100]], - * ['invoice' => ['total' => 200]] + * ['invoice' => ['total' => 200]], * ]; * * $total = (new Collection($items))->sumOf('invoice.total'); @@ -520,51 +555,70 @@ public function countBy($callback); * // Total: 6 * ``` * - * @param string|callable|null $matcher The property name to sum or a function + * @param callable|string|null $path The property name to sum or a function * If no value is passed, an identity function will be used. * that will return the value of the property to sum. * @return float|int */ - public function sumOf($matcher = null); + public function sumOf(callable|string|null $path = null): float|int; /** * Returns a new collection with the elements placed in a random order, * this function does not preserve the original keys in the collection. * - * @return \Cake\Collection\CollectionInterface + * @return \Cake\Collection\CollectionInterface */ - public function shuffle(); + public function shuffle(): CollectionInterface; /** - * Returns a new collection with maximum $size random elements + * Returns a new collection with maximum $length random elements * from this collection * - * @param int $size the maximum number of elements to randomly + * @param int $length the maximum number of elements to randomly * take from this collection - * @return \Cake\Collection\CollectionInterface + * @return \Cake\Collection\CollectionInterface */ - public function sample($size = 10); + public function sample(int $length = 10): CollectionInterface; /** - * Returns a new collection with maximum $size elements in the internal + * Returns a new collection with maximum $length elements in the internal * order this collection was created. If a second parameter is passed, it * will determine from what position to start taking elements. * - * @param int $size the maximum number of elements to take from + * @param int $length the maximum number of elements to take from * this collection - * @param int $from A positional offset from where to take the elements - * @return \Cake\Collection\CollectionInterface + * @param int $offset A positional offset from where to take the elements + * @return \Cake\Collection\CollectionInterface */ - public function take($size = 1, $from = 0); + public function take(int $length = 1, int $offset = 0): CollectionInterface; + + /** + * Returns the last N elements of a collection + * + * ### Example: + * + * ``` + * $items = [1, 2, 3, 4, 5]; + * + * $last = (new Collection($items))->takeLast(3); + * + * // Result will look like this when converted to array + * [3, 4, 5]; + * ``` + * + * @param int $length The number of elements at the end of the collection + * @return \Cake\Collection\CollectionInterface + */ + public function takeLast(int $length): CollectionInterface; /** * Returns a new collection that will skip the specified amount of elements * at the beginning of the iteration. * - * @param int $howMany The number of elements to skip. - * @return \Cake\Collection\CollectionInterface + * @param int $length The number of elements to skip. + * @return \Cake\Collection\CollectionInterface */ - public function skip($howMany); + public function skip(int $length): CollectionInterface; /** * Looks through each value in the list, returning a Collection of all the @@ -574,59 +628,85 @@ public function skip($howMany); * * ``` * $items = [ - * ['comment' => ['body' => 'cool', 'user' => ['name' => 'Mark']], - * ['comment' => ['body' => 'very cool', 'user' => ['name' => 'Renan']] + * ['comment' => ['body' => 'cool', 'user' => ['name' => 'Mark']]], + * ['comment' => ['body' => 'very cool', 'user' => ['name' => 'Renan']]], * ]; * * $extracted = (new Collection($items))->match(['user.name' => 'Renan']); * * // Result will look like this when converted to array * [ - * ['comment' => ['body' => 'very cool', 'user' => ['name' => 'Renan']] + * ['comment' => ['body' => 'very cool', 'user' => ['name' => 'Renan']]], * ] * ``` * - * @param array $conditions a key-value list of conditions where - * the key is a property path as accepted by `Collection::extract, - * and the value the condition against with each element will be matched - * @return \Cake\Collection\CollectionInterface + * @param array $conditions A key-value list of conditions where + * the key is a property path as accepted by `Collection::extract`, + * and the value is the expected value to match against. + * @return \Cake\Collection\CollectionInterface */ - public function match(array $conditions); + public function match(array $conditions): CollectionInterface; /** - * Returns the first result matching all of the key-value pairs listed in + * Returns the first result matching all the key-value pairs listed in * conditions. * - * @param array $conditions a key-value list of conditions where the key is - * a property path as accepted by `Collection::extract`, and the value the - * condition against with each element will be matched + * @param array $conditions A key-value list of conditions where the key is + * a property path as accepted by `Collection::extract`, and the value is + * the expected value to match against. * @see \Cake\Collection\CollectionInterface::match() - * @return mixed + * @return TValue|null */ - public function firstMatch(array $conditions); + public function firstMatch(array $conditions): mixed; /** * Returns the first result in this collection * - * @return mixed The first value in the collection will be returned. + * @return TValue|null The first value in the collection will be returned. */ - public function first(); + public function first(): mixed; /** * Returns the last result in this collection * - * @return mixed The last value in the collection will be returned. + * @return TValue|null The last value in the collection will be returned. */ - public function last(); + public function last(): mixed; /** * Returns a new collection as the result of concatenating the list of elements * in this collection with the passed list of elements * - * @param array|\Traversable $items Items list. - * @return \Cake\Collection\CollectionInterface + * @param iterable $items Items list. + * @return \Cake\Collection\CollectionInterface */ - public function append($items); + public function append(iterable $items): CollectionInterface; + + /** + * Append a single item creating a new collection. + * + * @param mixed $item The item to append. + * @param mixed $key The key to append the item with. If null a key will be generated. + * @return \Cake\Collection\CollectionInterface + */ + public function appendItem(mixed $item, mixed $key = null): CollectionInterface; + + /** + * Prepend a set of items to a collection creating a new collection + * + * @param iterable $items The items to prepend. + * @return \Cake\Collection\CollectionInterface + */ + public function prepend(iterable $items): CollectionInterface; + + /** + * Prepend a single item creating a new collection. + * + * @param mixed $item The item to prepend. + * @param mixed $key The key to prepend the item with. If null a key will be generated. + * @return \Cake\Collection\CollectionInterface + */ + public function prependItem(mixed $item, mixed $key = null): CollectionInterface; /** * Returns a new collection where the values extracted based on a value path @@ -656,7 +736,7 @@ public function append($items); * // Result will look like this when converted to array * [ * 'a' => [1 => 'foo', 3 => 'baz'], - * 'b' => [2 => 'bar'] + * 'b' => [2 => 'bar'], * ]; * ``` * @@ -666,22 +746,30 @@ public function append($items); * or a function returning the value out of the provided element * @param callable|string|null $groupPath the column name path to use as the parent * grouping key or a function returning the key out of the provided element - * @return \Cake\Collection\CollectionInterface + * @return \Cake\Collection\CollectionInterface */ - public function combine($keyPath, $valuePath, $groupPath = null); + public function combine( + callable|string $keyPath, + callable|string $valuePath, + callable|string|null $groupPath = null, + ): CollectionInterface; /** * Returns a new collection where the values are nested in a tree-like structure * based on an id property path and a parent id property path. * * @param callable|string $idPath the column name path to use for determining - * whether an element is parent of another + * whether an element is a parent of another * @param callable|string $parentPath the column name path to use for determining - * whether an element is child of another + * whether an element is a child of another * @param string $nestingKey The key name under which children are nested - * @return \Cake\Collection\CollectionInterface + * @return \Cake\Collection\CollectionInterface */ - public function nest($idPath, $parentPath, $nestingKey = 'children'); + public function nest( + callable|string $idPath, + callable|string $parentPath, + string $nestingKey = 'children', + ): CollectionInterface; /** * Returns a new collection containing each of the elements found in `$values` as @@ -700,16 +788,16 @@ public function nest($idPath, $parentPath, $nestingKey = 'children'); * * ``` * $items = [ - * ['comment' => ['body' => 'cool', 'user' => ['name' => 'Mark']], - * ['comment' => ['body' => 'awesome', 'user' => ['name' => 'Renan']] + * ['comment' => ['body' => 'cool', 'user' => ['name' => 'Mark']]], + * ['comment' => ['body' => 'awesome', 'user' => ['name' => 'Renan']]], * ]; * $ages = [25, 28]; * $inserted = (new Collection($items))->insert('comment.user.age', $ages); * * // Result will look like this when converted to array * [ - * ['comment' => ['body' => 'cool', 'user' => ['name' => 'Mark', 'age' => 25]], - * ['comment' => ['body' => 'awesome', 'user' => ['name' => 'Renan', 'age' => 28]] + * ['comment' => ['body' => 'cool', 'user' => ['name' => 'Mark', 'age' => 25]]], + * ['comment' => ['body' => 'awesome', 'user' => ['name' => 'Renan', 'age' => 28]]] * ]; * ``` * @@ -717,37 +805,38 @@ public function nest($idPath, $parentPath, $nestingKey = 'children'); * inside the hierarchy of each value so that the value can be inserted * @param mixed $values The values to be inserted at the specified path, * values are matched with the elements in this collection by its positional index. - * @return \Cake\Collection\CollectionInterface + * @return \Cake\Collection\CollectionInterface */ - public function insert($path, $values); + public function insert(string $path, mixed $values): CollectionInterface; /** * Returns an array representation of the results * - * @param bool $preserveKeys whether to use the keys returned by this + * @param bool $keepKeys Whether to use the keys returned by this * collection as the array keys. Keep in mind that it is valid for iterators * to return the same key for different elements, setting this value to false * can help getting all items if keys are not important in the result. - * @return array + * @return ($keepKeys is true ? array : array) */ - public function toArray($preserveKeys = true); + public function toArray(bool $keepKeys = true): array; /** * Returns an numerically-indexed array representation of the results. * This is equivalent to calling `toArray(false)` * - * @return array + * @return array */ - public function toList(); + public function toList(): array; /** - * Convert a result set into JSON. + * Returns the data that can be converted to JSON. This returns the same data + * as `toArray()` which contains only unique keys. * * Part of JsonSerializable interface. * * @return array The data to convert to JSON */ - public function jsonSerialize(); + public function jsonSerialize(): array; /** * Iterates once all elements in this collection and executes all stacked @@ -775,13 +864,23 @@ public function jsonSerialize(); * You can think of this method as a way to create save points for complex * calculations in a collection. * - * @param bool $preserveKeys whether to use the keys returned by this + * @param bool $keepKeys Whether to use the keys returned by this * collection as the array keys. Keep in mind that it is valid for iterators * to return the same key for different elements, setting this value to false * can help getting all items if keys are not important in the result. - * @return \Cake\Collection\CollectionInterface + * @return \Cake\Collection\CollectionInterface + */ + public function compile(bool $keepKeys = true): CollectionInterface; + + /** + * Returns a new collection where any operations chained after it are guaranteed + * to be run lazily. That is, elements will be yielded one at a time. + * + * A lazy collection can only be iterated once. A second attempt results in an error. + * + * @return \Cake\Collection\CollectionInterface */ - public function compile($preserveKeys = true); + public function lazy(): CollectionInterface; /** * Returns a new collection where the operations performed by this collection. @@ -790,9 +889,9 @@ public function compile($preserveKeys = true); * * This can also be used to make any non-rewindable iterator rewindable. * - * @return \Cake\Collection\CollectionInterface + * @return \Cake\Collection\CollectionInterface */ - public function buffered(); + public function buffered(): CollectionInterface; /** * Returns a new collection with each of the elements of this collection @@ -814,9 +913,9 @@ public function buffered(); * The possible values for the first argument are aliases for the following * constants and it is valid to pass those instead of the alias: * - * - desc: TreeIterator::SELF_FIRST - * - asc: TreeIterator::CHILD_FIRST - * - leaves: TreeIterator::LEAVES_ONLY + * - desc: RecursiveIteratorIterator::SELF_FIRST + * - asc: RecursiveIteratorIterator::CHILD_FIRST + * - leaves: RecursiveIteratorIterator::LEAVES_ONLY * * ### Example: * @@ -828,16 +927,19 @@ public function buffered(); * $flattenedIds = $collection->listNested()->extract('id'); // Yields [1, 2, 3, 4, 5] * ``` * - * @param string|int $dir The direction in which to return the elements - * @param string|callable $nestingKey The key name under which children are nested + * @param string|int $order The order in which to return the elements + * @param callable|string $nestingKey The key name under which children are nested * or a callable function that will return the children list - * @return \Cake\Collection\CollectionInterface + * @return \Cake\Collection\CollectionInterface */ - public function listNested($dir = 'desc', $nestingKey = 'children'); + public function listNested( + string|int $order = 'desc', + callable|string $nestingKey = 'children', + ): CollectionInterface; /** * Creates a new collection that when iterated will stop yielding results if - * the provided condition evaluates to false. + * the provided condition evaluates to true. * * This is handy for dealing with infinite iterators or any generator that * could start returning invalid elements at a certain point. For example, @@ -861,14 +963,14 @@ public function listNested($dir = 'desc', $nestingKey = 'children'); * $comments = (new Collection($comments))->stopWhen(['is_approved' => false]); * ``` * - * @param callable $condition the method that will receive each of the elements and - * returns false when the iteration should be stopped. + * @param callable|array $condition the method that will receive each of the elements and + * returns true when the iteration should be stopped. * If an array, it will be interpreted as a key-value list of conditions where * the key is a property path as accepted by `Collection::extract`, * and the value the condition against with each element will be matched. - * @return \Cake\Collection\CollectionInterface + * @return \Cake\Collection\CollectionInterface */ - public function stopWhen($condition); + public function stopWhen(callable|array $condition): CollectionInterface; /** * Creates a new collection where the items are the @@ -899,11 +1001,11 @@ public function stopWhen($condition); * }); * ``` * - * @param callable|null $transformer A callable function that will receive each of + * @param callable|null $callback A callable function that will receive each of * the items in the collection and should return an array or Traversable object - * @return \Cake\Collection\CollectionInterface + * @return \Cake\Collection\CollectionInterface */ - public function unfold(callable $transformer = null); + public function unfold(?callable $callback = null): CollectionInterface; /** * Passes this collection through a callable as its first argument. @@ -918,11 +1020,11 @@ public function unfold(callable $transformer = null); * }); * ``` * - * @param callable $handler A callable function that will receive + * @param callable $callback A callable function that will receive * this collection as first argument. - * @return \Cake\Collection\CollectionInterface + * @return \Cake\Collection\CollectionInterface */ - public function through(callable $handler); + public function through(callable $callback): CollectionInterface; /** * Combines the elements of this collection with each of the elements of the @@ -935,11 +1037,12 @@ public function through(callable $handler); * $collection->zip([3, 4], [5, 6])->toList(); // returns [[1, 3, 5], [2, 4, 6]] * ``` * - * @param array|\Traversable ...$items The collections to zip. - * @return \Cake\Collection\CollectionInterface + * @param iterable ...$items The collections to zip. + * @return \Cake\Collection\CollectionInterface */ - public function zip($items); + public function zip(iterable ...$items): CollectionInterface; + // phpcs:disable /** * Combines the elements of this collection with each of the elements of the * passed iterables, using their positional index as a reference. @@ -956,11 +1059,12 @@ public function zip($items); * $zipped->toList(); // returns [9, 12]; [(1 + 3 + 5), (2 + 4 + 6)] * ``` * - * @param array|\Traversable ...$items The collections to zip. - * @param callable $callable The function to use for zipping the elements together. - * @return \Cake\Collection\CollectionInterface + * @param iterable ...$items The collections to zip. + * @param callable $callback The function to use for zipping the elements together. + * @return \Cake\Collection\CollectionInterface */ - public function zipWith($items, $callable); + public function zipWith(iterable $items, $callback): CollectionInterface; + // phpcs:enable /** * Breaks the collection into smaller arrays of the given size. @@ -974,10 +1078,9 @@ public function zipWith($items, $callable); * ``` * * @param int $chunkSize The maximum size for each chunk - * @return \Cake\Collection\CollectionInterface - * @deprecated 4.0.0 Deprecated in favor of chunks + * @return \Cake\Collection\CollectionInterface> */ - public function chunk($chunkSize); + public function chunk(int $chunkSize): CollectionInterface; /** * Breaks the collection into smaller arrays of the given size. @@ -991,13 +1094,13 @@ public function chunk($chunkSize); * ``` * * @param int $chunkSize The maximum size for each chunk - * @param bool $preserveKeys If the keys of the array should be preserved - * @return \Cake\Collection\CollectionInterface + * @param bool $keepKeys If the keys of the array should be kept + * @return \Cake\Collection\CollectionInterface> */ - public function chunkWithKeys($chunkSize, $preserveKeys = true); + public function chunkWithKeys(int $chunkSize, bool $keepKeys = true): CollectionInterface; /** - * Returns whether or not there are elements in this collection + * Returns whether there are elements in this collection * * ### Example: * @@ -1012,16 +1115,16 @@ public function chunkWithKeys($chunkSize, $preserveKeys = true); * * @return bool */ - public function isEmpty(); + public function isEmpty(): bool; /** * Returns the closest nested iterator that can be safely traversed without * losing any possible transformations. This is used mainly to remove empty * IteratorIterator wrappers that can only slowdown the iteration process. * - * @return \Traversable + * @return \Iterator */ - public function unwrap(); + public function unwrap(): Iterator; /** * Transpose rows and columns into columns and rows @@ -1047,7 +1150,87 @@ public function unwrap(); * // ] * ``` * - * @return \Cake\Collection\CollectionInterface + * @return \Cake\Collection\CollectionInterface> + */ + public function transpose(): CollectionInterface; + + /** + * Returns the amount of elements in the collection. + * + * ## WARNINGS: + * + * ### Will change the current position of the iterator: + * + * Calling this method at the same time that you are iterating this collections, for example in + * a foreach, will result in undefined behavior. Avoid doing this. + * + * + * ### Consumes all elements for NoRewindIterator collections: + * + * On certain type of collections, calling this method may render unusable afterwards. + * That is, you may not be able to get elements out of it, or to iterate on it anymore. + * + * Specifically any collection wrapping a Generator (a function with a yield statement) + * or a unbuffered database cursor will not accept any other function calls after calling + * `count()` on it. + * + * Create a new collection with `buffered()` method to overcome this problem. + * + * ### Can report more elements than unique keys: + * + * Any collection constructed by appending collections together, or by having internal iterators + * returning duplicate keys, will report a larger amount of elements using this functions than + * the final amount of elements when converting the collections to a keyed array. This is because + * duplicate keys will be collapsed into a single one in the final array, whereas this count method + * is only concerned by the amount of elements after converting it to a plain list. + * + * If you need the count of elements after taking the keys in consideration + * (the count of unique keys), you can call `countKeys()` + * + * @return int + */ + public function count(): int; + + /** + * Returns the number of unique keys in this iterator. This is the same as the number of + * elements the collection will contain after calling `toArray()` + * + * This method comes with a number of caveats. Please refer to `CollectionInterface::count()` + * for details. + * + * @see \Cake\Collection\CollectionInterface::count() + * @return int + */ + public function countKeys(): int; + + /** + * Create a new collection that is the cartesian product of the current collection + * + * In order to create a cartesian product a collection must contain a single dimension + * of data. + * + * ### Example + * + * ``` + * $collection = new Collection([['A', 'B', 'C'], [1, 2, 3]]); + * $result = $collection->cartesianProduct()->toArray(); + * $expected = [ + * ['A', 1], + * ['A', 2], + * ['A', 3], + * ['B', 1], + * ['B', 2], + * ['B', 3], + * ['C', 1], + * ['C', 2], + * ['C', 3], + * ]; + * ``` + * + * @param callable|null $operation A callable that allows you to customize the product result. + * @param callable|null $filter A filtering callback that must return true for a result to be part + * of the final results. + * @return \Cake\Collection\CollectionInterface> */ - public function transpose(); + public function cartesianProduct(?callable $operation = null, ?callable $filter = null): CollectionInterface; } diff --git a/src/Collection/CollectionTrait.php b/src/Collection/CollectionTrait.php index d8fa78952eb..e150d23f6d3 100644 --- a/src/Collection/CollectionTrait.php +++ b/src/Collection/CollectionTrait.php @@ -1,4 +1,6 @@ */ - public function each(callable $c) + protected function newCollection(mixed ...$args): CollectionInterface + { + return new Collection(...$args); + } + + /** + * @inheritDoc + */ + public function each(callable $callback) { foreach ($this->optimizeUnwrap() as $k => $v) { - $c($v, $k); + $callback($v, $k); } return $this; @@ -57,38 +84,46 @@ public function each(callable $c) /** * {@inheritDoc} * - * @return \Cake\Collection\Iterator\FilterIterator + * @return \Cake\Collection\CollectionInterface */ - public function filter(callable $c = null) + public function filter(?callable $callback = null): CollectionInterface { - if ($c === null) { - $c = function ($v) { - return (bool)$v; - }; - } + $callback ??= fn($v) => (bool)$v; - return new FilterIterator($this->unwrap(), $c); + return new FilterIterator($this->unwrap(), $callback); } /** * {@inheritDoc} * - * @return \Cake\Collection\Iterator\FilterIterator + * @return \Cake\Collection\CollectionInterface */ - public function reject(callable $c) + public function reject(?callable $callback = null): CollectionInterface { - return new FilterIterator($this->unwrap(), function ($key, $value, $items) use ($c) { - return !$c($key, $value, $items); - }); + $callback ??= fn($v) => (bool)$v; + + return new FilterIterator($this->unwrap(), fn($value, $key, $items) => !$callback($value, $key, $items)); } /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface */ - public function every(callable $c) + public function unique(?callable $callback = null): CollectionInterface + { + $callback ??= fn($v) => $v; + + return new UniqueIterator($this->unwrap(), $callback); + } + + /** + * @inheritDoc + */ + public function every(callable $callback): bool { foreach ($this->optimizeUnwrap() as $key => $value) { - if (!$c($value, $key)) { + if (!$callback($value, $key)) { return false; } } @@ -97,12 +132,25 @@ public function every(callable $c) } /** - * {@inheritDoc} + * Returns true if the callback returns true for any element in the collection. + * + * The callback accepts the value and key of the element being tested. + * + * ### Example: + * + * ``` + * $hasYoungPeople = (new Collection([24, 45, 15]))->any(function ($value, $key) { + * return $value < 21; + * }); + * ``` + * + * @param callable $callback a callback function + * @return bool */ - public function some(callable $c) + public function any(callable $callback): bool { foreach ($this->optimizeUnwrap() as $key => $value) { - if ($c($value, $key) === true) { + if ($callback($value, $key) === true) { return true; } } @@ -111,9 +159,17 @@ public function some(callable $c) } /** - * {@inheritDoc} + * @inheritDoc + */ + public function some(callable $callback): bool + { + return $this->any($callback); + } + + /** + * @inheritDoc */ - public function contains($value) + public function contains(mixed $value): bool { foreach ($this->optimizeUnwrap() as $v) { if ($value === $v) { @@ -127,31 +183,28 @@ public function contains($value) /** * {@inheritDoc} * - * @return \Cake\Collection\Iterator\ReplaceIterator + * @return \Cake\Collection\CollectionInterface */ - public function map(callable $c) + public function map(callable $callback): CollectionInterface { - return new ReplaceIterator($this->unwrap(), $c); + return new ReplaceIterator($this->unwrap(), $callback); } /** - * {@inheritDoc} + * @inheritDoc */ - public function reduce(callable $c, $zero = null) + public function reduce(callable $callback, mixed $initial = null): mixed { - $isFirst = false; - if (func_num_args() < 2) { - $isFirst = true; - } + $isFirst = func_num_args() < 2; - $result = $zero; + $result = $initial; foreach ($this->optimizeUnwrap() as $k => $value) { if ($isFirst) { $result = $value; $isFirst = false; continue; } - $result = $c($result, $value, $k); + $result = $callback($result, $value, $k); } return $result; @@ -159,14 +212,16 @@ public function reduce(callable $c, $zero = null) /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface */ - public function extract($matcher) + public function extract(callable|string $path): CollectionInterface { - $extractor = new ExtractIterator($this->unwrap(), $matcher); - if (is_string($matcher) && strpos($matcher, '{*}') !== false) { - $extractor = $extractor + $extractor = new ExtractIterator($this->unwrap(), $path); + if (is_string($path) && str_contains($path, '{*}')) { + return $extractor ->filter(function ($data) { - return $data !== null && ($data instanceof Traversable || is_array($data)); + return is_iterable($data); }) ->unfold(); } @@ -175,35 +230,34 @@ public function extract($matcher) } /** - * {@inheritDoc} + * @inheritDoc */ - public function max($callback, $type = SORT_NUMERIC) + public function max(callable|string $path, int $sort = SORT_NUMERIC): mixed { - return (new SortIterator($this->unwrap(), $callback, SORT_DESC, $type))->first(); + return (new SortIterator($this->unwrap(), $path, SORT_DESC, $sort))->first(); } /** - * {@inheritDoc} + * @inheritDoc */ - public function min($callback, $type = SORT_NUMERIC) + public function min(callable|string $path, int $sort = SORT_NUMERIC): mixed { - return (new SortIterator($this->unwrap(), $callback, SORT_ASC, $type))->first(); + return (new SortIterator($this->unwrap(), $path, SORT_ASC, $sort))->first(); } /** - * {@inheritDoc} + * @inheritDoc */ - public function avg($matcher = null) + public function avg(callable|string|null $path = null): float|int|null { $result = $this; - if ($matcher != null) { - $result = $result->extract($matcher); + if ($path !== null) { + $result = $result->extract($path); } $result = $result - ->reduce(function ($acc, $current) { - list($count, $sum) = $acc; - - return [$count + 1, $sum + $current]; + ->reduce(function (array $acc, $current) { + // index 0 is the count, index 1 is the sum + return [$acc[0] + 1, $acc[1] + $current]; }, [0, 0]); if ($result[0] === 0) { @@ -214,15 +268,15 @@ public function avg($matcher = null) } /** - * {@inheritDoc} + * @inheritDoc */ - public function median($matcher = null) + public function median(callable|string|null $path = null): float|int|null { - $elements = $this; - if ($matcher != null) { - $elements = $elements->extract($matcher); + $items = $this; + if ($path !== null) { + $items = $items->extract($path); } - $values = $elements->toList(); + $values = $items->toList(); sort($values); $count = count($values); @@ -241,68 +295,140 @@ public function median($matcher = null) /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface */ - public function sortBy($callback, $dir = SORT_DESC, $type = SORT_NUMERIC) + public function sortBy(callable|string $path, int $order = SORT_DESC, int $sort = SORT_NUMERIC): CollectionInterface { - return new SortIterator($this->unwrap(), $callback, $dir, $type); + return new SortIterator($this->unwrap(), $path, $order, $sort); } /** - * {@inheritDoc} + * Splits a collection into sets, grouped by the result of running each value + * through the callback. If $callback is a string instead of a callable, + * groups by the property named by $callback on each of the values. + * + * When $callback is a string it should be a property name to extract or + * a dot separated path of properties that should be followed to get the last + * one in the path. + * + * ### Example: + * + * ``` + * $items = [ + * ['id' => 1, 'name' => 'foo', 'parent_id' => 10], + * ['id' => 2, 'name' => 'bar', 'parent_id' => 11], + * ['id' => 3, 'name' => 'baz', 'parent_id' => 10], + * ]; + * + * $group = (new Collection($items))->groupBy('parent_id'); + * + * // Or + * $group = (new Collection($items))->groupBy(function ($e) { + * return $e['parent_id']; + * }); + * + * // Result will look like this when converted to array + * [ + * 10 => [ + * ['id' => 1, 'name' => 'foo', 'parent_id' => 10], + * ['id' => 3, 'name' => 'baz', 'parent_id' => 10], + * ], + * 11 => [ + * ['id' => 2, 'name' => 'bar', 'parent_id' => 11], + * ] + * ]; + * ``` + * + * @param callable|string $path The column name to use for grouping or callback that returns the value. + * or a function returning the grouping key out of the provided element + * @param bool $preserveKeys Whether to preserve the keys of the existing + * collection when the values are grouped. Defaults to false. + * @return \Cake\Collection\CollectionInterface */ - public function groupBy($callback) + public function groupBy(callable|string $path, bool $preserveKeys = false): CollectionInterface { - $callback = $this->_propertyExtractor($callback); + $callback = $this->_propertyExtractor($path); $group = []; - foreach ($this->optimizeUnwrap() as $value) { - $group[$callback($value)][] = $value; + foreach ($this->optimizeUnwrap() as $key => $value) { + $pathValue = $callback($value); + if ($pathValue === null) { + throw new InvalidArgumentException( + 'Cannot group by path that does not exist or contains a null value. ' . + 'Use a callback to return a default value for that path.', + ); + } + if ($pathValue instanceof BackedEnum) { + $pathValue = $pathValue->value; + } elseif ($pathValue instanceof UnitEnum) { + $pathValue = $pathValue->name; + } + + if ($preserveKeys) { + $group[$pathValue][$key] = $value; + continue; + } + + $group[$pathValue][] = $value; } - return new Collection($group); + return $this->newCollection($group); } /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface */ - public function indexBy($callback) + public function indexBy(callable|string $path): CollectionInterface { - $callback = $this->_propertyExtractor($callback); + $callback = $this->_propertyExtractor($path); $group = []; foreach ($this->optimizeUnwrap() as $value) { - $group[$callback($value)] = $value; + $pathValue = $callback($value); + if ($pathValue === null) { + throw new InvalidArgumentException( + 'Cannot index by path that does not exist or contains a null value. ' . + 'Use a callback to return a default value for that path.', + ); + } + if ($pathValue instanceof BackedEnum) { + $pathValue = $pathValue->value; + } elseif ($pathValue instanceof UnitEnum) { + $pathValue = $pathValue->name; + } + + $group[$pathValue] = $value; } - return new Collection($group); + return $this->newCollection($group); } /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface */ - public function countBy($callback) + public function countBy(callable|string $path): CollectionInterface { - $callback = $this->_propertyExtractor($callback); - - $mapper = function ($value, $key, $mr) use ($callback) { - $mr->emitIntermediate($value, $callback($value)); - }; + $callback = $this->_propertyExtractor($path); - $reducer = function ($values, $key, $mr) { - $mr->emit(count($values), $key); - }; + $mapper = fn($value, $key, MapReduce $mr) => $mr->emitIntermediate($value, $callback($value)); + $reducer = fn($values, $key, MapReduce $mr) => $mr->emit(count($values), $key); - return new Collection(new MapReduce($this->unwrap(), $mapper, $reducer)); + return $this->newCollection(new MapReduce($this->unwrap(), $mapper, $reducer)); // @phpstan-ignore return.type } /** - * {@inheritDoc} + * @inheritDoc */ - public function sumOf($matcher = null) + public function sumOf(callable|string|null $path = null): float|int { - if ($matcher === null) { + if ($path === null) { return array_sum($this->toList()); } - $callback = $this->_propertyExtractor($matcher); + $callback = $this->_propertyExtractor($path); $sum = 0; foreach ($this->optimizeUnwrap() as $k => $v) { $sum += $callback($v, $k); @@ -313,70 +439,82 @@ public function sumOf($matcher = null) /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface */ - public function shuffle() + public function shuffle(): CollectionInterface { - $elements = $this->toArray(); - shuffle($elements); + $items = $this->toList(); + shuffle($items); - return new Collection($elements); + return $this->newCollection($items); // @phpstan-ignore return.type } /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface */ - public function sample($size = 10) + public function sample(int $length = 10): CollectionInterface { - return new Collection(new LimitIterator($this->shuffle(), 0, $size)); + return $this->newCollection(new LimitIterator($this->shuffle(), 0, $length)); // @phpstan-ignore return.type } /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface */ - public function take($size = 1, $from = 0) + public function take(int $length = 1, int $offset = 0): CollectionInterface { - return new Collection(new LimitIterator($this, $from, $size)); + return $this->newCollection(new LimitIterator($this, $offset, $length)); } /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface */ - public function skip($howMany) + public function skip(int $length): CollectionInterface { - return new Collection(new LimitIterator($this, $howMany)); + return $this->newCollection(new LimitIterator($this, $length)); } /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface */ - public function match(array $conditions) + public function match(array $conditions): CollectionInterface { return $this->filter($this->_createMatcherFilter($conditions)); } /** - * {@inheritDoc} + * @inheritDoc */ - public function firstMatch(array $conditions) + public function firstMatch(array $conditions): mixed { return $this->match($conditions)->first(); } /** - * {@inheritDoc} + * @inheritDoc */ - public function first() + public function first(): mixed { $iterator = new LimitIterator($this, 0, 1); foreach ($iterator as $result) { return $result; } + + return null; } /** - * {@inheritDoc} + * @inheritDoc */ - public function last() + public function last(): mixed { $iterator = $this->optimizeUnwrap(); if (is_array($iterator)) { @@ -401,45 +539,224 @@ public function last() /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface + */ + public function takeLast(int $length): CollectionInterface + { + if ($length < 1) { + throw new InvalidArgumentException('The takeLast method requires a number greater than 0.'); + } + + $iterator = $this->optimizeUnwrap(); + if (is_array($iterator)) { + return $this->newCollection(array_slice($iterator, $length * -1)); + } + + if ($iterator instanceof Countable) { + $count = count($iterator); + + if ($count === 0) { + return $this->newCollection([]); + } + + $iterator = new LimitIterator($iterator, max(0, $count - $length), $length); + + return $this->newCollection($iterator); + } + + $generator = function ($iterator, $length): Generator { + $result = []; + $bucket = 0; + $offset = 0; + + /** + * Consider the collection of elements [1, 2, 3, 4, 5, 6, 7, 8, 9], in order + * to get the last 4 elements, we can keep a buffer of 4 elements and + * fill it circularly using modulo logic, we use the $bucket variable + * to track the position to fill next in the buffer. This how the buffer + * looks like after 4 iterations: + * + * 0) 1 2 3 4 -- $bucket now goes back to 0, we have filled 4 elementes + * 1) 5 2 3 4 -- 5th iteration + * 2) 5 6 3 4 -- 6th iteration + * 3) 5 6 7 4 -- 7th iteration + * 4) 5 6 7 8 -- 8th iteration + * 5) 9 6 7 8 + * + * We can see that at the end of the iterations, the buffer contains all + * the last four elements, just in the wrong order. How do we keep the + * original order? Well, it turns out that the number of iteration also + * give us a clue on what's going on, Let's add a marker for it now: + * + * 0) 1 2 3 4 + * ^ -- The 0) above now becomes the $offset variable + * 1) 5 2 3 4 + * ^ -- $offset = 1 + * 2) 5 6 3 4 + * ^ -- $offset = 2 + * 3) 5 6 7 4 + * ^ -- $offset = 3 + * 4) 5 6 7 8 + * ^ -- We use module logic for $offset too + * and as you can see each time $offset is 0, then the buffer + * is sorted exactly as we need. + * 5) 9 6 7 8 + * ^ -- $offset = 1 + * + * The $offset variable is a marker for splitting the buffer in two, + * elements to the right for the marker are the head of the final result, + * whereas the elements at the left are the tail. For example consider step 5) + * which has an offset of 1: + * + * - $head = elements to the right = [6, 7, 8] + * - $tail = elements to the left = [9] + * - $result = $head + $tail = [6, 7, 8, 9] + * + * The logic above applies to collections of any size. + */ + + foreach ($iterator as $k => $item) { + $result[$bucket] = [$k, $item]; + $bucket = (++$bucket) % $length; + $offset++; + } + + $offset %= $length; + $head = array_slice($result, $offset); + $tail = array_slice($result, 0, $offset); + + foreach ($head as $v) { + yield $v[0] => $v[1]; + } + + foreach ($tail as $v) { + yield $v[0] => $v[1]; + } + }; + + return $this->newCollection($generator($iterator, $length)); + } + + /** + * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface */ - public function append($items) + public function append(iterable $items): CollectionInterface { $list = new AppendIterator(); $list->append($this->unwrap()); - $list->append((new Collection($items))->unwrap()); + $list->append($this->newCollection($items)->unwrap()); - return new Collection($list); + return $this->newCollection($list); } /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface */ - public function combine($keyPath, $valuePath, $groupPath = null) + public function appendItem(mixed $item, mixed $key = null): CollectionInterface { + if ($key !== null) { + $data = [$key => $item]; + } else { + $data = [$item]; + } + + return $this->append($data); + } + + /** + * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface + */ + public function prepend(mixed $items): CollectionInterface + { + return $this->newCollection($items)->append($this); + } + + /** + * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface + */ + public function prependItem(mixed $item, mixed $key = null): CollectionInterface + { + if ($key !== null) { + $data = [$key => $item]; + } else { + $data = [$item]; + } + + return $this->prepend($data); + } + + /** + * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface + */ + public function combine( + callable|string $keyPath, + callable|string $valuePath, + callable|string|null $groupPath = null, + ): CollectionInterface { $options = [ 'keyPath' => $this->_propertyExtractor($keyPath), 'valuePath' => $this->_propertyExtractor($valuePath), - 'groupPath' => $groupPath ? $this->_propertyExtractor($groupPath) : null + 'groupPath' => $groupPath ? $this->_propertyExtractor($groupPath) : null, ]; - $mapper = function ($value, $key, $mapReduce) use ($options) { + $mapper = function ($value, $key, MapReduce $mapReduce) use ($options) { $rowKey = $options['keyPath']; $rowVal = $options['valuePath']; if (!$options['groupPath']) { - $mapReduce->emit($rowVal($value, $key), $rowKey($value, $key)); + $mapKey = $rowKey($value, $key); + if ($mapKey === null) { + throw new InvalidArgumentException( + 'Cannot index by path that does not exist or contains a null value. ' . + 'Use a callback to return a default value for that path.', + ); + } + + if ($mapKey instanceof BackedEnum) { + $mapKey = $mapKey->value; + } elseif ($mapKey instanceof UnitEnum) { + $mapKey = $mapKey->name; + } + + $mapReduce->emit($rowVal($value, $key), $mapKey); return null; } $key = $options['groupPath']($value, $key); + if ($key === null) { + throw new InvalidArgumentException( + 'Cannot group by path that does not exist or contains a null value. ' . + 'Use a callback to return a default value for that path.', + ); + } + + $mapKey = $rowKey($value, $key); + if ($mapKey === null) { + throw new InvalidArgumentException( + 'Cannot index by path that does not exist or contains a null value. ' . + 'Use a callback to return a default value for that path.', + ); + } + $mapReduce->emitIntermediate( - [$rowKey($value, $key) => $rowVal($value, $key)], - $key + [$mapKey => $rowVal($value, $key)], + $key, ); }; - $reducer = function ($values, $key, $mapReduce) { + $reducer = function ($values, $key, MapReduce $mapReduce): void { $result = []; foreach ($values as $value) { $result += $value; @@ -447,34 +764,39 @@ public function combine($keyPath, $valuePath, $groupPath = null) $mapReduce->emit($result, $key); }; - return new Collection(new MapReduce($this->unwrap(), $mapper, $reducer)); + return $this->newCollection(new MapReduce($this->unwrap(), $mapper, $reducer)); } /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface */ - public function nest($idPath, $parentPath, $nestingKey = 'children') - { + public function nest( + callable|string $idPath, + callable|string $parentPath, + string $nestingKey = 'children', + ): CollectionInterface { $parents = []; $idPath = $this->_propertyExtractor($idPath); $parentPath = $this->_propertyExtractor($parentPath); $isObject = true; - $mapper = function ($row, $key, $mapReduce) use (&$parents, $idPath, $parentPath, $nestingKey) { + $mapper = function ($row, $key, MapReduce $mapReduce) use (&$parents, $idPath, $parentPath, $nestingKey): void { $row[$nestingKey] = []; $id = $idPath($row, $key); $parentId = $parentPath($row, $key); - $parents[$id] =& $row; + $parents[$id] = &$row; $mapReduce->emitIntermediate($id, $parentId); }; - $reducer = function ($values, $key, $mapReduce) use (&$parents, &$isObject, $nestingKey) { + $reducer = function ($values, $key, MapReduce $mapReduce) use (&$parents, &$isObject, $nestingKey) { static $foundOutType = false; if (!$foundOutType) { $isObject = is_object(current($parents)); $foundOutType = true; } - if (empty($key) || !isset($parents[$key])) { + if (!$key || !isset($parents[$key])) { foreach ($values as $id) { $parents[$id] = $isObject ? $parents[$id] : new ArrayIterator($parents[$id], 1); $mapReduce->emit($parents[$id]); @@ -485,13 +807,14 @@ public function nest($idPath, $parentPath, $nestingKey = 'children') $children = []; foreach ($values as $id) { - $children[] =& $parents[$id]; + $children[] = &$parents[$id]; } $parents[$key][$nestingKey] = $children; }; - return (new Collection(new MapReduce($this->unwrap(), $mapper, $reducer))) - ->map(function ($value) use (&$isObject) { + return $this->newCollection(new MapReduce($this->unwrap(), $mapper, $reducer)) + ->map(function ($value) use ($isObject) { + /** @var \ArrayIterator|\ArrayObject $value */ return $isObject ? $value : $value->getArrayCopy(); }); } @@ -499,93 +822,135 @@ public function nest($idPath, $parentPath, $nestingKey = 'children') /** * {@inheritDoc} * - * @return \Cake\Collection\Iterator\InsertIterator + * @return \Cake\Collection\CollectionInterface */ - public function insert($path, $values) + public function insert(string $path, mixed $values): CollectionInterface { return new InsertIterator($this->unwrap(), $path, $values); } /** - * {@inheritDoc} + * @inheritDoc */ - public function toArray($preserveKeys = true) + public function toArray(bool $keepKeys = true): array { $iterator = $this->unwrap(); if ($iterator instanceof ArrayIterator) { $items = $iterator->getArrayCopy(); - return $preserveKeys ? $items : array_values($items); + return $keepKeys ? $items : array_values($items); } // RecursiveIteratorIterator can return duplicate key values causing // data loss when converted into an array - if ($preserveKeys && get_class($iterator) === 'RecursiveIteratorIterator') { - $preserveKeys = false; + if ($keepKeys && $iterator::class === RecursiveIteratorIterator::class) { + $keepKeys = false; } - return iterator_to_array($this, $preserveKeys); + return iterator_to_array($this, $keepKeys); } /** - * {@inheritDoc} + * @inheritDoc */ - public function toList() + public function toList(): array { return $this->toArray(false); } /** - * {@inheritDoc} + * @inheritDoc */ - public function jsonSerialize() + public function jsonSerialize(): array { return $this->toArray(); } /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface */ - public function compile($preserveKeys = true) + public function compile(bool $keepKeys = true): CollectionInterface { - return new Collection($this->toArray($preserveKeys)); + return $this->newCollection($this->toArray($keepKeys)); } /** * {@inheritDoc} * - * @return \Cake\Collection\Iterator\BufferedIterator + * @return \Cake\Collection\CollectionInterface */ - public function buffered() + public function lazy(): CollectionInterface { - return new BufferedIterator($this->unwrap()); + $generator = function (): Generator { + foreach ($this->unwrap() as $k => $v) { + yield $k => $v; + } + }; + + return $this->newCollection($generator()); } /** * {@inheritDoc} * - * @return \Cake\Collection\Iterator\TreeIterator + * @return \Cake\Collection\CollectionInterface */ - public function listNested($dir = 'desc', $nestingKey = 'children') + public function buffered(): CollectionInterface { - $dir = strtolower($dir); - $modes = [ - 'desc' => TreeIterator::SELF_FIRST, - 'asc' => TreeIterator::CHILD_FIRST, - 'leaves' => TreeIterator::LEAVES_ONLY - ]; + return new BufferedIterator($this->unwrap()); + } + + /** + * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface + */ + public function listNested( + string|int $order = 'desc', + callable|string $nestingKey = 'children', + ): CollectionInterface { + if (is_string($order)) { + $order = strtolower($order); + $modes = [ + 'desc' => RecursiveIteratorIterator::SELF_FIRST, + 'asc' => RecursiveIteratorIterator::CHILD_FIRST, + 'leaves' => RecursiveIteratorIterator::LEAVES_ONLY, + ]; + + if (!isset($modes[$order])) { + throw new InvalidArgumentException(sprintf( + "Invalid direction `%s` provided. Must be one of: 'desc', 'asc', 'leaves'.", + $order, + )); + } + $order = $modes[$order]; + } + + assert( + in_array( + $order, + [ + RecursiveIteratorIterator::LEAVES_ONLY, + RecursiveIteratorIterator::SELF_FIRST, + RecursiveIteratorIterator::CHILD_FIRST, + ], + true, + ), + ); return new TreeIterator( new NestIterator($this, $nestingKey), - isset($modes[$dir]) ? $modes[$dir] : $dir + $order, ); } /** * {@inheritDoc} * - * @return \Cake\Collection\Iterator\StoppableIterator + * @return \Cake\Collection\CollectionInterface */ - public function stopWhen($condition) + public function stopWhen(callable|array $condition): CollectionInterface { if (!is_callable($condition)) { $condition = $this->_createMatcherFilter($condition); @@ -596,62 +961,72 @@ public function stopWhen($condition) /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface */ - public function unfold(callable $transformer = null) + public function unfold(?callable $callback = null): CollectionInterface { - if ($transformer === null) { - $transformer = function ($item) { - return $item; - }; - } + $callback ??= fn($v) => $v; - return new Collection( + return $this->newCollection( new RecursiveIteratorIterator( - new UnfoldIterator($this->unwrap(), $transformer), - RecursiveIteratorIterator::LEAVES_ONLY - ) + new UnfoldIterator($this->unwrap(), $callback), + RecursiveIteratorIterator::LEAVES_ONLY, + ), ); } /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface */ - public function through(callable $handler) + public function through(callable $callback): CollectionInterface { - $result = $handler($this); + $result = $callback($this); - return $result instanceof CollectionInterface ? $result : new Collection($result); + return $result instanceof CollectionInterface ? $result : $this->newCollection($result); } /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface */ - public function zip($items) + public function zip(iterable ...$items): CollectionInterface { - return new ZipIterator(array_merge([$this->unwrap()], func_get_args())); + return new ZipIterator(array_merge([$this->unwrap()], $items)); } /** * {@inheritDoc} + * + * @param iterable $items Items to zip. + * @param callable $callback The callback to apply. + * @return \Cake\Collection\CollectionInterface */ - public function zipWith($items, $callable) + public function zipWith(iterable $items, mixed $callback): CollectionInterface { if (func_num_args() > 2) { $items = func_get_args(); - $callable = array_pop($items); + $callback = array_pop($items); } else { $items = [$items]; } - return new ZipIterator(array_merge([$this->unwrap()], $items), $callable); + /** @var callable $callback */ + return new ZipIterator(array_merge([$this->unwrap()], $items), $callback); } /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface> */ - public function chunk($chunkSize) + public function chunk(int $chunkSize): CollectionInterface { - return $this->map(function ($v, $k, $iterator) use ($chunkSize) { + // @phpstan-ignore return.type + return $this->map(function ($v, $k, Iterator $iterator) use ($chunkSize) { $values = [$v]; for ($i = 1; $i < $chunkSize; $i++) { $iterator->next(); @@ -667,12 +1042,15 @@ public function chunk($chunkSize) /** * {@inheritDoc} + * + * @return \Cake\Collection\CollectionInterface> */ - public function chunkWithKeys($chunkSize, $preserveKeys = true) + public function chunkWithKeys(int $chunkSize, bool $keepKeys = true): CollectionInterface { - return $this->map(function ($v, $k, $iterator) use ($chunkSize, $preserveKeys) { + // @phpstan-ignore return.type + return $this->map(function ($v, $k, Iterator $iterator) use ($chunkSize, $keepKeys) { $key = 0; - if ($preserveKeys) { + if ($keepKeys) { $key = $k; } $values = [$key => $v]; @@ -681,7 +1059,7 @@ public function chunkWithKeys($chunkSize, $preserveKeys = true) if (!$iterator->valid()) { break; } - if ($preserveKeys) { + if ($keepKeys) { $values[$iterator->key()] = $iterator->current(); } else { $values[] = $iterator->current(); @@ -693,10 +1071,11 @@ public function chunkWithKeys($chunkSize, $preserveKeys = true) } /** - * {@inheritDoc} + * @inheritDoc */ - public function isEmpty() + public function isEmpty(): bool { + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable foreach ($this as $el) { return false; } @@ -705,43 +1084,47 @@ public function isEmpty() } /** - * {@inheritDoc} + * @inheritDoc */ - public function unwrap() + public function unwrap(): Iterator { $iterator = $this; - while (get_class($iterator) === 'Cake\Collection\Collection') { + // Unwrap Collection class and simple user subclasses. + // Internal CakePHP iterators/result sets have their own unwrap() implementations. + // We unwrap if the class is Collection itself, or a non-Cake subclass, + // or an anonymous class extending Collection. + while ( + $iterator instanceof Collection && + ( + $iterator::class === Collection::class || + !str_starts_with($iterator::class, 'Cake\\') || + str_contains($iterator::class, '@anonymous') + ) + ) { $iterator = $iterator->getInnerIterator(); } if ($iterator !== $this && $iterator instanceof CollectionInterface) { - $iterator = $iterator->unwrap(); + return $iterator->unwrap(); } + /** @var \Iterator */ return $iterator; } - /** - * Backwards compatible wrapper for unwrap() - * - * @return \Traversable - * @deprecated - */ - // @codingStandardsIgnoreLine - public function _unwrap() - { - return $this->unwrap(); - } - /** * {@inheritDoc} * - * @return \Cake\Collection\CollectionInterface + * @param callable|null $operation A callable that allows you to customize the product result. + * @param callable|null $filter A filtering callback that must return true for a result to be part + * of the final results. + * @return \Cake\Collection\CollectionInterface> + * @throws \LogicException */ - public function cartesianProduct(callable $operation = null, callable $filter = null) + public function cartesianProduct(?callable $operation = null, ?callable $filter = null): CollectionInterface { if ($this->isEmpty()) { - return new Collection([]); + return $this->newCollection([]); // @phpstan-ignore return.type } $collectionArrays = []; @@ -749,11 +1132,14 @@ public function cartesianProduct(callable $operation = null, callable $filter = $collectionArraysCounts = []; foreach ($this->toList() as $value) { + /** @phpstan-ignore argument.type (cartesianProduct requires array values) */ $valueCount = count($value); + /** @phpstan-ignore argument.type */ if ($valueCount !== count($value, COUNT_RECURSIVE)) { throw new LogicException('Cannot find the cartesian product of a multidimensional array'); } + /** @phpstan-ignore argument.type (cartesianProduct requires array values) */ $collectionArraysKeys[] = array_keys($value); $collectionArraysCounts[] = $valueCount; $collectionArrays[] = $value; @@ -767,37 +1153,44 @@ public function cartesianProduct(callable $operation = null, callable $filter = $changeIndex = $lastIndex; while (!($changeIndex === 0 && $currentIndexes[0] === $collectionArraysCounts[0])) { - $currentCombination = array_map(function ($value, $keys, $index) { + $currentCombination = array_map(function ($value, array $keys, $index) { return $value[$keys[$index]]; }, $collectionArrays, $collectionArraysKeys, $currentIndexes); if ($filter === null || $filter($currentCombination)) { - $result[] = ($operation === null) ? $currentCombination : $operation($currentCombination); + $result[] = $operation === null ? $currentCombination : $operation($currentCombination); } $currentIndexes[$lastIndex]++; - for ($changeIndex = $lastIndex; $currentIndexes[$changeIndex] === $collectionArraysCounts[$changeIndex] && $changeIndex > 0; $changeIndex--) { + for ( + $changeIndex = $lastIndex; + $currentIndexes[$changeIndex] === $collectionArraysCounts[$changeIndex] && $changeIndex > 0; + $changeIndex-- + ) { $currentIndexes[$changeIndex] = 0; $currentIndexes[$changeIndex - 1]++; } } - return new Collection($result); + return $this->newCollection($result); // @phpstan-ignore return.type } /** * {@inheritDoc} * - * @return \Cake\Collection\CollectionInterface + * @return \Cake\Collection\CollectionInterface> + * @throws \LogicException */ - public function transpose() + public function transpose(): CollectionInterface { $arrayValue = $this->toList(); + /** @phpstan-ignore argument.type (transpose requires array values) */ $length = count(current($arrayValue)); $result = []; - foreach ($arrayValue as $column => $row) { - if (count($row) != $length) { + foreach ($arrayValue as $row) { + /** @phpstan-ignore argument.type (transpose requires array values) */ + if (count($row) !== $length) { throw new LogicException('Child arrays do not have even length'); } } @@ -806,21 +1199,116 @@ public function transpose() $result[] = array_column($arrayValue, $column); } - return new Collection($result); + return $this->newCollection($result); // @phpstan-ignore return.type + } + + /** + * @inheritDoc + */ + public function count(): int + { + $traversable = $this->optimizeUnwrap(); + + if (is_array($traversable)) { + return count($traversable); + } + + return iterator_count($traversable); + } + + /** + * @inheritDoc + */ + public function countKeys(): int + { + return count($this->toArray()); + } + + /** + * Returns a new collection containing only the keys of the elements. + * + * @return \Cake\Collection\CollectionInterface + */ + public function keys(): CollectionInterface + { + $generator = function (): Generator { + foreach ($this->optimizeUnwrap() as $key => $value) { + yield $key; + } + }; + + return $this->newCollection($generator()); + } + + /** + * Returns a new collection containing only the values, re-indexed with consecutive integers. + * + * @return \Cake\Collection\CollectionInterface + */ + public function values(): CollectionInterface + { + $generator = function (): Generator { + foreach ($this->optimizeUnwrap() as $value) { + yield $value; + } + }; + + return $this->newCollection($generator()); + } + + /** + * @inheritDoc + */ + public function implode(string $glue, callable|string|null $path = null): string + { + $items = $this; + if ($path !== null) { + $items = $items->extract($path); + } + + return implode($glue, $items->toList()); + } + + /** + * Applies callback if condition is truthy. + * + * @return \Cake\Collection\CollectionInterface + */ + public function when(mixed $condition, callable $callback): CollectionInterface + { + if ($condition) { + return $callback($this, $condition); + } + + return $this; + } + + /** + * Applies callback if condition is falsy. + * + * @return \Cake\Collection\CollectionInterface + */ + public function unless(mixed $condition, callable $callback): CollectionInterface + { + if (!$condition) { + return $callback($this, $condition); + } + + return $this; } /** * Unwraps this iterator and returns the simplest * traversable that can be used for getting the data out * - * @return \Traversable|array + * @return \Iterator|array */ - protected function optimizeUnwrap() + protected function optimizeUnwrap(): Iterator|array { $iterator = $this->unwrap(); - if (get_class($iterator) === ArrayIterator::class) { - $iterator = $iterator->getArrayCopy(); + if ($iterator::class === ArrayIterator::class) { + return $iterator->getArrayCopy(); } return $iterator; diff --git a/src/Collection/ExtractTrait.php b/src/Collection/ExtractTrait.php index 2beb22dd393..dadff648ac9 100644 --- a/src/Collection/ExtractTrait.php +++ b/src/Collection/ExtractTrait.php @@ -1,4 +1,6 @@ _extract($element, $path); - }; + if (str_contains($path, '{*}')) { + return fn($element) => $this->_extract($element, $parts); } - return function ($element) use ($path) { - return $this->_simpleExtract($element, $path); + return function ($element) use ($parts) { + if (!is_array($element) && !$element instanceof ArrayAccess) { + return null; + } + + return $this->_simpleExtract($element, $parts); }; } @@ -56,28 +60,30 @@ protected function _propertyExtractor($callback) * by iterating over the column names contained in $path. * It will return arrays for elements in represented with `{*}` * - * @param array|\ArrayAccess $data Data. - * @param array $path Path to extract from. + * @param \ArrayAccess|array $data Data. + * @param array $parts Path to extract from. * @return mixed */ - protected function _extract($data, $path) + protected function _extract(ArrayAccess|array $data, array $parts): mixed { $value = null; $collectionTransform = false; - foreach ($path as $i => $column) { + foreach ($parts as $i => $column) { if ($column === '{*}') { $collectionTransform = true; continue; } - if ($collectionTransform && - !($data instanceof Traversable || is_array($data))) { + if ( + $collectionTransform && + !is_iterable($data) + ) { return null; } if ($collectionTransform) { - $rest = implode('.', array_slice($path, $i)); + $rest = implode('.', array_slice($parts, $i)); return (new Collection($data))->extract($rest); } @@ -97,14 +103,14 @@ protected function _extract($data, $path) * Returns a column from $data that can be extracted * by iterating over the column names contained in $path * - * @param array|\ArrayAccess $data Data. - * @param array $path Path to extract from. + * @param \ArrayAccess|array $data Data. + * @param array $parts Path to extract from. * @return mixed */ - protected function _simpleExtract($data, $path) + protected function _simpleExtract(ArrayAccess|array $data, array $parts): mixed { $value = null; - foreach ($path as $column) { + foreach ($parts as $column) { if (!isset($data[$column])) { return null; } @@ -116,20 +122,20 @@ protected function _simpleExtract($data, $path) } /** - * Returns a callable that receives a value and will return whether or not + * Returns a callable that receives a value and will return whether * it matches certain condition. * * @param array $conditions A key-value list of conditions to match where the * key is the property path to get from the current item and the value is the * value to be compared the item with. - * @return callable + * @return \Closure */ - protected function _createMatcherFilter(array $conditions) + protected function _createMatcherFilter(array $conditions): Closure { $matchers = []; foreach ($conditions as $property => $value) { $extractor = $this->_propertyExtractor($property); - $matchers[] = function ($v) use ($extractor, $value) { + $matchers[] = function ($v) use ($extractor, $value): bool { return $extractor($v) == $value; }; } diff --git a/src/Collection/Iterator/BufferedIterator.php b/src/Collection/Iterator/BufferedIterator.php index cc7adf0c5f3..fcd689fcd3b 100644 --- a/src/Collection/Iterator/BufferedIterator.php +++ b/src/Collection/Iterator/BufferedIterator.php @@ -1,4 +1,6 @@ */ -class BufferedIterator extends Collection implements Countable, Serializable +class BufferedIterator extends Collection { - /** * The in-memory cache containing results from previous iterators * - * @var \SplDoublyLinkedList + * @var \SplDoublyLinkedList */ - protected $_buffer; + protected SplDoublyLinkedList $_buffer; /** * Points to the next record number that should be fetched * * @var int */ - protected $_index = 0; + protected int $_index = 0; /** * Last record fetched from the inner iterator * * @var mixed */ - protected $_current; + protected mixed $_current; /** * Last key obtained from the inner iterator * * @var mixed */ - protected $_key; + protected mixed $_key; /** - * Whether or not the internal iterator's rewind method was already + * Whether the internal iterator's rewind method was already * called * * @var bool */ - protected $_started = false; + protected bool $_started = false; /** - * Whether or not the internal iterator has reached its end. + * Whether the internal iterator has reached its end. * * @var bool */ - protected $_finished = false; + protected bool $_finished = false; /** * Maintains an in-memory cache of the results yielded by the internal * iterator. * - * @param array|\Traversable $items The items to be filtered. + * @param iterable $items The items to be filtered. */ - public function __construct($items) + public function __construct(iterable $items) { $this->_buffer = new SplDoublyLinkedList(); parent::__construct($items); @@ -86,7 +89,7 @@ public function __construct($items) * * @return mixed */ - public function key() + public function key(): mixed { return $this->_key; } @@ -96,7 +99,7 @@ public function key() * * @return mixed */ - public function current() + public function current(): mixed { return $this->_current; } @@ -106,7 +109,7 @@ public function current() * * @return void */ - public function rewind() + public function rewind(): void { if ($this->_index === 0 && !$this->_started) { $this->_started = true; @@ -119,11 +122,11 @@ public function rewind() } /** - * Returns whether or not the iterator has more elements + * Returns whether the iterator has more elements * * @return bool */ - public function valid() + public function valid(): bool { if ($this->_buffer->offsetExists($this->_index)) { $current = $this->_buffer->offsetGet($this->_index); @@ -140,7 +143,7 @@ public function valid() $this->_key = parent::key(); $this->_buffer->push([ 'key' => $this->_key, - 'value' => $this->_current + 'value' => $this->_current, ]); } @@ -154,21 +157,25 @@ public function valid() * * @return void */ - public function next() + public function next(): void { $this->_index++; + // Don't move inner iterator if we have more buffer + if ($this->_buffer->offsetExists($this->_index)) { + return; + } if (!$this->_finished) { parent::next(); } } /** - * Returns the number or items in this collection + * Returns the number of items in this collection. * * @return int */ - public function count() + public function count(): int { if (!$this->_started) { $this->rewind(); @@ -182,30 +189,33 @@ public function count() } /** - * Returns a string representation of this object that can be used - * to reconstruct it + * Magic method used for serializing the iterator instance. * - * @return string + * @return array */ - public function serialize() + public function __serialize(): array { if (!$this->_finished) { $this->count(); } - return serialize($this->_buffer); + return iterator_to_array($this->_buffer); } /** - * Unserializes the passed string and rebuilds the BufferedIterator instance + * Magic method used to rebuild the iterator instance. * - * @param string $buffer The serialized buffer iterator + * @param array $data Data array. * @return void */ - public function unserialize($buffer) + public function __unserialize(array $data): void { $this->__construct([]); - $this->_buffer = unserialize($buffer); + + foreach ($data as $value) { + $this->_buffer->push($value); + } + $this->_started = true; $this->_finished = true; } diff --git a/src/Collection/Iterator/ExtractIterator.php b/src/Collection/Iterator/ExtractIterator.php index 2ffe139b77c..769d244fef4 100644 --- a/src/Collection/Iterator/ExtractIterator.php +++ b/src/Collection/Iterator/ExtractIterator.php @@ -1,4 +1,6 @@ */ class ExtractIterator extends Collection { - /** * A callable responsible for extracting a single value for each * item in the collection. @@ -43,17 +48,18 @@ class ExtractIterator extends Collection * * ``` * $items = [ - * ['comment' => ['body' => 'cool', 'user' => ['name' => 'Mark']], - * ['comment' => ['body' => 'very cool', 'user' => ['name' => 'Renan']] + * ['comment' => ['body' => 'cool', 'user' => ['name' => 'Mark']]], + * ['comment' => ['body' => 'very cool', 'user' => ['name' => 'Renan']]], * ]; - * $extractor = new ExtractIterator($items, 'comment.user.name''); + * $extractor = new ExtractIterator($items, 'comment.user.name'); * ``` * - * @param array|\Traversable $items The list of values to iterate - * @param string $path a dot separated string symbolizing the path to follow - * inside the hierarchy of each value so that the column can be extracted. + * @param iterable $items The list of values to iterate + * @param callable|string $path A dot separated path of column to follow + * so that the final one can be returned or a callable that will take care + * of doing that. */ - public function __construct($items, $path) + public function __construct(iterable $items, callable|string $path) { $this->_extractor = $this->_propertyExtractor($path); parent::__construct($items); @@ -65,7 +71,7 @@ public function __construct($items, $path) * * @return mixed */ - public function current() + public function current(): mixed { $extractor = $this->_extractor; @@ -73,14 +79,9 @@ public function current() } /** - * {@inheritDoc} - * - * We perform here some strictness analysis so that the - * iterator logic is bypassed entirely. - * - * @return \Iterator + * @inheritDoc */ - public function unwrap() + public function unwrap(): Iterator { $iterator = $this->getInnerIterator(); @@ -88,7 +89,7 @@ public function unwrap() $iterator = $iterator->unwrap(); } - if (get_class($iterator) !== ArrayIterator::class) { + if ($iterator::class !== ArrayIterator::class) { return $this; } diff --git a/src/Collection/Iterator/FilterIterator.php b/src/Collection/Iterator/FilterIterator.php index 4612f2b9f09..3095ac37306 100644 --- a/src/Collection/Iterator/FilterIterator.php +++ b/src/Collection/Iterator/FilterIterator.php @@ -1,4 +1,6 @@ */ class FilterIterator extends Collection { - /** * The callback used to filter the elements in this collection * @@ -43,10 +48,10 @@ class FilterIterator extends Collection * in the current iteration, the key of the element and the passed $items iterator * as arguments, in that order. * - * @param \Iterator $items The items to be filtered. + * @param iterable $items The items to be filtered. * @param callable $callback Callback. */ - public function __construct($items, callable $callback) + public function __construct(iterable $items, callable $callback) { if (!$items instanceof Iterator) { $items = new Collection($items); @@ -58,15 +63,11 @@ public function __construct($items, callable $callback) } /** - * {@inheritDoc} - * - * We perform here some strictness analysis so that the - * iterator logic is bypassed entirely. - * - * @return \Iterator + * @inheritDoc */ - public function unwrap() + public function unwrap(): Iterator { + /** @var \IteratorIterator> $filter */ $filter = $this->getInnerIterator(); $iterator = $filter->getInnerIterator(); @@ -74,13 +75,12 @@ public function unwrap() $iterator = $iterator->unwrap(); } - if (get_class($iterator) !== ArrayIterator::class) { + if ($iterator::class !== ArrayIterator::class) { return $filter; } // ArrayIterator can be traversed strictly. // Let's do that for performance gains - $callback = $this->_callback; $res = []; diff --git a/src/Collection/Iterator/InsertIterator.php b/src/Collection/Iterator/InsertIterator.php index 89411169de5..36163f1c598 100644 --- a/src/Collection/Iterator/InsertIterator.php +++ b/src/Collection/Iterator/InsertIterator.php @@ -1,4 +1,6 @@ */ class InsertIterator extends Collection { - /** * The collection from which to extract the values to be inserted * - * @var \Cake\Collection\Collection + * @var \Cake\Collection\Collection */ - protected $_values; + protected Collection $_values; /** * Holds whether the values collection is still valid. (has more records) * * @var bool */ - protected $_validValues = true; + protected bool $_validValues = true; /** * An array containing each of the properties to be traversed to reach the * point where the values should be inserted. * - * @var array + * @var array */ - protected $_path; + protected array $_path; /** * The property name to which values will be assigned * * @var string */ - protected $_target; + protected string $_target; /** * Constructs a new collection that will dynamically add properties to it out of * the values found in $values. * - * @param array|\Traversable $into The target collection to which the values will + * @param iterable $into The target collection to which the values will * be inserted at the specified path. * @param string $path A dot separated list of properties that need to be traversed * to insert the value into the target collection. - * @param array|\Traversable $values The source collection from which the values will + * @param iterable $values The source collection from which the values will * be inserted at the specified path. */ - public function __construct($into, $path, $values) + public function __construct(iterable $into, string $path, iterable $values) { parent::__construct($into); @@ -85,7 +90,7 @@ public function __construct($into, $path, $values) * * @return void */ - public function next() + public function next(): void { parent::next(); if ($this->_validValues) { @@ -100,7 +105,7 @@ public function next() * * @return mixed */ - public function current() + public function current(): mixed { $row = parent::current(); @@ -108,12 +113,12 @@ public function current() return $row; } - $pointer =& $row; + $pointer = &$row; foreach ($this->_path as $step) { if (!isset($pointer[$step])) { return $row; } - $pointer =& $pointer[$step]; + $pointer = &$pointer[$step]; } $pointer[$this->_target] = $this->_values->current(); @@ -126,7 +131,7 @@ public function current() * * @return void */ - public function rewind() + public function rewind(): void { parent::rewind(); $this->_values->rewind(); diff --git a/src/Collection/Iterator/MapReduce.php b/src/Collection/Iterator/MapReduce.php index b7449d720c5..e5b301bd5a1 100644 --- a/src/Collection/Iterator/MapReduce.php +++ b/src/Collection/Iterator/MapReduce.php @@ -1,4 +1,6 @@ */ class MapReduce implements IteratorAggregate { - /** - * Holds the shuffled results that were emitted from the map - * phase + * Holds the shuffled results emitted from the map phase * * @var array */ - protected $_intermediate = []; + protected array $_intermediate = []; /** * Holds the results as emitted during the reduce phase * * @var array */ - protected $_result = []; + protected array $_result = []; /** * Whether the Map-Reduce routine has been executed already on the data * * @var bool */ - protected $_executed = false; + protected bool $_executed = false; /** * Holds the original data that needs to be processed * - * @var \Traversable|null + * @var iterable */ - protected $_data; + protected iterable $_data; /** * A callable that will be executed for each record in the original data @@ -67,7 +69,7 @@ class MapReduce implements IteratorAggregate * A callable that will be executed for each intermediate record emitted during * the Map phase * - * @var callable + * @var callable|null */ protected $_reducer; @@ -76,7 +78,7 @@ class MapReduce implements IteratorAggregate * * @var int */ - protected $_counter = 0; + protected int $_counter = 0; /** * Constructor @@ -104,7 +106,7 @@ class MapReduce implements IteratorAggregate * ['odd' => [1, 3, 5], 'even' => [2, 4]] * ``` * - * @param \Traversable $data the original data to be processed + * @param iterable $data The original data to be processed. * @param callable $mapper the mapper callback. This function will receive 3 arguments. * The first one is the current value, second the current results key and third is * this class instance so you can call the result emitters. @@ -113,7 +115,7 @@ class MapReduce implements IteratorAggregate * of the bucket that was created during the mapping phase and third one is an * instance of this class. */ - public function __construct(Traversable $data, callable $mapper, callable $reducer = null) + public function __construct(iterable $data, callable $mapper, ?callable $reducer = null) { $this->_data = $data; $this->_mapper = $mapper; @@ -124,9 +126,9 @@ public function __construct(Traversable $data, callable $mapper, callable $reduc * Returns an iterator with the end result of running the Map and Reduce * phases on the original data * - * @return \ArrayIterator + * @return \Traversable */ - public function getIterator() + public function getIterator(): Traversable { if (!$this->_executed) { $this->_execute(); @@ -136,16 +138,23 @@ public function getIterator() } /** - * Appends a new record to the bucket labelled with $key, usually as a result + * Appends a new record to the bucket labeled with $key, usually as a result * of mapping a single record from the original data. * * @param mixed $val The record itself to store in the bucket - * @param string $bucket the name of the bucket where to put the record + * @param mixed $bucket the name of the bucket where to put the record + * @param mixed $key An optional key to assign to the value * @return void */ - public function emitIntermediate($val, $bucket) + public function emitIntermediate(mixed $val, mixed $bucket, mixed $key = null): void { - $this->_intermediate[$bucket][] = $val; + if ($key === null) { + $this->_intermediate[$bucket ?? ''][] = $val; + + return; + } + + $this->_intermediate[$bucket][$key] = $val; } /** @@ -153,39 +162,40 @@ public function emitIntermediate($val, $bucket) * for this record. * * @param mixed $val The value to be appended to the final list of results - * @param string|null $key and optional key to assign to the value + * @param mixed $key An optional key to assign to the value * @return void */ - public function emit($val, $key = null) + public function emit(mixed $val, mixed $key = null): void { - $this->_result[$key === null ? $this->_counter : $key] = $val; + $this->_result[$key ?? $this->_counter] = $val; $this->_counter++; } /** - * Runs the actual Map-Reduce algorithm. This is iterate the original data - * and call the mapper function for each , then for each intermediate + * Runs the actual Map-Reduce algorithm. This iterates the original data + * and calls the mapper function for each record, then for each intermediate * bucket created during the Map phase call the reduce function. * * @return void * @throws \LogicException if emitIntermediate was called but no reducer function * was provided */ - protected function _execute() + protected function _execute(): void { $mapper = $this->_mapper; foreach ($this->_data as $key => $val) { $mapper($val, $key, $this); } - $this->_data = null; - if (!empty($this->_intermediate) && empty($this->_reducer)) { + if ($this->_intermediate && $this->_reducer === null) { throw new LogicException('No reducer function was provided'); } $reducer = $this->_reducer; - foreach ($this->_intermediate as $key => $list) { - $reducer($list, $key, $this); + if ($reducer !== null) { + foreach ($this->_intermediate as $key => $list) { + $reducer($list, $key, $this); + } } $this->_intermediate = []; $this->_executed = true; diff --git a/src/Collection/Iterator/NestIterator.php b/src/Collection/Iterator/NestIterator.php index ac9e6cb3b57..2735cfdbf6e 100644 --- a/src/Collection/Iterator/NestIterator.php +++ b/src/Collection/Iterator/NestIterator.php @@ -1,4 +1,6 @@ + * @implements \RecursiveIterator */ class NestIterator extends Collection implements RecursiveIterator { - /** * The name of the property that contains the nested items for each element * - * @var string|callable + * @var callable|string */ protected $_nestKey; /** * Constructor * - * @param array|\Traversable $items Collection items. - * @param string|callable $nestKey the property that contains the nested items - * If a callable is passed, it should return the childrens for the passed item + * @param iterable $items Collection items. + * @param callable|string $nestKey the property that contains the nested items + * If a callable is passed, it should return the children for the passed item */ - public function __construct($items, $nestKey) + public function __construct(iterable $items, callable|string $nestKey) { parent::__construct($items); $this->_nestKey = $nestKey; @@ -48,9 +52,9 @@ public function __construct($items, $nestKey) /** * Returns a traversable containing the children for the current item * - * @return \Traversable + * @return \RecursiveIterator */ - public function getChildren() + public function getChildren(): RecursiveIterator { $property = $this->_propertyExtractor($this->_nestKey); @@ -63,13 +67,13 @@ public function getChildren() * * @return bool */ - public function hasChildren() + public function hasChildren(): bool { $property = $this->_propertyExtractor($this->_nestKey); $children = $property($this->current()); if (is_array($children)) { - return !empty($children); + return $children !== []; } return $children instanceof Traversable; diff --git a/src/Collection/Iterator/NoChildrenIterator.php b/src/Collection/Iterator/NoChildrenIterator.php index aaf1fe6ac05..dcd948b420c 100644 --- a/src/Collection/Iterator/NoChildrenIterator.php +++ b/src/Collection/Iterator/NoChildrenIterator.php @@ -1,4 +1,6 @@ + * @implements \RecursiveIterator */ class NoChildrenIterator extends Collection implements RecursiveIterator { - /** * Returns false as there are no children iterators in this collection * * @return bool */ - public function hasChildren() + public function hasChildren(): bool { return false; } /** - * Returns null as there are no children for this iteration level + * Returns a self instance without any elements. * - * @return null + * @return \RecursiveIterator */ - public function getChildren() + public function getChildren(): RecursiveIterator { - return null; + return new static([]); } } diff --git a/src/Collection/Iterator/ReplaceIterator.php b/src/Collection/Iterator/ReplaceIterator.php index ad9840c079d..1f2bb10ecb9 100644 --- a/src/Collection/Iterator/ReplaceIterator.php +++ b/src/Collection/Iterator/ReplaceIterator.php @@ -1,4 +1,6 @@ */ class ReplaceIterator extends Collection { - /** * The callback function to be used to transform values * @@ -35,9 +41,9 @@ class ReplaceIterator extends Collection /** * A reference to the internal iterator this object is wrapping. * - * @var \Iterator + * @var \Traversable */ - protected $_innerIterator; + protected Traversable $_innerIterator; /** * Creates an iterator from another iterator that will modify each of the values @@ -47,10 +53,10 @@ class ReplaceIterator extends Collection * in the current iteration, the key of the element and the passed $items iterator * as arguments, in that order. * - * @param array|\Traversable $items The items to be filtered. + * @param iterable $items The items to be filtered. * @param callable $callback Callback. */ - public function __construct($items, callable $callback) + public function __construct(iterable $items, callable $callback) { $this->_callback = $callback; parent::__construct($items); @@ -63,22 +69,15 @@ public function __construct($items, callable $callback) * * @return mixed */ - public function current() + public function current(): mixed { - $callback = $this->_callback; - - return $callback(parent::current(), $this->key(), $this->_innerIterator); + return ($this->_callback)(parent::current(), $this->key(), $this->_innerIterator); } /** - * {@inheritDoc} - * - * We perform here some strictness analysis so that the - * iterator logic is bypassed entirely. - * - * @return \Iterator + * @inheritDoc */ - public function unwrap() + public function unwrap(): Iterator { $iterator = $this->_innerIterator; @@ -86,7 +85,7 @@ public function unwrap() $iterator = $iterator->unwrap(); } - if (get_class($iterator) !== ArrayIterator::class) { + if ($iterator::class !== ArrayIterator::class) { return $this; } diff --git a/src/Collection/Iterator/SortIterator.php b/src/Collection/Iterator/SortIterator.php index 82c4b19e667..d2e0d3c6ca9 100644 --- a/src/Collection/Iterator/SortIterator.php +++ b/src/Collection/Iterator/SortIterator.php @@ -1,4 +1,6 @@ */ class SortIterator extends Collection { - /** * Wraps this iterator around the passed items so when iterated they are returned * in order. @@ -49,7 +59,7 @@ class SortIterator extends Collection * element. Please note that the callback function could be called more than once * per element. * - * @param array|\Traversable $items The values to sort + * @param iterable $items The values to sort * @param callable|string $callback A function used to return the actual value to * be compared. It can also be a string representing the path to use to fetch a * column or property in each element @@ -57,8 +67,12 @@ class SortIterator extends Collection * @param int $type the type of comparison to perform, either SORT_STRING * SORT_NUMERIC or SORT_NATURAL */ - public function __construct($items, $callback, $dir = SORT_DESC, $type = SORT_NUMERIC) - { + public function __construct( + iterable $items, + callable|string $callback, + int $dir = SORT_DESC, + int $type = SORT_NUMERIC, + ) { if (!is_array($items)) { $items = iterator_to_array((new Collection($items))->unwrap(), false); } @@ -67,7 +81,11 @@ public function __construct($items, $callback, $dir = SORT_DESC, $type = SORT_NU $results = []; foreach ($items as $key => $val) { $val = $callback($val); - if ($val instanceof DateTimeInterface && $type === SORT_NUMERIC) { + $isDateTime = + $val instanceof ChronosDate || + $val instanceof ChronosTime || + $val instanceof DateTimeInterface; + if ($isDateTime && $type === SORT_NUMERIC) { $val = $val->format('U'); } $results[$key] = $val; @@ -78,6 +96,7 @@ public function __construct($items, $callback, $dir = SORT_DESC, $type = SORT_NU foreach (array_keys($results) as $key) { $results[$key] = $items[$key]; } + /** @phpstan-ignore argument.type (sorted array keys may differ from TKey) */ parent::__construct($results); } @@ -86,8 +105,9 @@ public function __construct($items, $callback, $dir = SORT_DESC, $type = SORT_NU * * @return \Iterator */ - public function unwrap() + public function unwrap(): Iterator { + /** @var \Iterator */ return $this->getInnerIterator(); } } diff --git a/src/Collection/Iterator/StoppableIterator.php b/src/Collection/Iterator/StoppableIterator.php index 7d1882c6cd4..b148e1c6575 100644 --- a/src/Collection/Iterator/StoppableIterator.php +++ b/src/Collection/Iterator/StoppableIterator.php @@ -1,4 +1,6 @@ */ class StoppableIterator extends Collection { - /** * The condition to evaluate for each item of the collection * @@ -39,9 +45,9 @@ class StoppableIterator extends Collection /** * A reference to the internal iterator this object is wrapping. * - * @var \Iterator + * @var \Traversable */ - protected $_innerIterator; + protected Traversable $_innerIterator; /** * Creates an iterator that can be stopped based on a condition provided by a callback. @@ -50,12 +56,12 @@ class StoppableIterator extends Collection * in the current iteration, the key of the element and the passed $items iterator * as arguments, in that order. * - * @param array|\Traversable $items The list of values to iterate + * @param iterable $items The list of values to iterate * @param callable $condition A function that will be called for each item in * the collection, if the result evaluates to false, no more items will be * yielded from this iterator. */ - public function __construct($items, callable $condition) + public function __construct(iterable $items, callable $condition) { $this->_condition = $condition; parent::__construct($items); @@ -64,11 +70,11 @@ public function __construct($items, callable $condition) /** * Evaluates the condition and returns its result, this controls - * whether or not more results will be yielded. + * whether more results will be yielded. * * @return bool */ - public function valid() + public function valid(): bool { if (!parent::valid()) { return false; @@ -82,14 +88,9 @@ public function valid() } /** - * {@inheritDoc} - * - * We perform here some strictness analysis so that the - * iterator logic is bypassed entirely. - * - * @return \Iterator + * @inheritDoc */ - public function unwrap() + public function unwrap(): Iterator { $iterator = $this->_innerIterator; @@ -97,7 +98,7 @@ public function unwrap() $iterator = $iterator->unwrap(); } - if (get_class($iterator) !== ArrayIterator::class) { + if ($iterator::class !== ArrayIterator::class) { return $this; } diff --git a/src/Collection/Iterator/TreeIterator.php b/src/Collection/Iterator/TreeIterator.php index f4d22516f2f..beb961004b3 100644 --- a/src/Collection/Iterator/TreeIterator.php +++ b/src/Collection/Iterator/TreeIterator.php @@ -1,4 +1,6 @@ > + * @implements \Cake\Collection\CollectionInterface */ -class TreeIterator extends RecursiveIteratorIterator +class TreeIterator extends RecursiveIteratorIterator implements CollectionInterface { - + /** @use \Cake\Collection\CollectionTrait */ use CollectionTrait; /** * The iteration mode * - * @var int + * @var \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::SELF_FIRST|\RecursiveIteratorIterator::CHILD_FIRST */ - protected $_mode; + protected int $_mode; /** * Constructor * - * @param \RecursiveIterator $items The iterator to flatten. - * @param int $mode Iterator mode. - * @param int $flags Iterator flags. + * @param \RecursiveIterator $items The iterator to flatten. + * @param \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::SELF_FIRST|\RecursiveIteratorIterator::CHILD_FIRST $mode Iterator mode. + * @param \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::CATCH_GET_CHILD $flags Iterator flags. */ - public function __construct(RecursiveIterator $items, $mode = RecursiveIteratorIterator::SELF_FIRST, $flags = 0) - { + public function __construct( + RecursiveIterator $items, + int $mode = RecursiveIteratorIterator::SELF_FIRST, + int $flags = 0, + ) { parent::__construct($items, $mode, $flags); $this->_mode = $mode; } @@ -77,29 +88,35 @@ public function __construct(RecursiveIterator $items, $mode = RecursiveIteratorI * }); * ``` * - * @param string|callable $valuePath The property to extract or a callable to return + * @param callable|string $valuePath The property to extract or a callable to return * the display value - * @param string|callable|null $keyPath The property to use as iteration key or a + * @param callable|string|null $keyPath The property to use as iteration key or a * callable returning the key value. * @param string $spacer The string to use for prefixing the values according to * their depth in the tree - * @return \Cake\Collection\Iterator\TreePrinter + * @return \Cake\Collection\Iterator\TreePrinter */ - public function printer($valuePath, $keyPath = null, $spacer = '__') - { + public function printer( + callable|string $valuePath, + callable|string|null $keyPath = null, + string $spacer = '__', + ): TreePrinter { if (!$keyPath) { $counter = 0; - $keyPath = function () use (&$counter) { + $keyPath = function () use (&$counter): int { return $counter++; }; } + /** @var \RecursiveIterator $iterator */ + $iterator = $this->getInnerIterator(); + return new TreePrinter( - $this->getInnerIterator(), + $iterator, $valuePath, $keyPath, $spacer, - $this->_mode + $this->_mode, ); } } diff --git a/src/Collection/Iterator/TreePrinter.php b/src/Collection/Iterator/TreePrinter.php index fa72d0eabf8..95e73dbcc5a 100644 --- a/src/Collection/Iterator/TreePrinter.php +++ b/src/Collection/Iterator/TreePrinter.php @@ -1,4 +1,6 @@ > + * @implements \Cake\Collection\CollectionInterface */ -class TreePrinter extends RecursiveIteratorIterator +class TreePrinter extends RecursiveIteratorIterator implements CollectionInterface { - + /** @use \Cake\Collection\CollectionTrait */ use CollectionTrait; /** @@ -45,29 +54,34 @@ class TreePrinter extends RecursiveIteratorIterator * * @var mixed */ - protected $_current; + protected mixed $_current = null; /** * The string to use for prefixing the values according to their depth in the tree. * * @var string */ - protected $_spacer; + protected string $_spacer; /** * Constructor * - * @param \RecursiveIterator $items The iterator to flatten. - * @param string|callable $valuePath The property to extract or a callable to return + * @param \RecursiveIterator $items The iterator to flatten. + * @param callable|string $valuePath The property to extract or a callable to return * the display value. - * @param string|callable $keyPath The property to use as iteration key or a + * @param callable|string $keyPath The property to use as iteration key or a * callable returning the key value. * @param string $spacer The string to use for prefixing the values according to * their depth in the tree. - * @param int $mode Iterator mode. + * @param \RecursiveIteratorIterator::LEAVES_ONLY|\RecursiveIteratorIterator::SELF_FIRST|\RecursiveIteratorIterator::CHILD_FIRST $mode Iterator mode. */ - public function __construct($items, $valuePath, $keyPath, $spacer, $mode = RecursiveIteratorIterator::SELF_FIRST) - { + public function __construct( + RecursiveIterator $items, + callable|string $valuePath, + callable|string $keyPath, + string $spacer, + int $mode = RecursiveIteratorIterator::SELF_FIRST, + ) { parent::__construct($items, $mode); $this->_value = $this->_propertyExtractor($valuePath); $this->_key = $this->_propertyExtractor($keyPath); @@ -79,7 +93,7 @@ public function __construct($items, $valuePath, $keyPath, $spacer, $mode = Recur * * @return mixed */ - public function key() + public function key(): mixed { $extractor = $this->_key; @@ -91,7 +105,7 @@ public function key() * * @return string */ - public function current() + public function current(): string { $extractor = $this->_value; $current = $this->_fetchCurrent(); @@ -105,7 +119,7 @@ public function current() * * @return void */ - public function next() + public function next(): void { parent::next(); $this->_current = null; @@ -116,7 +130,7 @@ public function next() * * @return mixed */ - protected function _fetchCurrent() + protected function _fetchCurrent(): mixed { if ($this->_current !== null) { return $this->_current; diff --git a/src/Collection/Iterator/UnfoldIterator.php b/src/Collection/Iterator/UnfoldIterator.php index 06a2ca6a68b..fdc706e730a 100644 --- a/src/Collection/Iterator/UnfoldIterator.php +++ b/src/Collection/Iterator/UnfoldIterator.php @@ -1,4 +1,6 @@ + * @template-extends \IteratorIterator> */ class UnfoldIterator extends IteratorIterator implements RecursiveIterator { - /** * A function that is passed each element in this iterator and * must return an array or Traversable object. @@ -38,20 +42,20 @@ class UnfoldIterator extends IteratorIterator implements RecursiveIterator /** * A reference to the internal iterator this object is wrapping. * - * @var \Iterator + * @var \Traversable */ - protected $_innerIterator; + protected Traversable $_innerIterator; /** * Creates the iterator that will generate child iterators from each of the * elements it was constructed with. * - * @param array|\Traversable $items The list of values to iterate + * @param \Traversable $items The list of values to iterate * @param callable $unfolder A callable function that will receive the * current item and key. It must return an array or Traversable object * out of which the nested iterators will be yielded. */ - public function __construct($items, callable $unfolder) + public function __construct(Traversable $items, callable $unfolder) { $this->_unfolder = $unfolder; parent::__construct($items); @@ -64,7 +68,7 @@ public function __construct($items, callable $unfolder) * * @return bool */ - public function hasChildren() + public function hasChildren(): bool { return true; } @@ -73,9 +77,9 @@ public function hasChildren() * Returns an iterator containing the items generated by transforming * the current value with the callable function. * - * @return \RecursiveIterator + * @return \RecursiveIterator */ - public function getChildren() + public function getChildren(): RecursiveIterator { $current = $this->current(); $key = $this->key(); diff --git a/src/Collection/Iterator/UniqueIterator.php b/src/Collection/Iterator/UniqueIterator.php new file mode 100644 index 00000000000..970f91d2784 --- /dev/null +++ b/src/Collection/Iterator/UniqueIterator.php @@ -0,0 +1,56 @@ + + */ +class UniqueIterator extends Collection +{ + /** + * Creates a filtered iterator using the callback to determine which items are + * accepted or rejected. + * + * The callback is passed the value as the first argument and the key as the + * second argument. + * + * @param iterable $items The items to be filtered. + * @param callable $callback Callback. + */ + public function __construct(iterable $items, callable $callback) + { + $unique = []; + $uniqueValues = []; + foreach ($items as $k => $v) { + $compareValue = $callback($v, $k); + if (!in_array($compareValue, $uniqueValues, true)) { + $unique[$k] = $v; + $uniqueValues[] = $compareValue; + } + } + + parent::__construct($unique); + } +} diff --git a/src/Collection/Iterator/ZipIterator.php b/src/Collection/Iterator/ZipIterator.php index 9c4adac0a34..673067041c3 100644 --- a/src/Collection/Iterator/ZipIterator.php +++ b/src/Collection/Iterator/ZipIterator.php @@ -1,4 +1,6 @@ toList(); // Returns [4, 6] * ``` + * + * @template TKey + * @template TValue + * @implements \Cake\Collection\CollectionInterface */ -class ZipIterator extends MultipleIterator implements CollectionInterface, Serializable +class ZipIterator implements CollectionInterface { - + /** @use \Cake\Collection\CollectionTrait */ use CollectionTrait; + /** + * @var \MultipleIterator + */ + protected MultipleIterator $multipleIterator; + /** * The function to use for zipping items together * - * @var callable + * @var callable|null */ protected $_callback; @@ -59,7 +69,7 @@ class ZipIterator extends MultipleIterator implements CollectionInterface, Seria * * @var array */ - protected $_iterators = []; + protected array $_iterators = []; /** * Creates the iterator to merge together the values by for all the passed @@ -68,18 +78,18 @@ class ZipIterator extends MultipleIterator implements CollectionInterface, Seria * @param array $sets The list of array or iterators to be zipped. * @param callable|null $callable The function to use for zipping the elements of each iterator. */ - public function __construct(array $sets, $callable = null) + public function __construct(array $sets, ?callable $callable = null) { - $sets = array_map(function ($items) { - return (new Collection($items))->unwrap(); - }, $sets); + $this->multipleIterator = new MultipleIterator( + MultipleIterator::MIT_NEED_ALL | MultipleIterator::MIT_KEYS_NUMERIC, + ); $this->_callback = $callable; - parent::__construct(MultipleIterator::MIT_NEED_ALL | MultipleIterator::MIT_KEYS_NUMERIC); foreach ($sets as $set) { - $this->_iterators[] = $set; - $this->attachIterator($set); + $iterator = (new Collection($set))->unwrap(); + $this->_iterators[] = $iterator; + $this->multipleIterator->attachIterator($iterator); } } @@ -89,38 +99,81 @@ public function __construct(array $sets, $callable = null) * * @return mixed */ - public function current() + public function current(): mixed { - if ($this->_callback === null) { - return parent::current(); + $current = $this->multipleIterator->current(); + if ($this->_callback) { + return call_user_func_array($this->_callback, $current); } - return call_user_func_array($this->_callback, parent::current()); + return $current; + } + + /** + * Implements Iterator::key(). + * + * @return mixed + */ + public function key(): mixed + { + return $this->multipleIterator->key(); + } + + /** + * Implements Iterator::next(). + * + * @return void + */ + public function next(): void + { + $this->multipleIterator->next(); + } + + /** + * Implements Iterator::rewind(). + * + * @return void + */ + public function rewind(): void + { + $this->multipleIterator->rewind(); + } + + /** + * Implements Iterator::valid(). + * + * @return bool + */ + public function valid(): bool + { + return $this->multipleIterator->valid(); } /** - * Returns a string representation of this object that can be used - * to reconstruct it + * Magic method used for serializing the iterator instance. * - * @return string + * @return array */ - public function serialize() + public function __serialize(): array { - return serialize($this->_iterators); + return $this->_iterators; } /** - * Unserializes the passed string and rebuilds the ZipIterator instance + * Magic method used to rebuild the iterator instance. * - * @param string $iterators The serialized iterators + * @param array $data Data array. * @return void */ - public function unserialize($iterators) + public function __unserialize(array $data): void { - parent::__construct(MultipleIterator::MIT_NEED_ALL | MultipleIterator::MIT_KEYS_NUMERIC); - $this->_iterators = unserialize($iterators); + $this->multipleIterator = new MultipleIterator( + MultipleIterator::MIT_NEED_ALL | MultipleIterator::MIT_KEYS_NUMERIC, + ); + + $this->_iterators = $data; foreach ($this->_iterators as $it) { - $this->attachIterator($it); + $this->multipleIterator->attachIterator($it); } } } diff --git a/src/Collection/LICENSE.txt b/src/Collection/LICENSE.txt index 0c4b7932c31..0b3b943035e 100644 --- a/src/Collection/LICENSE.txt +++ b/src/Collection/LICENSE.txt @@ -1,7 +1,7 @@ The MIT License (MIT) CakePHP(tm) : The Rapid Development PHP Framework (https://cakephp.org) -Copyright (c) 2005-2016, Cake Software Foundation, Inc. (https://cakefoundation.org) +Copyright (c) 2005-2019, Cake Software Foundation, Inc. (https://cakefoundation.org) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/Collection/README.md b/src/Collection/README.md index 98639131701..7b87ad3fcc0 100644 --- a/src/Collection/README.md +++ b/src/Collection/README.md @@ -28,4 +28,4 @@ you have in your application as well. ## Documentation -Please make sure you check the [official documentation](https://book.cakephp.org/3.0/en/core-libraries/collections.html) +Please make sure you check the [official documentation](https://book.cakephp.org/5/en/core-libraries/collections.html) diff --git a/src/Collection/composer.json b/src/Collection/composer.json index 47a24c47f03..1862a770900 100644 --- a/src/Collection/composer.json +++ b/src/Collection/composer.json @@ -23,7 +23,7 @@ "source": "https://github.com/cakephp/collection" }, "require": { - "php": ">=5.6.0" + "php": ">=8.2" }, "autoload": { "psr-4": { @@ -32,5 +32,10 @@ "files": [ "functions.php" ] + }, + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" + } } } diff --git a/src/Collection/functions.php b/src/Collection/functions.php index e1f01b5840a..8cdcec5acf4 100644 --- a/src/Collection/functions.php +++ b/src/Collection/functions.php @@ -1,4 +1,7 @@ $items The items from which the collection will be built. + * @return \Cake\Collection\Collection + */ +function collection(iterable $items): CollectionInterface +{ + return new Collection($items); } diff --git a/src/Collection/functions_global.php b/src/Collection/functions_global.php new file mode 100644 index 00000000000..a8f2c235b17 --- /dev/null +++ b/src/Collection/functions_global.php @@ -0,0 +1,35 @@ + $items The items from which the collection will be built. + * @return \Cake\Collection\Collection + */ + function collection(iterable $items): CollectionInterface + { + return cakeCollection($items); + } +} diff --git a/src/Command/CacheClearCommand.php b/src/Command/CacheClearCommand.php new file mode 100644 index 00000000000..30b3937c542 --- /dev/null +++ b/src/Command/CacheClearCommand.php @@ -0,0 +1,97 @@ +setDescription(static::getDescription()) + ->addArgument('engine', [ + 'help' => 'The cache engine to clear.' . + 'For example, `cake cache clear _cake_model_` will clear the model cache.' . + ' Use `cake cache list` to list available engines.', + 'required' => true, + ]); + + return $parser; + } + + /** + * Implement this method with your command's logic. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $name = (string)$args->getArgument('engine'); + try { + $io->out("Clearing {$name}"); + + $engine = Cache::pool($name); + Cache::clear($name); + if ($engine instanceof ApcuEngine) { + $io->warning("ApcuEngine detected: Cleared {$name} CLI cache successfully " . + "but {$name} web cache must be cleared separately."); + } else { + $io->out("Cleared {$name} cache"); + } + } catch (InvalidArgumentException $e) { + $io->error($e->getMessage()); + $this->abort(); + } + + return static::CODE_SUCCESS; + } +} diff --git a/src/Command/CacheClearGroupCommand.php b/src/Command/CacheClearGroupCommand.php new file mode 100644 index 00000000000..2ac77f7747b --- /dev/null +++ b/src/Command/CacheClearGroupCommand.php @@ -0,0 +1,115 @@ +setDescription(static::getDescription()); + $parser->addArgument('group', [ + 'help' => 'The cache group to clear. For example, `cake cache clear_group mygroup` will clear ' . + 'all cache items belonging to group "mygroup".', + 'required' => true, + ]); + $parser->addArgument('config', [ + 'help' => 'Name of the configuration to use. Defaults to no value which clears all cache configurations.', + ]); + + return $parser; + } + + /** + * Clears the cache group + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $group = (string)$args->getArgument('group'); + try { + $groupConfigs = Cache::groupConfigs($group); + } catch (InvalidArgumentException) { + $io->error(sprintf('Cache group "%s" not found', $group)); + + return static::CODE_ERROR; + } + + $config = $args->getArgument('config'); + if ($config !== null && Cache::getConfig($config) === null) { + $io->error(sprintf('Cache config "%s" not found', $config)); + + return static::CODE_ERROR; + } + + foreach ($groupConfigs[$group] as $groupConfig) { + if ($config !== null && $config !== $groupConfig) { + continue; + } + + if (!Cache::clearGroup($group, $groupConfig)) { + $io->error(sprintf( + 'Error encountered clearing group "%s". Was unable to clear entries for "%s".', + $group, + $groupConfig, + )); + $this->abort(); + } else { + $io->success(sprintf('Cache "%s" was cleared.', $groupConfig)); + } + } + + return static::CODE_SUCCESS; + } +} diff --git a/src/Command/CacheClearallCommand.php b/src/Command/CacheClearallCommand.php new file mode 100644 index 00000000000..2d62ecf515c --- /dev/null +++ b/src/Command/CacheClearallCommand.php @@ -0,0 +1,77 @@ +setDescription(static::getDescription()); + + return $parser; + } + + /** + * Implement this method with your command's logic. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + foreach (Cache::configured() as $engine) { + $this->executeCommand(CacheClearCommand::class, [$engine], $io); + } + + return static::CODE_SUCCESS; + } +} diff --git a/src/Command/CacheListCommand.php b/src/Command/CacheListCommand.php new file mode 100644 index 00000000000..46466c9ca8e --- /dev/null +++ b/src/Command/CacheListCommand.php @@ -0,0 +1,75 @@ +setDescription(static::getDescription()); + + return $parser; + } + + /** + * Get the list of cache prefixes + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + foreach (Cache::configured() as $engine) { + $io->out("- {$engine}"); + } + + return static::CODE_SUCCESS; + } +} diff --git a/src/Command/Command.php b/src/Command/Command.php new file mode 100644 index 00000000000..546efc313bd --- /dev/null +++ b/src/Command/Command.php @@ -0,0 +1,47 @@ +commands = $commands; + } + + /** + * Gets the option parser instance and configures it. + * + * @param \Cake\Console\ConsoleOptionParser $parser The parser to build + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $modes = [ + 'commands' => 'Output a list of available commands', + 'subcommands' => 'Output a list of available sub-commands for a command', + 'options' => 'Output a list of available options for a command and possible subcommand.', + ]; + $modeHelp = ''; + foreach ($modes as $key => $help) { + $modeHelp .= "- {$key} {$help}\n"; + } + + $parser->setDescription( + static::getDescription(), + )->addArgument('mode', [ + 'help' => 'The type of thing to get completion on.', + 'required' => true, + 'choices' => array_keys($modes), + ])->addArgument('command', [ + 'help' => 'The command name to get information on.', + 'required' => false, + ])->addArgument('subcommand', [ + 'help' => 'The sub-command related to command to get information on.', + 'required' => false, + ])->setEpilog([ + 'The various modes allow you to get help information on commands and their arguments.', + 'The available modes are:', + '', + $modeHelp, + '', + 'This command is not intended to be called manually, and should be invoked from a ' . + 'terminal completion script.', + ]); + + return $parser; + } + + /** + * Main function Prints out the list of commands. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + return match ($args->getArgument('mode')) { + 'commands' => $this->getCommands($args, $io), + 'subcommands' => $this->getSubcommands($args, $io), + 'options' => $this->getOptions($args, $io), + default => static::CODE_ERROR, + }; + } + + /** + * Get the list of defined commands. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int + */ + protected function getCommands(Arguments $args, ConsoleIo $io): int + { + $options = []; + $verbose = $io->level() >= ConsoleIo::VERBOSE; + + // Build a map of command base names (without subcommands) to their classes + // to detect true duplicates (plugin-prefixed alias pointing to same command) + $commandClasses = []; + foreach ($this->commands as $key => $value) { + if (is_subclass_of($value, CommandHiddenInterface::class)) { + continue; + } + $parts = explode(' ', $key); + $commandName = $parts[0]; + // Only track base commands (no subcommands) and prefer first occurrence + if (count($parts) === 1 && !isset($commandClasses[$commandName])) { + $commandClasses[$commandName] = $value; + } + } + + foreach ($this->commands as $key => $value) { + if (is_subclass_of($value, CommandHiddenInterface::class)) { + continue; + } + $parts = explode(' ', $key); + $commandName = $parts[0]; + + // Skip plugin-prefixed aliases only if they are true duplicates + // (i.e., a short form exists that resolves to the same command class) + if (!$verbose && str_contains($commandName, '.')) { + $shortName = explode('.', $commandName)[1]; + if ( + isset($commandClasses[$shortName]) && + isset($commandClasses[$commandName]) && + $commandClasses[$shortName] === $commandClasses[$commandName] + ) { + continue; + } + } + $options[] = $commandName; + } + $options = array_unique($options); + $io->out(implode(' ', $options)); + + return static::CODE_SUCCESS; + } + + /** + * Get the list of defined sub-commands. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int + */ + protected function getSubcommands(Arguments $args, ConsoleIo $io): int + { + $name = $args->getArgument('command'); + if ($name === null || $name === '') { + return static::CODE_SUCCESS; + } + + $options = []; + foreach ($this->commands as $key => $value) { + if (is_subclass_of($value, CommandHiddenInterface::class)) { + continue; + } + $parts = explode(' ', $key); + if ($parts[0] !== $name) { + continue; + } + + // Space separate command name, collect + // hits as subcommands + if (count($parts) > 1) { + $options[] = implode(' ', array_slice($parts, 1)); + } + } + $options = array_unique($options); + $io->out(implode(' ', $options)); + + return static::CODE_SUCCESS; + } + + /** + * Get the options for a command or subcommand + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null + */ + protected function getOptions(Arguments $args, ConsoleIo $io): ?int + { + $name = $args->getArgument('command'); + $subcommand = $args->getArgument('subcommand'); + + $options = []; + foreach ($this->commands as $key => $value) { + if (is_subclass_of($value, CommandHiddenInterface::class)) { + continue; + } + $parts = explode(' ', $key); + if ($parts[0] !== $name) { + continue; + } + if ($subcommand && (!isset($parts[1]) || $parts[1] !== $subcommand)) { + continue; + } + + // Handle class strings + if (is_string($value)) { + $reflection = new ReflectionClass($value); + $value = $reflection->newInstance(); + assert($value instanceof BaseCommand); + } + + if (method_exists($value, 'getOptionParser')) { + /** @var \Cake\Console\ConsoleOptionParser $parser */ + $parser = $value->getOptionParser(); + + foreach ($parser->options() as $name => $option) { + $options[] = "--{$name}"; + $short = $option->short(); + if ($short) { + $options[] = "-{$short}"; + } + } + } + } + $options = array_unique($options); + $io->out(implode(' ', $options)); + + return static::CODE_SUCCESS; + } +} diff --git a/src/Command/CounterCacheCommand.php b/src/Command/CounterCacheCommand.php new file mode 100644 index 00000000000..6f266334413 --- /dev/null +++ b/src/Command/CounterCacheCommand.php @@ -0,0 +1,110 @@ +fetchTable($args->getArgument('model')); + + if (!$table->hasBehavior('CounterCache')) { + $io->error('The specified model does not have the CounterCache behavior attached.'); + + return static::CODE_ERROR; + } + + $methodArgs = []; + if ($args->hasOption('assoc')) { + $methodArgs['assocName'] = $args->getOption('assoc'); + } + if ($args->hasOption('limit')) { + $methodArgs['limit'] = (int)$args->getOption('limit'); + } + if ($args->hasOption('page')) { + $methodArgs['page'] = (int)$args->getOption('page'); + } + + /** @var \Cake\ORM\Table $table */ + $table->getBehavior('CounterCache')->updateCounterCache(...$methodArgs); + + $io->success('Counter cache updated successfully.'); + + return static::CODE_SUCCESS; + } + + /** + * @inheritDoc + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription(static::getDescription()) + ->addArgument('model', [ + 'help' => 'The model to update the counter cache for.', + 'required' => true, + ])->addOption('assoc', [ + 'help' => 'The association to update the counter cache for. By default all associations are updated.', + 'short' => 'a', + 'default' => null, + ]) + ->addOption('limit', [ + 'help' => 'The number of records to update per page/iteration', + 'short' => 'l', + 'default' => null, + ]) + ->addOption('page', [ + 'help' => 'The page/iteration number. By default all records will be updated one page at a time.', + 'short' => 'p', + 'default' => null, + ]); + + return $parser; + } +} diff --git a/src/Command/Helper/BannerHelper.php b/src/Command/Helper/BannerHelper.php new file mode 100644 index 00000000000..bea6ae43f8b --- /dev/null +++ b/src/Command/Helper/BannerHelper.php @@ -0,0 +1,9 @@ +out('I18n Command'); + $io->hr(); + $io->out('[E]xtract POT file from sources'); + $io->out('[I]nitialize a language from POT file'); + $io->out('[H]elp'); + $io->out('[Q]uit'); + + do { + $choice = strtolower($io->askChoice('What would you like to do?', ['E', 'I', 'H', 'Q'])); + $code = null; + switch ($choice) { + case 'e': + $code = $this->executeCommand(I18nExtractCommand::class, [], $io); + break; + case 'i': + $code = $this->executeCommand(I18nInitCommand::class, [], $io); + break; + case 'h': + $io->out($this->getOptionParser()->help()); + break; + case 'q': + // Do nothing + break; + default: + $io->err( + 'You have made an invalid selection. ' . + 'Please choose a command to execute by entering E, I, H, or Q.', + ); + } + if ($code === static::CODE_ERROR) { + $this->abort(); + } + } while ($choice !== 'q'); + + return static::CODE_SUCCESS; + } + + /** + * Gets the option parser instance and configures it. + * + * @param \Cake\Console\ConsoleOptionParser $parser The parser to update + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription(static::getDescription()); + + return $parser; + } +} diff --git a/src/Command/I18nExtractCommand.php b/src/Command/I18nExtractCommand.php new file mode 100644 index 00000000000..acfda357568 --- /dev/null +++ b/src/Command/I18nExtractCommand.php @@ -0,0 +1,1011 @@ + + */ + protected array $_paths = []; + + /** + * Files from where to extract + * + * @var array + */ + protected array $_files = []; + + /** + * Merge all domain strings into the default.pot file + * + * @var bool + */ + protected bool $_merge = false; + + /** + * Current file being processed + * + * @var string + */ + protected string $_file = ''; + + /** + * Contains all content waiting to be written + * + * @var array + */ + protected array $_storage = []; + + /** + * Extracted tokens + * + * @var array + */ + protected array $_tokens = []; + + /** + * Extracted strings indexed by domain. + * + * @var array + */ + protected array $_translations = []; + + /** + * Destination path + * + * @var string + */ + protected string $_output = ''; + + /** + * An array of directories to exclude. + * + * @var array + */ + protected array $_exclude = []; + + /** + * Holds whether this call should extract the CakePHP Lib messages + * + * @var bool + */ + protected bool $_extractCore = false; + + /** + * Displays marker error(s) if true + * + * @var bool + */ + protected bool $_markerError = false; + + /** + * Count number of marker errors found + * + * @var int + */ + protected int $_countMarkerError = 0; + + /** + * @inheritDoc + */ + public static function defaultName(): string + { + return 'i18n extract'; + } + + /** + * @inheritDoc + */ + public static function getDescription(): string + { + return 'Extract i18n POT files from application source files.'; + } + + /** + * Method to interact with the user and get path selections. + * + * @param \Cake\Console\ConsoleIo $io The io instance. + * @return void + */ + protected function _getPaths(ConsoleIo $io): void + { + $defaultPaths = array_merge( + [APP], + array_values(App::path('templates')), + ['D'], // This is required to break the loop below + ); + $defaultPathIndex = 0; + while (true) { + $currentPaths = $this->_paths !== [] ? $this->_paths : ['None']; + $message = sprintf( + "Current paths: %s\nWhat is the path you would like to extract?\n[Q]uit [D]one", + implode(', ', $currentPaths), + ); + $response = $io->ask($message, $defaultPaths[$defaultPathIndex] ?? 'D'); + if (strtoupper($response) === 'Q') { + $io->error('Extract Aborted'); + $this->abort(); + } + if (strtoupper($response) === 'D' && count($this->_paths)) { + $io->out(); + + return; + } + if (strtoupper($response) === 'D') { + $io->warning('No directories selected. Please choose a directory.'); + } elseif (is_dir($response)) { + $this->_paths[] = $response; + $defaultPathIndex++; + } else { + $io->error('The directory path you supplied was not found. Please try again.'); + } + $io->out(); + } + } + + /** + * Execute the command + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $plugin = ''; + if ($args->getOption('exclude')) { + $this->_exclude = explode(',', (string)$args->getOption('exclude')); + } + if ($args->getOption('files')) { + $this->_files = explode(',', (string)$args->getOption('files')); + } + if ($args->getOption('paths')) { + $this->_paths = explode(',', (string)$args->getOption('paths')); + } + if ($args->getOption('plugin')) { + $plugin = Inflector::camelize((string)$args->getOption('plugin')); + if ($this->_paths === []) { + $this->_paths = [Plugin::classPath($plugin), Plugin::templatePath($plugin)]; + } + } elseif (!$args->getOption('paths')) { + $this->_getPaths($io); + } + + if ($args->hasOption('extract-core')) { + $this->_extractCore = strtolower((string)$args->getOption('extract-core')) !== 'no'; + } else { + $response = $io->askChoice( + 'Would you like to extract the messages from the CakePHP core?', + ['y', 'n'], + 'n', + ); + $this->_extractCore = strtolower($response) === 'y'; + } + + if ($args->hasOption('exclude-plugins') && $this->_isExtractingApp()) { + $this->_exclude = array_merge($this->_exclude, array_values(App::path('plugins'))); + } + + if ($this->_extractCore) { + $this->_paths[] = CAKE; + } + + if ($args->hasOption('output')) { + $this->_output = (string)$args->getOption('output'); + } elseif ($args->hasOption('plugin')) { + $this->_output = Plugin::path($plugin) + . 'resources' . DIRECTORY_SEPARATOR + . 'locales' . DIRECTORY_SEPARATOR; + } else { + $message = "What is the path you would like to output?\n[Q]uit"; + $localePaths = array_values(App::path('locales')); + if (!$localePaths) { + $localePaths[] = ROOT . DIRECTORY_SEPARATOR + . 'resources' . DIRECTORY_SEPARATOR + . 'locales' . DIRECTORY_SEPARATOR; + } + while (true) { + $response = $io->ask( + $message, + $localePaths[0], + ); + if (strtoupper($response) === 'Q') { + $io->error('Extract Aborted'); + + return static::CODE_ERROR; + } + if ($this->_isPathUsable($response)) { + $this->_output = $response . DIRECTORY_SEPARATOR; + break; + } + + $io->err(''); + $io->error( + 'The directory path you supplied was ' . + 'not found. Please try again.', + ); + $io->err(''); + } + } + + if ($args->hasOption('merge')) { + $this->_merge = strtolower((string)$args->getOption('merge')) !== 'no'; + } else { + $io->out(); + $response = $io->askChoice( + 'Would you like to merge all domain strings into the default.pot file?', + ['y', 'n'], + 'n', + ); + $this->_merge = strtolower($response) === 'y'; + } + + $this->_markerError = (bool)$args->getOption('marker-error'); + + if (!$this->_files) { + $this->_searchFiles(); + } + + $this->_output = rtrim($this->_output, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + if (!$this->_isPathUsable($this->_output)) { + $io->error(sprintf('The output directory `%s` was not found or writable.', $this->_output)); + + return static::CODE_ERROR; + } + + $this->_extract($args, $io); + + return static::CODE_SUCCESS; + } + + /** + * Add a translation to the internal translations property + * + * Takes care of duplicate translations + * + * @param string $domain The domain + * @param string $msgid The message string + * @param array $details Context and plural form if any, file and line references + * @return void + */ + protected function _addTranslation(string $domain, string $msgid, array $details = []): void + { + $context = $details['msgctxt'] ?? ''; + + if (empty($this->_translations[$domain][$msgid][$context])) { + $this->_translations[$domain][$msgid][$context] = [ + 'msgid_plural' => false, + ]; + } + + if (isset($details['msgid_plural'])) { + $this->_translations[$domain][$msgid][$context]['msgid_plural'] = $details['msgid_plural']; + } + + if (isset($details['file'])) { + $line = $details['line'] ?? 0; + $this->_translations[$domain][$msgid][$context]['references'][$details['file']][] = $line; + } + } + + /** + * Extract text + * + * @param \Cake\Console\Arguments $args The Arguments instance + * @param \Cake\Console\ConsoleIo $io The io instance + * @return void + */ + protected function _extract(Arguments $args, ConsoleIo $io): void + { + $io->out(); + $io->out(); + $io->out('Extracting...'); + $io->hr(); + $io->out('Paths:'); + foreach ($this->_paths as $path) { + $io->out(' ' . $path); + } + $io->out('Output Directory: ' . $this->_output); + $io->hr(); + $this->_extractTokens($args, $io); + $this->_buildFiles($args); + $this->_writeFiles($args, $io); + $this->_paths = []; + $this->_files = []; + $this->_storage = []; + $this->_translations = []; + $this->_tokens = []; + $io->out(); + if ($this->_countMarkerError) { + $io->error("{$this->_countMarkerError} marker error(s) detected."); + $io->err(' => Use the --marker-error option to display errors.'); + } + + $io->out('Done.'); + } + + /** + * Gets the option parser instance and configures it. + * + * @param \Cake\Console\ConsoleOptionParser $parser The parser to configure + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription([ + static::getDescription(), + 'Source files are parsed and string literal format strings ' . + 'provided to the __ family of functions are extracted.', + ])->addOption('app', [ + 'help' => 'Directory where your application is located.', + ])->addOption('paths', [ + 'help' => 'Comma separated list of paths that are searched for source files.', + ])->addOption('merge', [ + 'help' => 'Merge all domain strings into a single default.po file.', + 'default' => 'no', + 'choices' => ['yes', 'no'], + ])->addOption('output', [ + 'help' => 'Full path to output directory.', + ])->addOption('files', [ + 'help' => 'Comma separated list of files to parse.', + ])->addOption('exclude-plugins', [ + 'boolean' => true, + 'default' => true, + 'help' => 'Ignores all files in plugins if this command is run inside from the same app directory.', + ])->addOption('plugin', [ + 'help' => 'Extracts tokens only from the plugin specified and ' + . "puts the result in the plugin's `locales` directory.", + 'short' => 'p', + ])->addOption('exclude', [ + 'help' => 'Comma separated list of directories to exclude.' . + ' Any path containing a path segment with the provided values will be skipped. E.g. test,vendors', + ])->addOption('overwrite', [ + 'boolean' => true, + 'default' => false, + 'help' => 'Always overwrite existing .pot files.', + ])->addOption('extract-core', [ + 'help' => 'Extract messages from the CakePHP core libraries.', + 'choices' => ['yes', 'no'], + ])->addOption('no-location', [ + 'boolean' => true, + 'default' => false, + 'help' => 'Do not write file locations for each extracted message.', + ])->addOption('marker-error', [ + 'boolean' => true, + 'default' => false, + 'help' => 'Do not display marker error.', + ]); + + return $parser; + } + + /** + * Extract tokens out of all files to be processed + * + * @param \Cake\Console\Arguments $args The io instance + * @param \Cake\Console\ConsoleIo $io The io instance + * @return void + */ + protected function _extractTokens(Arguments $args, ConsoleIo $io): void + { + $progress = $io->helper('progress'); + assert($progress instanceof ProgressHelper); + $progress->init(['total' => count($this->_files)]); + $isVerbose = $args->getOption('verbose'); + + $functions = [ + '__' => ['singular'], + '__n' => ['singular', 'plural'], + '__d' => ['domain', 'singular'], + '__dn' => ['domain', 'singular', 'plural'], + '__x' => ['context', 'singular'], + '__xn' => ['context', 'singular', 'plural'], + '__dx' => ['domain', 'context', 'singular'], + '__dxn' => ['domain', 'context', 'singular', 'plural'], + ]; + $pattern = '/(' . implode('|', array_keys($functions)) . ')\s*\(/'; + + foreach ($this->_files as $file) { + $this->_file = $file; + if ($isVerbose) { + $io->verbose(sprintf('Processing %s...', $file)); + } + + $code = (string)file_get_contents($file); + + if (preg_match($pattern, $code) === 1) { + $allTokens = token_get_all($code); + + $this->_tokens = []; + foreach ($allTokens as $token) { + if (!is_array($token) || ($token[0] !== T_WHITESPACE && $token[0] !== T_INLINE_HTML)) { + $this->_tokens[] = $token; + } + } + unset($allTokens); + + foreach ($functions as $functionName => $map) { + $this->_parse($io, $functionName, $map); + } + } + + $this->extractFileReflection($file, $code); + + if (!$isVerbose) { + $progress->increment(1); + $progress->draw(); + } + } + } + + /** + * Parse tokens + * + * @param \Cake\Console\ConsoleIo $io The io instance + * @param string $functionName Function name that indicates translatable string (e.g: '__') + * @param array $map Array containing what variables it will find (e.g: domain, singular, plural) + * @return void + */ + protected function _parse(ConsoleIo $io, string $functionName, array $map): void + { + $count = 0; + $tokenCount = count($this->_tokens); + + while ($tokenCount - $count > 1) { + $countToken = $this->_tokens[$count]; + $firstParenthesis = $this->_tokens[$count + 1]; + if (!is_array($countToken)) { + $count++; + continue; + } + + [$type, $string, $line] = $countToken; + if (($type === T_STRING) && ($string === $functionName) && ($firstParenthesis === '(')) { + $position = $count; + $depth = 0; + + while (!$depth) { + if ($this->_tokens[$position] === '(') { + $depth++; + } elseif ($this->_tokens[$position] === ')') { + $depth--; + } + $position++; + } + + $mapCount = count($map); + $strings = $this->_getStrings($position, $mapCount); + + if ($mapCount === count($strings)) { + $singular = ''; + $vars = array_combine($map, $strings); + extract($vars); + $domain ??= 'default'; + $details = [ + 'file' => $this->_file, + 'line' => $line, + ]; + $details['file'] = '.' . str_replace(ROOT, '', $details['file']); + if (isset($plural)) { + $details['msgid_plural'] = $plural; + } + if (isset($context)) { + $details['msgctxt'] = $context; + } + $this->_addTranslation($domain, $singular, $details); + } else { + $this->_markerError($io, $this->_file, $line, $functionName, $count); + } + } + $count++; + } + } + + /** + * Build the translate template file contents out of obtained strings + * + * @param \Cake\Console\Arguments $args Console arguments + * @return void + */ + protected function _buildFiles(Arguments $args): void + { + $paths = $this->_paths; + $paths[] = realpath(APP) . DIRECTORY_SEPARATOR; + + usort($paths, function (string $a, string $b) { + return strlen($a) - strlen($b); + }); + + foreach ($this->_translations as $domain => $translations) { + foreach ($translations as $msgid => $contexts) { + foreach ($contexts as $context => $details) { + $plural = $details['msgid_plural']; + $files = $details['references']; + $header = ''; + + if (!$args->getOption('no-location')) { + $occurrences = []; + foreach ($files as $file => $lines) { + $lines = array_unique($lines); + foreach ($lines as $line) { + $occurrences[] = $file . ':' . $line; + } + } + $occurrences = implode("\n#: ", $occurrences); + + $header = '#: ' + . str_replace(DIRECTORY_SEPARATOR, '/', $occurrences) + . "\n"; + } + + $sentence = ''; + if ($context !== '') { + $sentence .= "msgctxt \"{$context}\"\n"; + } + if ($plural === false) { + $sentence .= "msgid \"{$msgid}\"\n"; + $sentence .= "msgstr \"\"\n\n"; + } else { + $sentence .= "msgid \"{$msgid}\"\n"; + $sentence .= "msgid_plural \"{$plural}\"\n"; + $sentence .= "msgstr[0] \"\"\n"; + $sentence .= "msgstr[1] \"\"\n\n"; + } + + if ($domain !== 'default' && $this->_merge) { + $this->_store('default', $header, $sentence); + } else { + $this->_store($domain, $header, $sentence); + } + } + } + } + } + + /** + * Prepare a file to be stored + * + * @param string $domain The domain + * @param string $header The header content. + * @param string $sentence The sentence to store. + * @return void + */ + protected function _store(string $domain, string $header, string $sentence): void + { + $this->_storage[$domain] ??= []; + + if (!isset($this->_storage[$domain][$sentence])) { + $this->_storage[$domain][$sentence] = $header; + } else { + $this->_storage[$domain][$sentence] .= $header; + } + } + + /** + * Write the files that need to be stored + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return void + */ + protected function _writeFiles(Arguments $args, ConsoleIo $io): void + { + $io->out(); + $overwriteAll = false; + if ($args->getOption('overwrite')) { + $overwriteAll = true; + } + foreach ($this->_storage as $domain => $sentences) { + $output = $this->_writeHeader($domain); + $headerLength = strlen($output); + foreach ($sentences as $sentence => $header) { + $output .= $header . $sentence; + } + + $filename = str_replace('/', '_', $domain) . '.pot'; + $outputPath = $this->_output . $filename; + + if ($this->checkUnchanged($outputPath, $headerLength, $output)) { + $io->out($filename . ' is unchanged. Skipping.'); + continue; + } + + $response = ''; + while ($overwriteAll === false && file_exists($outputPath) && strtoupper($response) !== 'Y') { + $io->out(); + $response = $io->askChoice( + sprintf('Error: %s already exists in this location. Overwrite? [Y]es, [N]o, [A]ll', $filename), + ['y', 'n', 'a'], + 'y', + ); + if (strtoupper($response) === 'N') { + $response = ''; + while (!$response) { + $response = $io->ask('What would you like to name this file?', 'new_' . $filename); + $filename = $response; + } + } elseif (strtoupper($response) === 'A') { + $overwriteAll = true; + } + } + $fs = new Filesystem(); + $fs->dumpFile($this->_output . $filename, $output); + } + } + + /** + * Build the translation template header + * + * @param string $domain Domain + * @return string Translation template header + */ + protected function _writeHeader(string $domain): string + { + $projectIdVersion = $domain === 'cake' ? 'CakePHP ' . Configure::version() : 'PROJECT VERSION'; + + $output = "# LANGUAGE translation of CakePHP Application\n"; + $output .= "# Copyright YEAR NAME \n"; + $output .= "#\n"; + $output .= "#, fuzzy\n"; + $output .= "msgid \"\"\n"; + $output .= "msgstr \"\"\n"; + $output .= '"Project-Id-Version: ' . $projectIdVersion . "\\n\"\n"; + $output .= '"POT-Creation-Date: ' . date('Y-m-d H:iO') . "\\n\"\n"; + $output .= "\"PO-Revision-Date: YYYY-mm-DD HH:MM+ZZZZ\\n\"\n"; + $output .= "\"Last-Translator: NAME \\n\"\n"; + $output .= "\"Language-Team: LANGUAGE \\n\"\n"; + $output .= "\"MIME-Version: 1.0\\n\"\n"; + $output .= "\"Content-Type: text/plain; charset=utf-8\\n\"\n"; + $output .= "\"Content-Transfer-Encoding: 8bit\\n\"\n"; + $output .= "\"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\\n\"\n\n"; + + return $output; + } + + /** + * Check whether the old and new output are the same, thus unchanged + * + * Compares the sha1 hashes of the old and new file without header. + * + * @param string $oldFile The existing file. + * @param int $headerLength The length of the file header in bytes. + * @param string $newFileContent The content of the new file. + * @return bool Whether the old and new file are unchanged. + */ + protected function checkUnchanged(string $oldFile, int $headerLength, string $newFileContent): bool + { + if (!file_exists($oldFile)) { + return false; + } + $oldFileContent = file_get_contents($oldFile); + if ($oldFileContent === false) { + throw new CakeException(sprintf('Cannot read file content of `%s`', $oldFile)); + } + + $oldChecksum = sha1(substr($oldFileContent, $headerLength)); + $newChecksum = sha1(substr($newFileContent, $headerLength)); + + return $oldChecksum === $newChecksum; + } + + /** + * Get the strings from the position forward + * + * @param int $position Actual position on tokens array + * @param int $target Number of strings to extract + * @return array Strings extracted + */ + protected function _getStrings(int &$position, int $target): array + { + $strings = []; + $count = 0; + while ( + $count < $target + && ($this->_tokens[$position] === ',' + || $this->_tokens[$position][0] === T_CONSTANT_ENCAPSED_STRING + || $this->_tokens[$position][0] === T_LNUMBER + ) + ) { + $count = count($strings); + if ($this->_tokens[$position][0] === T_CONSTANT_ENCAPSED_STRING && $this->_tokens[$position + 1] === '.') { + $string = ''; + while ( + $this->_tokens[$position][0] === T_CONSTANT_ENCAPSED_STRING + || $this->_tokens[$position] === '.' + ) { + if ($this->_tokens[$position][0] === T_CONSTANT_ENCAPSED_STRING) { + $string .= $this->_formatString($this->_tokens[$position][1]); + } + $position++; + } + $strings[] = $string; + } elseif ($this->_tokens[$position][0] === T_CONSTANT_ENCAPSED_STRING) { + $strings[] = $this->_formatString($this->_tokens[$position][1]); + } elseif ($this->_tokens[$position][0] === T_LNUMBER) { + $strings[] = $this->_tokens[$position][1]; + } + $position++; + } + + return $strings; + } + + /** + * Format a string to be added as a translatable string + * + * @param string $string String to format + * @return string Formatted string + */ + protected function _formatString(string $string): string + { + $quote = substr($string, 0, 1); + $string = substr($string, 1, -1); + if ($quote === '"') { + $string = stripcslashes($string); + } else { + $string = strtr($string, ["\\'" => "'", '\\\\' => '\\']); + } + $string = str_replace("\r\n", "\n", $string); + + return addcslashes($string, "\0..\37\\\""); + } + + /** + * Indicate an invalid marker on a processed file + * + * @param \Cake\Console\ConsoleIo $io The io instance. + * @param string $file File where invalid marker resides + * @param int $line Line number + * @param string $marker Marker found + * @param int $count Count + * @return void + */ + protected function _markerError(ConsoleIo $io, string $file, int $line, string $marker, int $count): void + { + if (!str_contains($this->_file, CAKE_CORE_INCLUDE_PATH)) { + $this->_countMarkerError++; + } + + if (!$this->_markerError) { + return; + } + + $io->error(sprintf("Invalid marker content in %s:%s\n* %s(", $file, $line, $marker)); + $count += 2; + $tokenCount = count($this->_tokens); + $parenthesis = 1; + + while (($tokenCount - $count > 0) && $parenthesis) { + if (is_array($this->_tokens[$count])) { + $io->err($this->_tokens[$count][1], 0); + } else { + $io->err($this->_tokens[$count], 0); + if ($this->_tokens[$count] === '(') { + $parenthesis++; + } + + if ($this->_tokens[$count] === ')') { + $parenthesis--; + } + } + $count++; + } + $io->err("\n"); + } + + /** + * Extract Label attribute strings from a PHP file using reflection. + * + * @param string $file Absolute path to the file being processed. + * @param string $code File contents. + * @return void + */ + protected function extractFileReflection(string $file, string $code): void + { + $fqn = $this->parseClassName($code); + if ($fqn === null) { + return; + } + + try { + // @phpstan-ignore argument.type + $reflection = new ReflectionClass($fqn); + } catch (Throwable $e) { + $this->io->warning( + sprintf('Could not reflect class/enum %s in file %s: %s', $fqn, $file, $e->getMessage()), + ); + + return; + } + + if (!$reflection->isEnum()) { + return; + } + + $relativeFile = '.' . str_replace(ROOT, '', $file); + + foreach ($reflection->getReflectionConstants() as $constant) { + if (!$constant->isEnumCase()) { + continue; + } + + $labelAttributes = $constant->getAttributes(Label::class); + if (!$labelAttributes) { + continue; + } + + /** @var \Cake\Database\Type\Attribute\Label $label */ + $label = $labelAttributes[0]->newInstance(); + $details = [ + 'file' => $relativeFile, + 'line' => 0, + 'msgctxt' => $label->context, + ]; + + $this->_addTranslation($label->domain, $label->label, $details); + } + } + + /** + * Parse the fully qualified class/enum name from PHP source code. + * + * Uses token_get_all() to read the namespace declaration and the first + * class or enum name without executing the file. + * + * @param string $code PHP source code. + * @return string|null Fully qualified name, or null if none found. + */ + protected function parseClassName(string $code): ?string + { + $tokens = token_get_all($code); + $namespace = ''; + $waitingForNamespace = false; + $waitingForName = false; + $previous = null; + + foreach ($tokens as $token) { + if (!is_array($token)) { + continue; + } + + [$type, $value] = $token; + + if ($type === T_WHITESPACE) { + continue; + } + + if ($type === T_NAMESPACE) { + $waitingForNamespace = true; + continue; + } + + if ($waitingForNamespace) { + if ($type === T_STRING || $type === T_NAME_QUALIFIED) { + $namespace = $value; + } + $waitingForNamespace = false; + continue; + } + + if (($type === T_ENUM || $type === T_CLASS) && $previous !== T_DOUBLE_COLON) { + $waitingForName = true; + continue; + } + + if ($waitingForName && $type === T_STRING) { + return $namespace !== '' ? $namespace . '\\' . $value : $value; + } + + if ($waitingForName) { + $waitingForName = false; + } + + $previous = $type; + } + + return null; + } + + /** + * Search files that may contain translatable strings + * + * @return void + */ + protected function _searchFiles(): void + { + $pattern = false; + if ($this->_exclude) { + $exclude = []; + foreach ($this->_exclude as $e) { + if (DIRECTORY_SEPARATOR !== '\\' && !str_starts_with($e, DIRECTORY_SEPARATOR)) { + $e = DIRECTORY_SEPARATOR . $e; + } + $exclude[] = preg_quote($e, '/'); + } + $pattern = '/' . implode('|', $exclude) . '/'; + } + + foreach ($this->_paths as $path) { + $path = realpath($path); + if ($path === false) { + continue; + } + $path .= DIRECTORY_SEPARATOR; + $files = (new Finder()) + ->in($path) + ->name('*.php') + ->files(); + foreach ($files as $file) { + $this->_files[] = $file->getPathname(); + } + } + $this->_files = array_unique($this->_files); + sort($this->_files); + if ($pattern) { + $this->_files = preg_grep($pattern, $this->_files, PREG_GREP_INVERT) ?: []; + $this->_files = array_values($this->_files); + } + $this->_files = array_unique($this->_files); + } + + /** + * Returns whether this execution is meant to extract string only from directories in folder represented by the + * APP constant, i.e. this task is extracting strings from same application. + * + * @return bool + */ + protected function _isExtractingApp(): bool + { + return $this->_paths === [APP]; + } + + /** + * Checks whether a given path is usable for writing. + * + * @param string $path Path to folder + * @return bool true if it exists and is writable, false otherwise + */ + protected function _isPathUsable(string $path): bool + { + if (!is_dir($path)) { + mkdir($path, 0777 ^ umask(), true); + } + + return is_dir($path) && is_writable($path); + } +} diff --git a/src/Command/I18nInitCommand.php b/src/Command/I18nInitCommand.php new file mode 100644 index 00000000000..f89e32a7371 --- /dev/null +++ b/src/Command/I18nInitCommand.php @@ -0,0 +1,123 @@ +getArgument('language'); + if (!$language) { + $language = $io->ask('Please specify language code, e.g. `en`, `eng`, `en_US` etc.'); + } + if (strlen($language) < 2) { + $io->error('Invalid language code. Valid is `en`, `eng`, `en_US` etc.'); + + return static::CODE_ERROR; + } + + $paths = array_values(App::path('locales')); + if ($args->hasOption('plugin')) { + $plugin = Inflector::camelize((string)$args->getOption('plugin')); + $paths = [Plugin::path($plugin) . 'resources' . DIRECTORY_SEPARATOR . 'locales' . DIRECTORY_SEPARATOR]; + } + + $response = $io->ask('What folder?', rtrim($paths[0], DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR); + $sourceFolder = rtrim($response, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $targetFolder = $sourceFolder . $language . DIRECTORY_SEPARATOR; + if (!is_dir($targetFolder)) { + mkdir($targetFolder, 0777 ^ umask(), true); + } + + $count = 0; + $iterator = new DirectoryIterator($sourceFolder); + foreach ($iterator as $fileInfo) { + if (!$fileInfo->isFile()) { + continue; + } + $filename = $fileInfo->getFilename(); + $newFilename = $fileInfo->getBasename('.pot'); + $newFilename .= '.po'; + + $content = file_get_contents($sourceFolder . $filename); + if ($content === false) { + throw new CakeException(sprintf('Cannot read file content of `%s`', $sourceFolder . $filename)); + } + $io->createFile($targetFolder . $newFilename, $content); + $count++; + } + + $io->out('Generated ' . $count . ' PO files in ' . $targetFolder); + + return static::CODE_SUCCESS; + } + + /** + * Gets the option parser instance and configures it. + * + * @param \Cake\Console\ConsoleOptionParser $parser The parser to update + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription(static::getDescription()) + ->addOption('plugin', [ + 'help' => 'The plugin to create a PO file in.', + 'short' => 'p', + ]) + ->addArgument('language', [ + 'help' => 'Two-letter language code to create PO files for.', + ]); + + return $parser; + } +} diff --git a/src/Command/PluginAssetsCopyCommand.php b/src/Command/PluginAssetsCopyCommand.php new file mode 100644 index 00000000000..c418c80fd46 --- /dev/null +++ b/src/Command/PluginAssetsCopyCommand.php @@ -0,0 +1,86 @@ +getArgument('name'); + $overwrite = (bool)$args->getOption('overwrite'); + $this->_process($this->_list($name), true, $overwrite); + + return static::CODE_SUCCESS; + } + + /** + * Get the option parser. + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to update + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription( + static::getDescription(), + )->addArgument('name', [ + 'help' => 'A specific plugin you want to copy assets for.', + 'required' => false, + ])->addOption('overwrite', [ + 'help' => 'Overwrite existing symlink / folder / files.', + 'default' => false, + 'boolean' => true, + ]); + + return $parser; + } +} diff --git a/src/Command/PluginAssetsRemoveCommand.php b/src/Command/PluginAssetsRemoveCommand.php new file mode 100644 index 00000000000..335757b0921 --- /dev/null +++ b/src/Command/PluginAssetsRemoveCommand.php @@ -0,0 +1,91 @@ +getArgument('name'); + $plugins = $this->_list($name); + + foreach ($plugins as $plugin => $config) { + $this->io->out(); + $this->io->out('For plugin: ' . $plugin); + $this->io->hr(); + + $this->_remove($config); + } + + $this->io->out(); + $this->io->out('Done'); + + return static::CODE_SUCCESS; + } + + /** + * Get the option parser. + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to update + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription( + static::getDescription(), + )->addArgument('name', [ + 'help' => 'A specific plugin you want to remove.', + 'required' => false, + ]); + + return $parser; + } +} diff --git a/src/Command/PluginAssetsSymlinkCommand.php b/src/Command/PluginAssetsSymlinkCommand.php new file mode 100644 index 00000000000..f4714a79fac --- /dev/null +++ b/src/Command/PluginAssetsSymlinkCommand.php @@ -0,0 +1,92 @@ +getArgument('name'); + $overwrite = (bool)$args->getOption('overwrite'); + $relative = (bool)$args->getOption('relative'); + $this->_process($this->_list($name), false, $overwrite, $relative); + + return static::CODE_SUCCESS; + } + + /** + * Get the option parser. + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to update + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription( + static::getDescription(), + )->addArgument('name', [ + 'help' => 'A specific plugin you want to symlink assets for.', + 'required' => false, + ])->addOption('overwrite', [ + 'help' => 'Overwrite existing symlink / folder / files.', + 'default' => false, + 'boolean' => true, + ])->addOption('relative', [ + 'help' => 'If symlink should be relative.', + 'default' => false, + 'boolean' => true, + ]); + + return $parser; + } +} diff --git a/src/Command/PluginAssetsTrait.php b/src/Command/PluginAssetsTrait.php new file mode 100644 index 00000000000..d9d4574ec4b --- /dev/null +++ b/src/Command/PluginAssetsTrait.php @@ -0,0 +1,319 @@ + List of plugins with meta data. + */ + protected function _list(?string $name = null): array + { + if ($name === null) { + $pluginsList = Plugin::loaded(); + } else { + $pluginsList = [$name]; + } + + $plugins = []; + + foreach ($pluginsList as $plugin) { + $path = Plugin::path($plugin) . 'webroot'; + if (!is_dir($path)) { + $this->io->verbose('', 1); + $this->io->verbose( + sprintf('Skipping plugin %s. It does not have webroot folder.', $plugin), + 2, + ); + continue; + } + + $link = Inflector::underscore($plugin); + $wwwRoot = Configure::read('App.wwwRoot'); + $dir = $wwwRoot; + $namespaced = false; + if (str_contains($link, '/')) { + $namespaced = true; + $parts = explode('/', $link); + $link = array_pop($parts); + $dir = $wwwRoot . implode(DIRECTORY_SEPARATOR, $parts) . DIRECTORY_SEPARATOR; + } + + $plugins[$plugin] = [ + 'srcPath' => Plugin::path($plugin) . 'webroot', + 'destDir' => $dir, + 'link' => $link, + 'namespaced' => $namespaced, + ]; + } + + return $plugins; + } + + /** + * Process plugins + * + * @param array $plugins List of plugins to process + * @param bool $copy Force copy mode. Default false. + * @param bool $overwrite Overwrite existing files. + * @param bool $relative Relative. Default false. + * @return void + */ + protected function _process( + array $plugins, + bool $copy = false, + bool $overwrite = false, + bool $relative = false, + ): void { + foreach ($plugins as $plugin => $config) { + $this->io->out(); + $this->io->out('For plugin: ' . $plugin); + $this->io->hr(); + + if ( + $config['namespaced'] && + !is_dir($config['destDir']) && + !$this->_createDirectory($config['destDir']) + ) { + continue; + } + + $dest = $config['destDir'] . $config['link']; + if ($copy) { + if ((is_link($dest) || $overwrite) && !$this->_remove($config)) { + continue; + } + + if (file_exists($dest)) { + $this->io->verbose($dest . ' already exists', 1); + } else { + $this->_copyDirectory($config['srcPath'], $dest); + } + continue; + } + + $result = $this->_createSymlink( + $config['srcPath'], + $dest, + $relative, + ); + if ($result) { + continue; + } + + if ($this->_isSymlinkValid($config['srcPath'], $dest)) { + $this->io->verbose($dest . ' already exists', 1); + continue; + } + + if (!$this->_remove($config)) { + continue; + } + + if (!$this->_createSymlink($config['srcPath'], $dest)) { + continue; + } + } + + $this->io->out(); + $this->io->out('Done'); + } + + /** + * Remove folder/symlink. + * + * @param array $config Plugin config. + * @return bool + */ + protected function _remove(array $config): bool + { + if ($config['namespaced'] && !is_dir($config['destDir'])) { + return true; + } + + $dest = $config['destDir'] . $config['link']; + + if (is_link($dest)) { + // phpcs:ignore + $success = DIRECTORY_SEPARATOR === '\\' ? @rmdir($dest) : @unlink($dest); + if ($success) { + $this->io->out('Unlinked ' . $dest); + + return true; + } + $this->io->error('Failed to unlink ' . $dest); + + return false; + } + + if (!file_exists($dest)) { + return true; + } + + $fs = new Filesystem(); + if (!$fs->deleteDir($dest)) { + $this->io->error('Failed to delete ' . $dest); + + return false; + } + + $this->io->out('Deleted ' . $dest); + + return true; + } + + /** + * Create directory + * + * @param string $dir Directory name + * @return bool + */ + protected function _createDirectory(string $dir): bool + { + // phpcs:disable + $result = @mkdir($dir, 0777 ^ umask(), true); + // phpcs:enable + + if ($result) { + $this->io->out('Created directory ' . $dir); + + return true; + } + + $this->io->error('Failed creating directory ' . $dir); + + return false; + } + + /** + * Create symlink + * + * @param string $target Target directory + * @param string $link Link name + * @param bool $relative Relative (true) or Absolute (false) + * @return bool + */ + protected function _createSymlink(string $target, string $link, bool $relative = false): bool + { + if ($relative) { + $target = $this->_makeRelativePath($link, $target); + } + + // phpcs:disable + $result = @symlink($target, $link); + // phpcs:enable + + if ($result) { + $this->io->out('Created symlink ' . $link); + + return true; + } + + return false; + } + + /** + * Generate a relative path from one directory to another. + * + * @param string $from The symlink path + * @param string $to The target path + * @return string Relative path + */ + protected function _makeRelativePath(string $from, string $to): string + { + $from = is_dir($from) ? rtrim($from, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR : dirname($from); + $from = realpath($from); + $to = realpath($to); + + if ($from === false || $to === false) { + throw new InvalidArgumentException('Invalid path provided to _makeRelativePath.'); + } + + $fromParts = explode(DIRECTORY_SEPARATOR, $from); + $toParts = explode(DIRECTORY_SEPARATOR, $to); + + $fromCount = count($fromParts); + $toCount = count($toParts); + + // Remove common parts + while ($fromCount && $toCount && $fromParts[0] === $toParts[0]) { + array_shift($fromParts); + array_shift($toParts); + $fromCount--; + $toCount--; + } + + return str_repeat('..' . DIRECTORY_SEPARATOR, $fromCount) . implode(DIRECTORY_SEPARATOR, $toParts); + } + + /** + * Checks if symlink exist and points to the correct target. + * + * @param string $target + * @param string $link + * @return bool + */ + protected function _isSymlinkValid(string $target, string $link): bool + { + if (!is_link($link)) { + return false; + } + + $linkedPath = readlink($link); + if ($linkedPath === false) { + return false; + } + + return realpath($target) === realpath($linkedPath); + } + + /** + * Copy directory + * + * @param string $source Source directory + * @param string $destination Destination directory + * @return bool + */ + protected function _copyDirectory(string $source, string $destination): bool + { + $fs = new Filesystem(); + if ($fs->copyDir($source, $destination)) { + $this->io->out('Copied assets to directory ' . $destination); + + return true; + } + + $this->io->error('Error copying assets to directory ' . $destination); + + return false; + } +} diff --git a/src/Command/PluginListCommand.php b/src/Command/PluginListCommand.php new file mode 100644 index 00000000000..455e92e6390 --- /dev/null +++ b/src/Command/PluginListCommand.php @@ -0,0 +1,105 @@ +getOption('composer-path'); + $config = PluginConfig::getAppConfig($path ?: null); + + $table = [ + ['Plugin', 'Is Loaded', 'Only Debug', 'Only CLI', 'Optional', 'Version'], + ]; + + if ($config === []) { + $io->warning(__d('cake', 'No plugins have been found.')); + + return static::CODE_ERROR; + } + + foreach ($config as $pluginName => $options) { + $isLoaded = $loadedPluginsCollection->has($pluginName); + $onlyDebug = $options['onlyDebug'] ?? false; + $onlyCli = $options['onlyCli'] ?? false; + $optional = $options['optional'] ?? false; + $version = $options['version'] ?? ''; + $table[] = [ + $pluginName, + $isLoaded ? 'X' : '', + $onlyDebug ? 'X' : '', + $onlyCli ? 'X' : '', + $optional ? 'X' : '', + $version, + ]; + } + $io->helper('Table')->output($table); + + return static::CODE_SUCCESS; + } + + /** + * Get the option parser. + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to update + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription(static::getDescription()); + $parser->addOption('composer-path', [ + 'help' => 'The absolute path to the composer.lock file to retrieve the versions from', + ]); + + return $parser; + } +} diff --git a/src/Command/PluginLoadCommand.php b/src/Command/PluginLoadCommand.php new file mode 100644 index 00000000000..1d9cce1a578 --- /dev/null +++ b/src/Command/PluginLoadCommand.php @@ -0,0 +1,249 @@ + + */ + protected static array $devTags = ['dev', 'testing', 'static analysis']; + + /** + * @var array + */ + protected static array $cliTags = ['cli', 'command line', 'shell']; + + /** + * Config file + * + * @var string + */ + protected string $configFile = CONFIG . 'plugins.php'; + + /** + * @inheritDoc + */ + public static function defaultName(): string + { + return 'plugin load'; + } + + /** + * @inheritDoc + */ + public static function getDescription(): string + { + return 'Command for loading plugins.'; + } + + /** + * Execute the command + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null The exit code or null for success + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $plugin = (string)$args->getArgument('plugin'); + $options = []; + if ($args->getOption('only-debug')) { + $options['onlyDebug'] = true; + } + if ($args->getOption('only-cli')) { + $options['onlyCli'] = true; + } + if ($args->getOption('optional')) { + $options['optional'] = true; + } + + foreach (PluginInterface::VALID_HOOKS as $hook) { + if ($args->getOption('no-' . $hook)) { + $options[$hook] = false; + } + } + + $path = null; + try { + $path = Plugin::getCollection()->findPath($plugin); + } catch (MissingPluginException $e) { + if (empty($options['optional'])) { + $io->error($e->getMessage()); + $io->error('Ensure you have the correct spelling and casing.'); + + return static::CODE_ERROR; + } + } + + $recommendations = $path ? $this->recommendations($path) : []; + foreach ($recommendations as $name => $v) { + if (isset($options[$name]) && $options[$name] === $v) { + continue; + } + + $option = $name . ': ' . ($v ? 'true' : 'false'); + $question = 'Based on the plugin composer keywords, this seems to be `' . $option . '`. '; + $question .= 'Do you want to change this?'; + $in = $io->askChoice($question, ['y', 'n'], 'y'); + if ($in !== 'y') { + continue; + } + + $options[$name] = $v; + } + + $result = $this->modifyConfigFile($plugin, $options); + if ($result === static::CODE_ERROR) { + $io->error('Failed to update `CONFIG/plugins.php`'); + } + + $io->success('Plugin added successfully to `CONFIG/plugins.php`'); + + return $result; + } + + /** + * Modify the plugins config file. + * + * @param string $plugin Plugin name. + * @param array $options Plugin options. + * @return int + */ + protected function modifyConfigFile(string $plugin, array $options): int + { + // phpcs:ignore + $config = @include $this->configFile; + if (!is_array($config)) { + $config = []; + } else { + $config = Hash::normalize($config); + } + + $config[$plugin] = $options; + + if (class_exists(VarExporter::class)) { + $array = VarExporter::export($config, VarExporter::TRAILING_COMMA_IN_ARRAY); + } else { + $array = var_export($config, true); + } + + $contents = 'configFile, $contents)) { + return static::CODE_SUCCESS; + } + + return static::CODE_ERROR; + } + + /** + * @param string $path + * @return array + */ + protected function recommendations(string $path): array + { + $file = $path . 'composer.json'; + if (!file_exists($file)) { + return []; + } + + $content = file_get_contents($file); + $array = $content ? json_decode($content, true) : []; + $keywords = $array['keywords'] ?? []; + if (!$keywords) { + return []; + } + + $recommendations = []; + foreach (static::$devTags as $tag) { + if (in_array($tag, $keywords, true)) { + $recommendations['onlyDebug'] = true; + } + } + foreach (static::$cliTags as $tag) { + if (in_array($tag, $keywords, true)) { + $recommendations['onlyCli'] = true; + } + } + + if (!empty($recommendations['onlyDebug'])) { + $recommendations['optional'] = true; + } + + return $recommendations; + } + + /** + * Get the option parser. + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to update + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + return $parser + ->setDescription(static::getDescription()) + ->addArgument('plugin', [ + 'help' => 'Name of the plugin to load. Must be in CamelCase format. Example: cake plugin load Example', + 'required' => true, + ]) + ->addOption('only-debug', [ + 'boolean' => true, + 'help' => 'Load the plugin only when `debug` is enabled.', + ]) + ->addOption('only-cli', [ + 'boolean' => true, + 'help' => 'Load the plugin only for CLI.', + ]) + ->addOption('optional', [ + 'boolean' => true, + 'help' => 'Do not throw an error if the plugin is not available.', + ]) + ->addOption('no-bootstrap', [ + 'boolean' => true, + 'help' => 'Do not run the `bootstrap()` hook.', + ]) + ->addOption('no-console', [ + 'boolean' => true, + 'help' => 'Do not run the `console()` hook.', + ]) + ->addOption('no-middleware', [ + 'boolean' => true, + 'help' => 'Do not run the `middleware()` hook.', + ]) + ->addOption('no-routes', [ + 'boolean' => true, + 'help' => 'Do not run the `routes()` hook.', + ]) + ->addOption('no-services', [ + 'boolean' => true, + 'help' => 'Do not run the `services()` hook.', + ]); + } +} diff --git a/src/Command/PluginLoadedCommand.php b/src/Command/PluginLoadedCommand.php new file mode 100644 index 00000000000..1e44d83c04b --- /dev/null +++ b/src/Command/PluginLoadedCommand.php @@ -0,0 +1,72 @@ +out($loaded); + + return static::CODE_SUCCESS; + } + + /** + * Get the option parser. + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to update + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription(static::getDescription()); + + return $parser; + } +} diff --git a/src/Command/PluginUnloadCommand.php b/src/Command/PluginUnloadCommand.php new file mode 100644 index 00000000000..a9db3c4be2c --- /dev/null +++ b/src/Command/PluginUnloadCommand.php @@ -0,0 +1,129 @@ +getArgument('plugin'); + + $result = $this->modifyConfigFile($plugin); + if ($result === null) { + $io->success('Plugin removed from `CONFIG/plugins.php`'); + + return static::CODE_SUCCESS; + } + + $io->err($result); + + return static::CODE_ERROR; + } + + /** + * Modify the plugins config file. + * + * @param string $plugin Plugin name. + * @return string|null + */ + protected function modifyConfigFile(string $plugin): ?string + { + // phpcs:ignore + $config = @include $this->configFile; + if (!is_array($config)) { + return '`CONFIG/plugins.php` not found or does not return an array'; + } + + $config = Hash::normalize($config); + if (!array_key_exists($plugin, $config)) { + return sprintf('Plugin `%s` could not be found', $plugin); + } + + unset($config[$plugin]); + + if (class_exists(VarExporter::class)) { + $array = VarExporter::export($config); + } else { + $array = var_export($config, true); + } + $contents = 'configFile, $contents)) { + return null; + } + + return 'Failed to update `CONFIG/plugins.php`'; + } + + /** + * Get the option parser. + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to update + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription( + static::getDescription(), + ) + ->addArgument('plugin', [ + 'help' => 'Name of the plugin to unload.', + 'required' => true, + ]); + + return $parser; + } +} diff --git a/src/Command/RoutesCheckCommand.php b/src/Command/RoutesCheckCommand.php new file mode 100644 index 00000000000..feaa310f3d1 --- /dev/null +++ b/src/Command/RoutesCheckCommand.php @@ -0,0 +1,108 @@ +getArgument('url'); + try { + $parsed = Router::parseRequest(new ServerRequest(['url' => $url])); + $name = $parsed['_name'] ?? $parsed['_route']->getName(); + + unset($parsed['_route'], $parsed['_matchedRoute']); + ksort($parsed); + + $output = [ + ['Route name', 'URI template', 'Defaults'], + [$name, $url, json_encode($parsed, JSON_THROW_ON_ERROR)], + ]; + $io->helper('table')->output($output); + $io->out(); + } catch (RedirectException $e) { + $output = [ + ['URI template', 'Redirect'], + [$url, $e->getMessage()], + ]; + $io->helper('table')->output($output); + $io->out(); + } catch (MissingRouteException) { + $io->warning("'{$url}' did not match any routes."); + $io->out(); + + return static::CODE_ERROR; + } + + return static::CODE_SUCCESS; + } + + /** + * Get the option parser. + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to update + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription([ + static::getDescription(), + 'Will output the routing parameters the route resolves to.', + ]) + ->addArgument('url', [ + 'help' => 'The URL to check.', + 'required' => true, + ]); + + return $parser; + } +} diff --git a/src/Command/RoutesCommand.php b/src/Command/RoutesCommand.php new file mode 100644 index 00000000000..15c23c5a7ff --- /dev/null +++ b/src/Command/RoutesCommand.php @@ -0,0 +1,158 @@ +getOption('with-middlewares') || $args->getOption('verbose')) { + $header[] = 'Middlewares'; + } + if ($args->getOption('verbose')) { + $header[] = 'Defaults'; + } + + $availableRoutes = Router::routes(); + $output = []; + $duplicateRoutesCounter = []; + + foreach ($availableRoutes as $route) { + $methods = isset($route->defaults['_method']) ? (array)$route->defaults['_method'] : ['']; + + $item = [ + $route->options['_name'] ?? $route->getName(), + $route->template, + $route->defaults['plugin'] ?? '', + $route->defaults['prefix'] ?? '', + $route->defaults['controller'] ?? '', + $route->defaults['action'] ?? '', + implode(', ', $methods), + ]; + + if ($args->getOption('with-middlewares') || $args->getOption('verbose')) { + $item[] = implode(', ', $route->getMiddleware()); + } + if ($args->getOption('verbose')) { + ksort($route->defaults); + $item[] = json_encode($route->defaults, JSON_THROW_ON_ERROR); + } + + $output[] = $item; + + foreach ($methods as $method) { + $duplicateRoutesCounter[$route->template][$method] ??= 0; + $duplicateRoutesCounter[$route->template][$method]++; + } + } + + if ($args->getOption('sort')) { + usort($output, function (array $a, array $b) { + return strcasecmp($a[0], $b[0]); + }); + } + + array_unshift($output, $header); + + $io->helper('table')->output($output); + $io->out(); + + $duplicateRoutes = []; + + foreach ($availableRoutes as $route) { + $methods = isset($route->defaults['_method']) ? (array)$route->defaults['_method'] : ['']; + + foreach ($methods as $method) { + if ( + $duplicateRoutesCounter[$route->template][$method] > 1 || + ($method === '' && count($duplicateRoutesCounter[$route->template]) > 1) || + ($method !== '' && isset($duplicateRoutesCounter[$route->template][''])) + ) { + $duplicateRoutes[] = [ + $route->options['_name'] ?? $route->getName(), + $route->template, + $route->defaults['plugin'] ?? '', + $route->defaults['prefix'] ?? '', + $route->defaults['controller'] ?? '', + $route->defaults['action'] ?? '', + implode(', ', $methods), + ]; + + break; + } + } + } + + if ($duplicateRoutes) { + array_unshift($duplicateRoutes, $header); + $io->warning('The following possible route collisions were detected.'); + $io->helper('table')->output($duplicateRoutes); + $io->out(); + } + + return static::CODE_SUCCESS; + } + + /** + * Get the option parser. + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to update + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser + ->setDescription(static::getDescription()) + ->addOption('sort', [ + 'help' => 'Sorts alphabetically by route name A-Z', + 'short' => 's', + 'boolean' => true, + ]) + ->addOption('with-middlewares', [ + 'help' => 'Show route specific middlewares', + 'short' => 'm', + 'boolean' => true, + ]); + + return $parser; + } +} diff --git a/src/Command/RoutesGenerateCommand.php b/src/Command/RoutesGenerateCommand.php new file mode 100644 index 00000000000..6275919b828 --- /dev/null +++ b/src/Command/RoutesGenerateCommand.php @@ -0,0 +1,112 @@ +_splitArgs($args->getArguments()); + $url = Router::url($args); + $io->out("> {$url}"); + $io->out(); + } catch (MissingRouteException) { + $io->warning('The provided parameters do not match any routes.'); + $io->out(); + + return static::CODE_ERROR; + } + + return static::CODE_SUCCESS; + } + + /** + * Split the CLI arguments into a hash. + * + * @param array $args The arguments to split. + * @return array + */ + protected function _splitArgs(array $args): array + { + $out = []; + foreach ($args as $arg) { + if (str_contains($arg, ':')) { + [$key, $value] = explode(':', $arg, 2); + if (in_array($value, ['true', 'false'], true)) { + $value = $value === 'true'; + } + $out[$key] = $value; + } else { + $out[] = $arg; + } + } + + return $out; + } + + /** + * Get the option parser. + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to update + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription([ + static::getDescription(), + 'Will output the URL if there is a match.' . + "\n\n" . + 'Routing parameters should be supplied in a key:value format. ' . + 'For example `controller:Articles action:view 2`', + ]); + + return $parser; + } +} diff --git a/src/Command/SchemacacheBuildCommand.php b/src/Command/SchemacacheBuildCommand.php new file mode 100644 index 00000000000..0fc323b5734 --- /dev/null +++ b/src/Command/SchemacacheBuildCommand.php @@ -0,0 +1,102 @@ +getOption('connection')); + assert($connection instanceof Connection); + + $cache = new SchemaCache($connection); + } catch (RuntimeException $e) { + $io->error($e->getMessage()); + + return static::CODE_ERROR; + } + $tables = $cache->build($args->getArgument('name')); + + foreach ($tables as $table) { + $io->verbose(sprintf('Cached `%s`', $table)); + } + + $io->out('Cache build complete'); + + return static::CODE_SUCCESS; + } + + /** + * Get the option parser. + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to update + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription([ + static::getDescription(), + ' If a table name is provided, only that table will be cached.', + ])->addOption('connection', [ + 'help' => 'The connection to build/clear metadata cache data for.', + 'short' => 'c', + 'default' => 'default', + ])->addArgument('name', [ + 'help' => 'A specific table you want to refresh cached data for.', + 'required' => false, + ]); + + return $parser; + } +} diff --git a/src/Command/SchemacacheClearCommand.php b/src/Command/SchemacacheClearCommand.php new file mode 100644 index 00000000000..bd5cc3305b8 --- /dev/null +++ b/src/Command/SchemacacheClearCommand.php @@ -0,0 +1,102 @@ +getOption('connection')); + assert($connection instanceof Connection); + + $cache = new SchemaCache($connection); + } catch (RuntimeException $e) { + $io->error($e->getMessage()); + + return static::CODE_ERROR; + } + $tables = $cache->clear($args->getArgument('name')); + + foreach ($tables as $table) { + $io->verbose(sprintf('Cleared `%s`', $table)); + } + + $io->out('Cache clear complete'); + + return static::CODE_SUCCESS; + } + + /** + * Get the option parser. + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to update + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription([ + static::getDescription(), + 'If a table name is provided, only that table will be removed.', + ])->addOption('connection', [ + 'help' => 'The connection to build/clear metadata cache data for.', + 'short' => 'c', + 'default' => 'default', + ])->addArgument('name', [ + 'help' => 'A specific table you want to clear cached data for.', + 'required' => false, + ]); + + return $parser; + } +} diff --git a/src/Command/ServerCommand.php b/src/Command/ServerCommand.php new file mode 100644 index 00000000000..b2be0e97b7b --- /dev/null +++ b/src/Command/ServerCommand.php @@ -0,0 +1,244 @@ +getOption('host')) { + $this->_host = (string)$args->getOption('host'); + } + if ($args->getOption('port')) { + $this->_port = (int)$args->getOption('port'); + } + if ($args->getOption('document_root')) { + $this->_documentRoot = (string)$args->getOption('document_root'); + } + if ($args->getOption('ini_path')) { + $this->_iniPath = (string)$args->getOption('ini_path'); + } + if ($args->getOption('frankenphp')) { + $this->server = 'frankenphp'; + } + + // For Windows + if (substr($this->_documentRoot, -1, 1) === DIRECTORY_SEPARATOR) { + $this->_documentRoot = substr($this->_documentRoot, 0, strlen($this->_documentRoot) - 1); + } + if (preg_match("/^([a-z]:)[\\\]+(.+)$/i", $this->_documentRoot, $m)) { + $this->_documentRoot = $m[1] . '\\' . $m[2]; + } + + $this->_iniPath = rtrim($this->_iniPath, DIRECTORY_SEPARATOR); + if (preg_match("/^([a-z]:)[\\\]+(.+)$/i", $this->_iniPath, $m)) { + $this->_iniPath = $m[1] . '\\' . $m[2]; + } + + $io->out(); + $io->out(sprintf('Welcome to CakePHP %s Console', 'v' . Configure::version())); + $io->hr(); + $io->out(sprintf('App : %s', Configure::read('App.dir'))); + $io->out(sprintf('Path: %s', APP)); + $io->out(sprintf('DocumentRoot: %s', $this->_documentRoot)); + $io->out(sprintf('Ini Path: %s', $this->_iniPath)); + $io->hr(); + } + + /** + * Execute. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int The exit code + */ + public function execute(Arguments $args, ConsoleIo $io): int + { + $this->startup($args, $io); + + $io->out(sprintf( + '%s server is running at http://%s:%s/', + $this->server, + $this->_host, + $this->_port, + )); + $io->out('You can exit with `CTRL-C`'); + + return $this->runCommand($this->{$this->server . 'Command'}()); + } + + /** + * Runs the command. + * + * @param string $command The command to run + * @return int The exit code + */ + protected function runCommand(string $command): int + { + if (system($command) === false) { + return static::CODE_ERROR; + } + + return static::CODE_SUCCESS; + } + + /** + * Returns the command to run PHP's built-in server. + * + * @return string + */ + protected function phpCommand(): string + { + $command = sprintf( + '%s -S %s:%d -t %s', + (string)env('PHP', 'php'), + $this->_host, + $this->_port, + escapeshellarg($this->_documentRoot), + ); + + if ($this->_iniPath) { + $command = sprintf('%s -c %s', $command, $this->_iniPath); + } + + return sprintf('%s %s', $command, escapeshellarg($this->_documentRoot . '/index.php')); + } + + /** + * Returns the command to run frankenphp's server. + * + * @return string + */ + protected function frankenphpCommand(): string + { + return sprintf( + '%s php-server -a -l %s:%d -r %s', + (string)env('FRANKENPHP', 'frankenphp'), + $this->_host, + $this->_port, + escapeshellarg($this->_documentRoot), + ); + } + + /** + * Hook method for defining this command's option parser. + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser to update + * @return \Cake\Console\ConsoleOptionParser + */ + public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription([ + static::getDescription(), + "[WARN] Don't use this in a production environment", + ])->addOption('host', [ + 'short' => 'H', + 'help' => 'ServerHost', + ])->addOption('port', [ + 'short' => 'p', + 'help' => 'ListenPort', + ])->addOption('ini_path', [ + 'short' => 'I', + 'help' => 'php.ini path', + ])->addOption('document_root', [ + 'short' => 'd', + 'help' => 'DocumentRoot', + ])->addOption('frankenphp', [ + 'boolean' => true, + 'short' => 'f', + 'help' => "Use frankenphp instead of PHP's built-in server", + ]); + + return $parser; + } +} diff --git a/src/Command/VersionCommand.php b/src/Command/VersionCommand.php new file mode 100644 index 00000000000..629727047f0 --- /dev/null +++ b/src/Command/VersionCommand.php @@ -0,0 +1,76 @@ +out($version); + + if ($args->getOption('verbose')) { + $this->outputVerbose($io, $version); + } + + return static::CODE_SUCCESS; + } + + /** + * Output verbose version information. + * + * @param \Cake\Console\ConsoleIo $io The console io + * @param string $version The CakePHP version + * @return void + */ + protected function outputVerbose(ConsoleIo $io, string $version): void + { + $io->out(); + + // Show release link for stable and RC versions, but not dev + if (!str_contains($version, '-dev')) { + $io->out(sprintf( + 'Release: https://github.com/cakephp/cakephp/releases/tag/%s', + $version, + )); + } + + $io->out(sprintf('PHP: %s (%s)', PHP_VERSION, PHP_SAPI)); + } +} diff --git a/src/Console/Arguments.php b/src/Console/Arguments.php new file mode 100644 index 00000000000..fb126afe07b --- /dev/null +++ b/src/Console/Arguments.php @@ -0,0 +1,305 @@ + + */ + protected array $argNames; + + /** + * Positional arguments. + * + * @var array|string> + */ + protected array $args; + + /** + * Named options + * + * @var array|string|bool|null> + */ + protected array $options; + + /** + * Constructor + * + * @param array|string> $args Positional arguments + * @param array|string|bool|null> $options Named arguments + * @param array $argNames List of argument names. Order is expected to be + * the same as $args. + */ + public function __construct(array $args, array $options, array $argNames) + { + $this->args = $args; + $this->options = $options; + $this->argNames = $argNames; + } + + /** + * Get all positional arguments. + * + * @return array|string> + */ + public function getArguments(): array + { + return $this->args; + } + + /** + * Get positional arguments by index. + * + * @param int $index The argument index to access. + * @return string|null The argument value or null + */ + public function getArgumentAt(int $index): ?string + { + if (!$this->hasArgumentAt($index)) { + return null; + } + + $value = $this->args[$index]; + + if ($value !== null && !is_string($value)) { + throw new ConsoleException(sprintf( + 'Argument at index `%d` is not of type `string`, use `getArrayArgument()` instead.', + $index, + )); + } + + return $value; + } + + /** + * Get positional arguments (multiple) by index. + * + * @param int $index The argument index to access. + * @return array|null The argument value or null + */ + public function getArrayArgumentAt(int $index): ?array + { + if (!$this->hasArgumentAt($index)) { + return null; + } + + $value = $this->args[$index]; + + if ($value !== null && !is_array($value)) { + throw new ConsoleException(sprintf( + 'Argument at index `%d` is not of type `array`, use `getArgument()` instead.', + $index, + )); + } + + return $value; + } + + /** + * Check if a positional argument exists by index + * + * @param int $index The argument index to check. + * @return bool + */ + public function hasArgumentAt(int $index): bool + { + return isset($this->args[$index]); + } + + /** + * Check if a positional argument exists by name + * + * @param string $name The argument name to check. + * @return bool + */ + public function hasArgument(string $name): bool + { + $offset = array_search($name, $this->argNames, true); + if ($offset === false) { + return false; + } + + return isset($this->args[$offset]); + } + + /** + * Returns positional argument value by name or null if doesn't exist + * + * @param string $name The argument name to check. + * @return string|null + */ + public function getArgument(string $name): ?string + { + $this->assertArgumentExists($name); + + $offset = array_search($name, $this->argNames, true); + $value = $this->args[$offset] ?? null; + + if ($value !== null && !is_string($value)) { + throw new ConsoleException(sprintf( + 'Argument `%s` is not of type `string`, use `getArrayArgument()` instead.', + $name, + )); + } + + return $value; + } + + /** + * Gets a multiple (array) argument's value or null if not set. + * + * @param string $name Argument name. + * @return array|null + */ + public function getArrayArgument(string $name): ?array + { + $this->assertArgumentExists($name); + + $offset = array_search($name, $this->argNames, true); + $value = $this->args[$offset] ?? null; + + if ($value !== null && !is_array($value)) { + throw new ConsoleException(sprintf( + 'Argument `%s` is not of type `array`, use `getArgument()` instead.', + $name, + )); + } + + return $value; + } + + /** + * Get an array of all the options + * + * @return array|string|bool|null> + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * Get a non-multiple option's value or null if not set. + * + * @param string $name The name of the option to check. + * @return string|bool|null + */ + public function getOption(string $name): string|bool|null + { + $value = $this->options[$name] ?? null; + if (is_array($value)) { + throw new ConsoleException(sprintf( + 'Cannot get multiple values for option `%s`, use `getArrayOption()` instead.', + $name, + )); + } + + assert($value === null || is_string($value) || is_bool($value)); + + return $value; + } + + /** + * Get a boolean option's value or null if not set. + * + * @param string $name Option name. + * @return bool|null + */ + public function getBooleanOption(string $name): ?bool + { + $value = $this->options[$name] ?? null; + if ($value !== null && !is_bool($value)) { + throw new ConsoleException(sprintf( + 'Option `%s` is not of type `bool`, use `getOption()` instead.', + $name, + )); + } + + return $value; + } + + /** + * Gets a multiple option's value or null if not set. + * + * @return array|null + * @deprecated 5.2.0 Use getArrayOption instead. + */ + public function getMultipleOption(string $name): ?array + { + deprecationWarning( + '5.2.0', + 'getMultipleOption() is deprecated. Use `getArrayOption()` instead.', + ); + + return $this->getArrayOption($name); + } + + /** + * Gets a multiple (array) option's value or null if not set. + * + * @return array|null + */ + public function getArrayOption(string $name): ?array + { + $value = $this->options[$name] ?? null; + if ($value !== null && !is_array($value)) { + throw new ConsoleException(sprintf( + 'Option `%s` is not of type `array`, use `getOption()` instead.', + $name, + )); + } + + return $value; + } + + /** + * Check if an option is defined and not null. + * + * @param string $name The name of the option to check. + * @return bool + */ + public function hasOption(string $name): bool + { + return isset($this->options[$name]); + } + + /** + * @param string $name + * @return void + */ + protected function assertArgumentExists(string $name): void + { + if (in_array($name, $this->argNames, true)) { + return; + } + + throw new ConsoleException(sprintf( + 'Argument `%s` is not defined on this Command. Could this be an option maybe?', + $name, + )); + } +} diff --git a/src/Console/BaseCommand.php b/src/Console/BaseCommand.php new file mode 100644 index 00000000000..17280582193 --- /dev/null +++ b/src/Console/BaseCommand.php @@ -0,0 +1,357 @@ +factory = $factory; + $this->getEventManager()->on($this); + } + + /** + * @inheritDoc + */ + public function setName(string $name) + { + assert( + str_contains($name, ' ') && !str_starts_with($name, ' '), + "The name '{$name}' is missing a space. Names should look like `cake routes`", + ); + $this->name = $name; + + return $this; + } + + /** + * Get the command name. + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get the command description. + * + * @return string + */ + public static function getDescription(): string + { + return ''; + } + + /** + * Get the root command name. + * + * @return string + */ + public function getRootName(): string + { + [$root] = explode(' ', $this->name); + + return $root; + } + + /** + * Get the command name. + * + * Returns the command name based on class name. + * For e.g. for a command with class name `UpdateTableCommand` the default + * name returned would be `'update_table'`. + * + * @return string + */ + public static function defaultName(): string + { + $pos = strrpos(static::class, '\\'); + $name = substr(static::class, $pos + 1, -7); + + return Inflector::underscore($name); + } + + /** + * Get the option parser. + * + * You can override buildOptionParser() to define your options & arguments. + * + * @return \Cake\Console\ConsoleOptionParser + * @throws \Cake\Core\Exception\CakeException When the parser is invalid + */ + public function getOptionParser(): ConsoleOptionParser + { + [$root, $name] = explode(' ', $this->name, 2); + $parser = new ConsoleOptionParser($name); + $parser->setRootName($root); + $parser->setDescription(static::getDescription()); + + return $this->buildOptionParser($parser); + } + + /** + * Hook method for defining this command's option parser. + * + * @param \Cake\Console\ConsoleOptionParser $parser The parser to be defined + * @return \Cake\Console\ConsoleOptionParser The built parser. + */ + protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + return $parser; + } + + /** + * Hook method invoked by CakePHP when a command is about to be executed. + * + * Override this method and implement expensive/important setup steps that + * should not run on every command run. This method will be called *after* + * the options and arguments are validated and processed, so `$this->args` + * and `$this->io` are both available. + * + * @return void + */ + public function initialize(): void + { + } + + /** + * Returns a list of all events that will fire in the command during its lifecycle. + * You can override this function to add your own listener callbacks + * + * @return array + */ + public function implementedEvents(): array + { + return [ + 'Command.beforeExecute' => 'beforeExecute', + 'Command.afterExecute' => 'afterExecute', + ]; + } + + /** + * Called immediately prior to the command's run method. You can use this method to configure and customize the + * command or perform logic that needs to happen before the command runs. + * + * @param \Cake\Event\EventInterface $event An Event instance + * @param \Cake\Console\Arguments $args + * @param \Cake\Console\ConsoleIo $io + * @return void + * @link https://book.cakephp.org/5/en/console-commands/commands.html#lifecycle-callbacks + */ + public function beforeExecute(EventInterface $event, Arguments $args, ConsoleIo $io): void + { + } + + /** + * Called immediately after the command's run method, unless an exception occurs. You can use this method to + * perform logic that needs to happen after the command runs. + * + * @param \Cake\Event\EventInterface $event An Event instance + * @param \Cake\Console\Arguments $args + * @param \Cake\Console\ConsoleIo $io + * @param int|null $result + * @return void + * @link https://book.cakephp.org/5/en/console-commands/commands.html#lifecycle-callbacks + */ + public function afterExecute(EventInterface $event, Arguments $args, ConsoleIo $io, ?int $result): void + { + } + + /** + * @inheritDoc + */ + public function run(array $argv, ConsoleIo $io): ?int + { + $this->io = $io; + + $parser = $this->getOptionParser(); + try { + [$options, $arguments] = $parser->parse($argv, $io); + $args = new Arguments( + $arguments, + $options, + $parser->argumentNames(), + ); + } catch (ConsoleException $e) { + $io->error('Error: ' . $e->getMessage()); + + return static::CODE_ERROR; + } + $this->args = $args; + + $this->setOutputLevel($args, $io); + $this->initialize(); + + if ($args->getOption('help')) { + $this->displayHelp($parser, $args, $io); + + return static::CODE_SUCCESS; + } + + if ($args->getOption('quiet')) { + $io->setInteractive(false); + } + + $this->dispatchEvent('Command.beforeExecute', ['args' => $args, 'io' => $io]); + $result = $this->execute($args, $io); + $this->dispatchEvent('Command.afterExecute', ['args' => $args, 'io' => $io, 'result' => $result]); + + return $result; + } + + /** + * Output help content + * + * @param \Cake\Console\ConsoleOptionParser $parser The option parser. + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return void + */ + protected function displayHelp(ConsoleOptionParser $parser, Arguments $args, ConsoleIo $io): void + { + $format = 'text'; + if ($args->getArgumentAt(0) === 'xml') { + $format = 'xml'; + $io->setOutputAs(ConsoleOutput::RAW); + } + + $io->out($parser->help($format)); + } + + /** + * Set the output level based on the Arguments. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return void + */ + protected function setOutputLevel(Arguments $args, ConsoleIo $io): void + { + $io->setLoggers(ConsoleIo::NORMAL); + if ($args->getOption('quiet')) { + $io->level(ConsoleIo::QUIET); + $io->setLoggers(ConsoleIo::QUIET); + } + if ($args->getOption('verbose')) { + $io->level(ConsoleIo::VERBOSE); + $io->setLoggers(ConsoleIo::VERBOSE); + } + } + + /** + * Implement this method with your command's logic. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null|void The exit code or null for success + */ + abstract public function execute(Arguments $args, ConsoleIo $io); + + /** + * Halt the current process with a StopException. + * + * @param int $code The exit code to use. + * @throws \Cake\Console\Exception\StopException + * @return never + */ + public function abort(int $code = self::CODE_ERROR): never + { + throw new StopException('Command aborted', $code); + } + + /** + * Execute another command with the provided set of arguments. + * + * If you are using a string command name, that command's dependencies + * will not be resolved with the application container. Instead you will + * need to pass the command as an object with all of its dependencies. + * + * @param \Cake\Console\CommandInterface|string $command The command class name or command instance. + * @param array $args The arguments to invoke the command with. + * @param \Cake\Console\ConsoleIo|null $io The ConsoleIo instance to use for the executed command. + * @return int|null The exit code or null for success of the command. + */ + public function executeCommand(CommandInterface|string $command, array $args = [], ?ConsoleIo $io = null): ?int + { + if (is_string($command)) { + assert( + is_subclass_of($command, CommandInterface::class), + sprintf('Command `%s` is not a subclass of `%s`.', $command, CommandInterface::class), + ); + + $command = $this->factory?->create($command) ?? new $command(); + } + $io = $io ?: new ConsoleIo(); + + try { + return $command->run($args, $io); + } catch (StopException $e) { + return $e->getCode(); + } + } +} diff --git a/src/Console/Command/HelpCommand.php b/src/Console/Command/HelpCommand.php new file mode 100644 index 00000000000..2dbb8a21be7 --- /dev/null +++ b/src/Console/Command/HelpCommand.php @@ -0,0 +1,548 @@ +commands = $commands; + } + + /** + * Set the header line rendered above command listings. + * + * @param string $headerLine Header text including optional console markup. + * @return void + */ + public function setHeaderLine(string $headerLine): void + { + $this->headerLine = $headerLine; + } + + /** + * Main function Prints out the list of commands. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $commands = $this->commands->getIterator(); + if ($commands instanceof ArrayIterator) { + $commands->ksort(); + } + + // Filter by command prefix if provided + $filter = $args->getArgument('command'); + if ($filter) { + $commands = $this->filterByPrefix($commands, $filter); + } + + if ($args->getOption('xml')) { + $this->asXml($io, $commands); + + return static::CODE_SUCCESS; + } + + $verbose = $io->level() >= ConsoleIo::VERBOSE; + $this->asText($io, $commands, $verbose); + + return static::CODE_SUCCESS; + } + + /** + * Filter commands by prefix. + * + * @param iterable $commands The command collection. + * @param string $prefix The prefix to filter by. + * @return array Filtered commands. + */ + protected function filterByPrefix(iterable $commands, string $prefix): array + { + $filtered = []; + foreach ($commands as $name => $class) { + if (str_starts_with($name, $prefix . ' ') || $name === $prefix) { + $filtered[$name] = $class; + } + } + + return $filtered; + } + + /** + * Output text. + * + * @param \Cake\Console\ConsoleIo $io The console io + * @param iterable $commands The command collection to output. + * @param bool $verbose Whether to show verbose output with descriptions. + * @return void + */ + protected function asText(ConsoleIo $io, iterable $commands, bool $verbose = false): void + { + $invert = []; + foreach ($commands as $name => $class) { + // Skip hidden commands + if (is_subclass_of($class, CommandHiddenInterface::class)) { + continue; + } + if (is_object($class)) { + $class = $class::class; + } + $invert[$class] ??= []; + $invert[$class][] = $name; + } + + $commandList = []; + foreach ($invert as $class => $names) { + preg_match('/^(.+)\\\\Command\\\\/', $class, $matches); + // Probably not a useful class + if (!$matches) { + continue; + } + $shortestName = $this->getShortestName($names); + if (str_contains($shortestName, '.')) { + [, $shortestName] = explode('.', $shortestName, 2); + } + + $commandList[] = [ + 'name' => $shortestName, + 'description' => is_subclass_of($class, BaseCommand::class) ? $class::getDescription() : '', + ]; + } + sort($commandList); + + $headerLine = $this->getHeaderLine(); + if ($headerLine !== '') { + $io->out($headerLine, 2); + } + + if ($verbose) { + $this->outputPaths($io); + $this->outputGrouped($io, $invert); + } else { + $this->outputCompactCommands($io, $commandList); + $io->out(''); + } + + $root = $this->getRootName(); + $io->out("To run a command, type `{$root} command_name [args|options]`"); + $io->out("To get help on a specific command, type `{$root} command_name --help`"); + if (!$verbose) { + $io->out("To see full descriptions and plugin grouping, use `{$root} --help -v`", 2); + } else { + $io->out('', 2); + } + } + + /** + * Get the help output header line. + * + * @return string + */ + protected function getHeaderLine(): string + { + if ($this->headerLine !== null) { + return $this->headerLine; + } + + $version = Configure::version(); + if ($version === 'unknown') { + return ''; + } + + $debug = Configure::read('debug') ? 'true' : 'false'; + + return "CakePHP: {$version} (debug: {$debug})"; + } + + /** + * Output commands grouped by plugin/namespace (verbose mode). + * + * @param \Cake\Console\ConsoleIo $io The console io + * @param array> $invert Inverted command map (class => names). + * @return void + */ + protected function outputGrouped(ConsoleIo $io, array $invert): void + { + $grouped = []; + $plugins = Plugin::loaded(); + foreach ($invert as $class => $names) { + preg_match('/^(.+)\\\\Command\\\\/', $class, $matches); + if (!$matches || $names === []) { + continue; + } + $namespace = str_replace('\\', '/', $matches[1]); + $prefix = 'app'; + if ($namespace === 'Cake') { + $prefix = 'cakephp'; + } elseif (method_exists($class, 'getGroup')) { + $prefix = $class::getGroup(); + } elseif (in_array($namespace, $plugins, true)) { + $prefix = Inflector::underscore($namespace); + } + $shortestName = $this->getShortestName($names); + if (str_contains($shortestName, '.')) { + [, $shortestName] = explode('.', $shortestName, 2); + } + + $grouped[$prefix][] = [ + 'name' => $shortestName, + 'description' => is_subclass_of($class, BaseCommand::class) ? $class::getDescription() : '', + ]; + } + ksort($grouped); + + if (isset($grouped['app'])) { + $app = $grouped['app']; + unset($grouped['app']); + $grouped = ['app' => $app] + $grouped; + } + + $io->out('Available Commands:', 2); + foreach ($grouped as $prefix => $names) { + $io->out("{$prefix}:"); + sort($names); + foreach ($names as $data) { + $io->out(' - ' . $data['name'] . ''); + if ($data['description']) { + $io->info(str_pad(" \u{2514}", 13, "\u{2500}") . ' ' . $data['description']); + } + } + $io->out(''); + } + } + + /** + * Output commands with inline descriptions, grouped by prefix. + * + * @param \Cake\Console\ConsoleIo $io The console io + * @param array $commands List of commands with names and descriptions. + * @return void + */ + protected function outputCompactCommands(ConsoleIo $io, array $commands): void + { + $maxWidth = $this->getTerminalWidth(); + + // Group commands by their first word (prefix) + $groups = []; + foreach ($commands as $data) { + $parts = explode(' ', $data['name'], 2); + $prefix = $parts[0]; + $subcommand = $parts[1] ?? null; + + $groups[$prefix] ??= []; + $groups[$prefix][] = [ + 'subcommand' => $subcommand, + 'description' => $data['description'], + ]; + } + + // Separate single commands from grouped commands + $singleCommands = []; + $groupedCommands = []; + + foreach ($groups as $prefix => $cmds) { + if (count($cmds) === 1 && $cmds[0]['subcommand'] === null) { + $singleCommands[$prefix] = $cmds[0]; + } else { + $groupedCommands[$prefix] = $cmds; + } + } + + // Find the longest full command name for padding + $maxNameLength = 0; + foreach ($commands as $data) { + $maxNameLength = max($maxNameLength, strlen($data['name'])); + } + $nameColumnWidth = max($maxNameLength + 5, 17); + + // Output single commands under "Available Commands:" header + $isFirst = true; + if ($singleCommands !== []) { + $io->out('Available Commands:'); + foreach ($singleCommands as $prefix => $cmd) { + $description = $cmd['description']; + $padding = str_repeat(' ', $nameColumnWidth - 2 - strlen($prefix)); + $linePrefix = ' ' . $prefix . '' . $padding; + + if ($description !== '') { + $description = strtok($description, "\n"); + $this->outputWrappedLine($io, $linePrefix, $description, $maxWidth); + } else { + $io->out($linePrefix); + } + } + $isFirst = false; + } + + // Output grouped commands with headers + foreach ($groupedCommands as $prefix => $cmds) { + if (!$isFirst) { + $io->out(''); + } + $io->out("{$prefix}:"); + + foreach ($cmds as $cmd) { + $fullName = $cmd['subcommand'] !== null ? $prefix . ' ' . $cmd['subcommand'] : $prefix; + $description = $cmd['description']; + + $padding = str_repeat(' ', $nameColumnWidth - 2 - strlen($fullName)); + $linePrefix = ' ' . $fullName . '' . $padding; + + if ($description !== '') { + $description = strtok($description, "\n"); + $this->outputWrappedLine($io, $linePrefix, $description, $maxWidth); + } else { + $io->out($linePrefix); + } + } + $isFirst = false; + } + } + + /** + * Output a line with description, wrapping based on terminal width. + * + * @param \Cake\Console\ConsoleIo $io The console io + * @param string $prefix The line prefix (command name with padding) + * @param string $description The description text + * @param int $maxWidth Maximum terminal width + * @param int $maxChars Maximum total description characters (0 = unlimited) + * @return void + */ + protected function outputWrappedLine( + ConsoleIo $io, + string $prefix, + string $description, + int $maxWidth, + int $maxChars = 200, + ): void { + $prefixLength = strlen($this->stripMarkup($prefix)); + $availableWidth = $maxWidth - $prefixLength; + + if ($availableWidth <= 10) { + $io->out($prefix); + + return; + } + + // Truncate description to max chars if set + if ($maxChars > 0 && strlen($description) > $maxChars) { + $description = substr($description, 0, $maxChars - 3) . '...'; + } + + if (strlen($description) <= $availableWidth) { + $io->out($prefix . $description); + + return; + } + + // Wrap description across multiple lines + $indent = str_repeat(' ', $prefixLength); + $remaining = $description; + $firstLine = true; + + while ($remaining !== '') { + $linePrefix = $firstLine ? $prefix : $indent; + $firstLine = false; + + if (strlen($remaining) <= $availableWidth) { + $io->out($linePrefix . $remaining); + break; + } + + // Find word break point + $breakPoint = strrpos(substr($remaining, 0, $availableWidth), ' '); + if ($breakPoint === false || $breakPoint < $availableWidth / 2) { + $breakPoint = $availableWidth; + } + + $io->out($linePrefix . substr($remaining, 0, $breakPoint)); + $remaining = ltrim(substr($remaining, $breakPoint)); + } + } + + /** + * Get terminal width for line wrapping. + * + * @return int Terminal width in columns + */ + protected function getTerminalWidth(): int + { + // Check COLUMNS environment variable (commonly set by shells) + $columns = getenv('COLUMNS'); + if ($columns !== false && is_numeric($columns) && (int)$columns > 0) { + return (int)$columns; + } + + // Try tput cols (Unix/Linux/macOS) + if (str_contains(strtolower(PHP_OS), 'win') === false) { + $result = null; + $output = exec('tput cols 2>/dev/null', result_code: $result); + if ($result === 0 && is_numeric($output) && (int)$output > 0) { + return (int)$output; + } + + // Try stty size (returns "rows cols") + $output = exec('stty size 2>/dev/null', result_code: $result); + if ($result === 0 && $output !== false && preg_match('/^\d+\s+(\d+)$/', $output, $matches)) { + return (int)$matches[1]; + } + } + + // Default to 120 columns (modern terminals) + return 120; + } + + /** + * Output relevant paths if defined + * + * @param \Cake\Console\ConsoleIo $io IO object. + * @return void + */ + protected function outputPaths(ConsoleIo $io): void + { + $paths = []; + if (Configure::check('App.dir')) { + $appPath = rtrim(Configure::read('App.dir'), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + // Extra space is to align output + $paths['app'] = ' ' . $appPath; + } + if (defined('ROOT')) { + $paths['root'] = rtrim(ROOT, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + } + if (defined('CORE_PATH')) { + $paths['core'] = rtrim(CORE_PATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + } + if ($paths === []) { + return; + } + $io->out('Current Paths:', 2); + foreach ($paths as $key => $value) { + $io->out("* {$key}: {$value}"); + } + $io->out(''); + } + + /** + * @param non-empty-array $names Names + * @return string + */ + protected function getShortestName(array $names): string + { + usort($names, function ($a, $b) { + return strlen($a) - strlen($b); + }); + + return array_shift($names); + } + + /** + * Strip ConsoleOutput markup tags from a string. + * + * @param string $text Text that may contain markup tags + * @return string Text with markup tags removed + */ + protected function stripMarkup(string $text): string + { + return preg_replace('/<\/?[a-z]+>/', '', $text) ?? $text; + } + + /** + * Output as XML + * + * @param \Cake\Console\ConsoleIo $io The console io + * @param iterable $commands The command collection to output + * @return void + */ + protected function asXml(ConsoleIo $io, iterable $commands): void + { + $shells = new SimpleXMLElement(''); + foreach ($commands as $name => $class) { + // Skip hidden commands + if (is_subclass_of($class, CommandHiddenInterface::class)) { + continue; + } + if (is_object($class)) { + $class = $class::class; + } + $shell = $shells->addChild('shell'); + $shell->addAttribute('name', $name); + $shell->addAttribute('call_as', $name); + $shell->addAttribute('provider', $class); + $shell->addAttribute('help', $name . ' -h'); + } + $io->setOutputAs(ConsoleOutput::RAW); + $io->out((string)$shells->saveXML()); + } + + /** + * Gets the option parser instance and configures it. + * + * @param \Cake\Console\ConsoleOptionParser $parser The parser to build + * @return \Cake\Console\ConsoleOptionParser + */ + protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->setDescription( + 'Get the list of available commands for this application.', + )->addArgument('command', [ + 'help' => 'Filter commands by prefix (e.g., "cache" to show only cache commands).', + ])->addOption('xml', [ + 'help' => 'Get the listing as XML.', + 'boolean' => true, + ]); + + return $parser; + } +} diff --git a/src/Console/CommandCollection.php b/src/Console/CommandCollection.php index 4cf3e242422..3f5b3ad5d2b 100644 --- a/src/Console/CommandCollection.php +++ b/src/Console/CommandCollection.php @@ -1,46 +1,49 @@ > */ class CommandCollection implements IteratorAggregate, Countable { /** * Command list * - * @var array + * @var array> */ - protected $commands = []; + protected array $commands = []; /** * Constructor * - * @param array $commands The map of commands to add to the collection. + * @param array> $commands The map of commands to add to the collection. */ public function __construct(array $commands = []) { @@ -53,17 +56,28 @@ public function __construct(array $commands = []) * Add a command to the collection * * @param string $name The name of the command you want to map. - * @param string|\Cake\Console\Shell $command The command to map. + * @param \Cake\Console\CommandInterface|class-string<\Cake\Console\CommandInterface> $command The command to map. + * Can be a FQCN or CommandInterface instance. * @return $this + * @throws \InvalidArgumentException */ - public function add($name, $command) + public function add(string $name, CommandInterface|string $command) { - // Once we have a new Command class this should check - // against that interface. - if (!is_subclass_of($command, Shell::class)) { - $class = is_string($command) ? $command : get_class($command); + if (is_string($command)) { + assert( + is_subclass_of($command, CommandInterface::class), + sprintf( + 'Cannot use `%s` for command `%s`. ' . + 'It is not a subclass of `%s`.', + $command, + $name, + CommandInterface::class, + ), + ); + } + if (!preg_match('/^[^\s]+(?:(?: [^\s]+){1,2})?$/ui', $name)) { throw new InvalidArgumentException( - "Cannot use '$class' for command '$name' it is not a subclass of Cake\Console\Shell." + "The command name `{$name}` is invalid. Names can only be a maximum of three words.", ); } @@ -75,7 +89,7 @@ public function add($name, $command) /** * Add multiple commands at once. * - * @param array $commands A map of command names => command classes/instances. + * @param array> $commands A map of command names => command classes/instances. * @return $this * @see \Cake\Console\CommandCollection::add() */ @@ -94,20 +108,37 @@ public function addMany(array $commands) * @param string $name The named shell. * @return $this */ - public function remove($name) + public function remove(string $name) { unset($this->commands[$name]); return $this; } + /** + * Replace a command from the collection with another command if it exists. + * + * @param string $oldName Name of command to remove. + * @param string $newName The name of the command you want to map. + * @param \Cake\Console\CommandInterface|class-string<\Cake\Console\CommandInterface> $command The command to map. + * Can be a FQCN or CommandInterface instance. + * @return $this + */ + public function replace(string $oldName, string $newName, CommandInterface|string $command) + { + $this->remove($oldName); + $this->add($newName, $command); + + return $this; + } + /** * Check whether the named shell exists in the collection. * * @param string $name The named shell. * @return bool */ - public function has($name) + public function has(string $name): bool { return isset($this->commands[$name]); } @@ -116,13 +147,13 @@ public function has($name) * Get the target for a command. * * @param string $name The named shell. - * @return string|\Cake\Console\Shell Either the shell class or an instance. + * @return \Cake\Console\CommandInterface|class-string<\Cake\Console\CommandInterface> Either the command class or an instance. * @throws \InvalidArgumentException when unknown commands are fetched. */ - public function get($name) + public function get(string $name): CommandInterface|string { if (!$this->has($name)) { - throw new InvalidArgumentException("The $name is not a known command name."); + throw new InvalidArgumentException(sprintf('The `%s` is not a known command name.', $name)); } return $this->commands[$name]; @@ -131,9 +162,9 @@ public function get($name) /** * Implementation of IteratorAggregate. * - * @return \ArrayIterator + * @return \Traversable> */ - public function getIterator() + public function getIterator(): Traversable { return new ArrayIterator($this->commands); } @@ -145,65 +176,136 @@ public function getIterator() * * @return int */ - public function count() + public function count(): int { return count($this->commands); } /** - * Automatically discover shell commands in CakePHP, the application and all plugins. + * Auto-discover commands from the named plugin. * - * Commands will be located using filesystem conventions. Commands are - * discovered in the following order: + * Discovered commands will have their names de-duplicated with + * existing commands in the collection. If a command is already + * defined in the collection and discovered in a plugin, only + * the long name (`plugin.command`) will be returned. * - * - CakePHP provided commands - * - Application commands - * - Plugin commands + * @param string $plugin The plugin to scan. + * @return array> Discovered plugin commands. + */ + public function discoverPlugin(string $plugin): array + { + $scanner = new CommandScanner(); + $shells = $scanner->scanPlugin($plugin); + + return $this->resolveNames($shells); + } + + /** + * Auto-discover commands in a filesystem directory. + * + * Useful for registering commands that live in a subdirectory and + * namespace of the application (e.g. `src/Command/Maintenance/`) + * without listing each command individually, typically from + * `Application::console()`: * - * Commands from plugins will be added based on the order plugins are loaded. - * Plugin shells will attempt to use a short name. If however, a plugin - * provides a shell that conflicts with CakePHP or the application shells, - * the full `plugin_name.shell` name will be used. Plugin shells are added - * in the order that plugins were loaded. + * ``` + * $commands->addMany($commands->discoverDirectory( + * ROOT . '/src/Command/Maintenance', + * 'App\\Command\\Maintenance', + * 'maintenance ', + * )); + * ``` * - * @return array An array of command names and their classes. + * The directory is scanned non-recursively. A non-empty `$prefix` is + * required. The prefix produces a `prefix.command` long name so discovered + * commands whose short name collides with an existing command is exposed + * only under its prefixed name, and does not replace an existing command. + * The prefixed long name itself follows the same last-wins + * precedence as `discoverPlugin()` / `autoDiscover()`. The path and + * namespace are normalized, so a trailing separator is optional. + * + * @param string $path The directory to scan. + * @param string $namespace The namespace the commands live in. + * @param string $prefix Prefix applied to each command's full name. + * @return array> Discovered commands. + * @throws \InvalidArgumentException When `$prefix` is empty. */ - public function autoDiscover() + public function discoverDirectory(string $path, string $namespace, string $prefix): array { + if ($prefix === '') { + throw new InvalidArgumentException( + 'A non-empty `$prefix` is required so discovered commands ' . + 'de-duplicate against existing ones instead of overwriting them.', + ); + } + $namespace = rtrim($namespace, '\\') . '\\'; $scanner = new CommandScanner(); - $shells = $scanner->scanAll(); - $adder = function ($out, $shells, $key) { - if (empty($shells[$key])) { - return $out; - } + return $this->resolveNames($scanner->scanDir($path, $namespace, $prefix)); + } - foreach ($shells[$key] as $info) { - $name = $info['name']; - $addLong = $name !== $info['fullName']; - - // If the short name has been used, use the full name. - // This allows app shells to have name preference. - // and app shells to overwrite core shells. - if (isset($out[$name]) && $addLong) { - $name = $info['fullName']; - } - - $out[$name] = $info['class']; - if ($addLong) { - $out[$info['fullName']] = $info['class']; - } - } + /** + * Resolve names based on existing commands + * + * @param array> $input The results of a CommandScanner operation. + * @return array> A flat map of command names => class names. + */ + protected function resolveNames(array $input): array + { + $out = []; + foreach ($input as $info) { + $name = $info['name']; + $addLong = $name !== $info['fullName']; - return $out; - }; + // If the short name has been used, use the full name. + // This allows app shells to have name preference. + // and app shells to overwrite core shells. + if ($this->has($name) && $addLong) { + $name = $info['fullName']; + } - $out = $adder([], $shells, 'CORE'); - $out = $adder($out, $shells, 'app'); - foreach (array_keys($shells['plugins']) as $key) { - $out = $adder($out, $shells['plugins'], $key); + /** @var class-string<\Cake\Console\CommandInterface> $class */ + $class = $info['class']; + $out[$name] = $class; + if ($addLong) { + $out[$info['fullName']] = $class; + } } return $out; } + + /** + * Automatically discover commands in CakePHP, the application and all plugins. + * + * Commands will be located using filesystem conventions. Commands are + * discovered in the following order: + * + * - CakePHP provided commands + * - Application commands + * + * Commands defined in the application will overwrite commands with + * the same name provided by CakePHP. + * + * @return array> An array of command names and their classes. + */ + public function autoDiscover(): array + { + $scanner = new CommandScanner(); + + $core = $this->resolveNames($scanner->scanCore()); + $app = $this->resolveNames($scanner->scanApp()); + + return $app + $core; + } + + /** + * Get the list of available command names. + * + * @return array Command names + */ + public function keys(): array + { + return array_keys($this->commands); + } } diff --git a/src/Console/CommandCollectionAwareInterface.php b/src/Console/CommandCollectionAwareInterface.php index 89a2ade6eb4..5c2e457c4ec 100644 --- a/src/Console/CommandCollectionAwareInterface.php +++ b/src/Console/CommandCollectionAwareInterface.php @@ -1,23 +1,24 @@ container = $container; + } + + /** + * @inheritDoc + */ + public function create(string $className): CommandInterface + { + if ($this->container?->has($className)) { + return $this->container->get($className); + } + + /** @var \Cake\Console\CommandInterface */ + return new $className($this); + } +} diff --git a/src/Console/CommandFactoryInterface.php b/src/Console/CommandFactoryInterface.php new file mode 100644 index 00000000000..5d04472cf3d --- /dev/null +++ b/src/Console/CommandFactoryInterface.php @@ -0,0 +1,29 @@ + $className Command class name. + * @return \Cake\Console\CommandInterface + */ + public function create(string $className): CommandInterface; +} diff --git a/src/Console/CommandHiddenInterface.php b/src/Console/CommandHiddenInterface.php new file mode 100644 index 00000000000..3f48e920887 --- /dev/null +++ b/src/Console/CommandHiddenInterface.php @@ -0,0 +1,37 @@ + */ - protected $aliases = []; + protected array $aliases = [ + '--version' => 'version', + '--help' => 'help', + '-h' => 'help', + '-v' => 'help', + '--verbose' => 'help', + ]; /** * Constructor * * @param \Cake\Core\ConsoleApplicationInterface $app The application to run CLI commands for. * @param string $root The root command name to be removed from argv. + * @param \Cake\Console\CommandFactoryInterface|null $factory Command factory instance. */ - public function __construct(ConsoleApplicationInterface $app, $root = 'cake') - { + public function __construct( + ConsoleApplicationInterface $app, + string $root = 'cake', + ?CommandFactoryInterface $factory = null, + ) { $this->app = $app; $this->root = $root; - $this->aliases = [ - '--version' => 'version', - '--help' => 'help', - '-h' => 'help', - ]; + $this->factory = $factory; } /** @@ -84,7 +105,7 @@ public function __construct(ConsoleApplicationInterface $app, $root = 'cake') * $runner->setAliases(['--version' => 'version']); * ``` * - * @param array $aliases The map of aliases to replace. + * @param array $aliases The map of aliases to replace. * @return $this */ public function setAliases(array $aliases) @@ -105,52 +126,137 @@ public function setAliases(array $aliases) * - Run the requested command. * * @param array $argv The arguments from the CLI environment. - * @param \Cake\Console\ConsoleIo $io The ConsoleIo instance. Used primarily for testing. + * @param \Cake\Console\ConsoleIo|null $io The ConsoleIo instance. Used primarily for testing. * @return int The exit code of the command. - * @throws \RuntimeException */ - public function run(array $argv, ConsoleIo $io = null) + public function run(array $argv, ?ConsoleIo $io = null): int { - $this->app->bootstrap(); + assert($argv !== [], 'Cannot run any commands. No arguments received.'); + + $this->bootstrap(); $commands = new CommandCollection([ - 'version' => VersionShell::class, - 'help' => HelpShell::class, + 'help' => HelpCommand::class, ]); + if (class_exists(VersionCommand::class)) { + $commands->add('version', VersionCommand::class); + } $commands = $this->app->console($commands); - if (!($commands instanceof CommandCollection)) { - $type = is_object($commands) ? get_class($commands) : gettype($commands); - throw new RuntimeException( - "The application's `console` method did not return a CommandCollection." . - " Got '{$type}' instead." - ); + + if ($this->app instanceof PluginApplicationInterface) { + $commands = $this->app->pluginConsole($commands); } $this->dispatchEvent('Console.buildCommands', ['commands' => $commands]); + $this->loadRoutes(); - if (empty($argv)) { - throw new RuntimeException("Cannot run any commands. No arguments received."); - } // Remove the root executable segment array_shift($argv); $io = $io ?: new ConsoleIo(); - $shell = $this->getShell($io, $commands, array_shift($argv)); + + /** @var array{string|null, array} $resolved */ + $resolved = $this->longestCommandName($commands, $argv); + [$name, $argv] = $resolved; + + // If -v/--verbose is used as command, preserve it as flag for help command + if ($name === '-v' || $name === '--verbose') { + $argv = array_merge([$name], $argv); + $name = 'help'; + } + + // Check if this is a command prefix (e.g., "cache" has subcommands like "cache clear") + // Show help for that prefix instead of running the base command + if ($name !== null && !$commands->has($name) && $this->hasCommandsWithPrefix($commands, $name)) { + $argv = [$name]; + $name = 'help'; + } try { - $shell->initialize(); - $result = $shell->runCommand($argv, true); - } catch (StopException $e) { - return $e->getCode(); + $name = $this->resolveName($commands, $io, $name); + } catch (MissingOptionException $e) { + $io->error($e->getFullMessage()); + + return CommandInterface::CODE_ERROR; + } + + $command = $this->getCommand($io, $commands, $name); + + // If the matched command also has sibling subcommands (e.g. `i18n` exists alongside + // `i18n init` / `i18n extract`), an unknown next token is almost always a typo for a + // subcommand. Reject it instead of letting it fall through as a positional argument. + // + // Commands that declare their own positional arguments are exempt: there the next token + // is a legitimate argument (e.g. `bake template Articles` alongside `bake template all`), + // not a mistyped subcommand, and only the command's own parser can tell the two apart. + if ( + isset($argv[0]) + && !str_starts_with($argv[0], '-') + && $this->hasCommandsWithPrefix($commands, $name) + && $this->isArgumentlessCommand($command) + ) { + $candidate = $name . ' ' . $argv[0]; + if (!$commands->has($candidate)) { + $io->error($this->unknownSubcommandMessage($commands, $name, $argv[0])); + + return CommandInterface::CODE_ERROR; + } } - if ($result === null || $result === true) { - return Shell::CODE_SUCCESS; + $result = $this->runCommand($command, $argv, $io); + + if ($result === null) { + return CommandInterface::CODE_SUCCESS; } - if (is_int($result)) { + if ($result >= 0 && $result <= 255) { return $result; } - return Shell::CODE_ERROR; + return CommandInterface::CODE_ERROR; + } + + /** + * Application bootstrap wrapper. + * + * Calls the application's `bootstrap()` hook. After the application the + * plugins are bootstrapped and events are registered. + * + * @return void + */ + protected function bootstrap(): void + { + $this->app->bootstrap(); + if ($this->app instanceof PluginApplicationInterface) { + $this->app->pluginBootstrap(); + } + } + + /** + * Get the application's event manager or the global one. + * + * @return \Cake\Event\EventManagerInterface + */ + public function getEventManager(): EventManagerInterface + { + if ($this->app instanceof PluginApplicationInterface) { + return $this->app->getEventManager(); + } + + return EventManager::instance(); + } + + /** + * Get/set the application's event manager. + * + * @param \Cake\Event\EventManagerInterface $eventManager The event manager to set. + * @return $this + */ + public function setEventManager(EventManagerInterface $eventManager) + { + if ($this->app instanceof EventDispatcherInterface) { + $this->app->setEventManager($eventManager); + } + + return $this; } /** @@ -159,47 +265,233 @@ public function run(array $argv, ConsoleIo $io = null) * @param \Cake\Console\ConsoleIo $io The IO wrapper for the created shell class. * @param \Cake\Console\CommandCollection $commands The command collection to find the shell in. * @param string $name The command name to find - * @return \Cake\Console\Shell + * @return \Cake\Console\CommandInterface + */ + protected function getCommand(ConsoleIo $io, CommandCollection $commands, string $name): CommandInterface + { + $instance = $commands->get($name); + if (is_string($instance)) { + $instance = $this->createCommand($instance); + } + + if ($instance instanceof HelpCommand && $this->app instanceof ConsoleHelpHeaderProviderInterface) { + $instance->setHeaderLine($this->app->getConsoleHelpHeader()); + } + + $instance->setName("{$this->root} {$name}"); + + if ($instance instanceof CommandCollectionAwareInterface) { + $instance->setCommandCollection($commands); + } + + return $instance; + } + + /** + * Build the longest command name that exists in the collection + * + * Build the longest command name that matches a + * defined command. This will traverse a maximum of 3 tokens. + * + * @param \Cake\Console\CommandCollection $commands The command collection to check. + * @param array $argv The CLI arguments. + * @return array An array of the resolved name and modified argv. + */ + protected function longestCommandName(CommandCollection $commands, array $argv): array + { + for ($i = 3; $i > 1; $i--) { + $parts = array_slice($argv, 0, $i); + $name = implode(' ', $parts); + if ($commands->has($name)) { + return [$name, array_slice($argv, $i)]; + } + + $firstChar = $name[0] ?? ''; + if ($firstChar === strtoupper($firstChar) && str_contains($name, '.')) { + $underName = Inflector::underscore($name); + if ($commands->has($underName)) { + return [$underName, array_slice($argv, $i)]; + } + } + } + $name = array_shift($argv); + + return [$name, $argv]; + } + + /** + * Resolve the command name into a name that exists in the collection. + * + * Apply backwards compatible inflections and aliases. + * Will step forward up to 3 tokens in $argv to generate + * a command name in the CommandCollection. More specific + * command names take precedence over less specific ones. + * + * @param \Cake\Console\CommandCollection $commands The command collection to check. + * @param \Cake\Console\ConsoleIo $io ConsoleIo object for errors. + * @param string|null $name The name from the CLI args. + * @return string The resolved name. + * @throws \Cake\Console\Exception\MissingOptionException */ - protected function getShell(ConsoleIo $io, CommandCollection $commands, $name) + protected function resolveName(CommandCollection $commands, ConsoleIo $io, ?string $name): string { if (!$name) { - $io->err('No command provided. Choose one of the available commands.', 2); $name = 'help'; } - if (isset($this->aliases[$name])) { - $name = $this->aliases[$name]; - } + $name = $this->aliases[$name] ?? $name; if (!$commands->has($name)) { $name = Inflector::underscore($name); } if (!$commands->has($name)) { - throw new RuntimeException( - "Unknown command `{$this->root} {$name}`." . - " Run `{$this->root} --help` to get the list of valid commands." + throw new MissingOptionException( + "Unknown command `{$this->root} {$name}`. " . + "Run `{$this->root} --help` to get the list of commands.", + $name, + $commands->keys(), ); } - $instance = $commands->get($name); - if (is_string($instance)) { - $instance = $this->createShell($instance, $io); + + return $name; + } + + /** + * Check if there are commands that start with the given prefix. + * + * @param \Cake\Console\CommandCollection $commands The command collection. + * @param string $prefix The prefix to check. + * @return bool True if commands with this prefix exist. + */ + protected function hasCommandsWithPrefix(CommandCollection $commands, string $prefix): bool + { + foreach ($commands->keys() as $name) { + if (str_starts_with($name, $prefix . ' ')) { + return true; + } } - $instance->setRootName($this->root); - if ($instance instanceof CommandCollectionAwareInterface) { - $instance->setCommandCollection($commands); + + return false; + } + + /** + * Check whether a command is known to accept no positional arguments. + * + * Only returns true when the command's option parser can be inspected and declares zero + * arguments. When the parser cannot be determined, it errs on the side of caution and + * returns false, so a potentially valid command is run rather than rejected by the + * sibling-subcommand check as a mistyped subcommand. + * + * @param \Cake\Console\CommandInterface $command The resolved command instance to inspect. + * @return bool + */ + protected function isArgumentlessCommand(CommandInterface $command): bool + { + if (!$command instanceof BaseCommand) { + return false; } - return $instance; + try { + $parser = $command->getOptionParser(); + } catch (Throwable) { + return false; + } + + return $parser->arguments() === []; } /** - * The wrapper for creating shell instances. + * Build the error message shown when a token following a command name doesn't + * match any known subcommand of that command. * - * @param string $className Shell class name. - * @param \Cake\Console\ConsoleIo $io The IO wrapper for the created shell class. - * @return \Cake\Console\Shell + * @param \Cake\Console\CommandCollection $commands The command collection. + * @param string $name The matched command name (e.g. "i18n"). + * @param string $token The unknown next token (e.g. "nonsense"). + * @return string + */ + protected function unknownSubcommandMessage( + CommandCollection $commands, + string $name, + string $token, + ): string { + $prefix = $name . ' '; + $available = []; + foreach ($commands->keys() as $key) { + if (str_starts_with($key, $prefix)) { + $available[] = $key; + } + } + sort($available); + + $message = "Unknown command `{$this->root} {$name} {$token}`."; + if ($available !== []) { + $message .= "\nAvailable subcommands: `" . implode('`, `', $available) . '`.'; + } + $message .= "\nRun `{$this->root} {$name} --help` to see usage."; + + return $message; + } + + /** + * Execute a Command class. + * + * @param \Cake\Console\CommandInterface $command The command to run. + * @param array $argv The CLI arguments to invoke. + * @param \Cake\Console\ConsoleIo $io The console io + * @return int|null Exit code */ - protected function createShell($className, ConsoleIo $io) + protected function runCommand(CommandInterface $command, array $argv, ConsoleIo $io): ?int { - return new $className($io); + try { + if ($command instanceof EventListenerInterface) { + $this->getEventManager()->on($command); + } + if ($command instanceof EventDispatcherInterface) { + $command->setEventManager($this->getEventManager()); + } + + return $command->run($argv, $io); + } catch (StopException $e) { + return $e->getCode(); + } + } + + /** + * The wrapper for creating command instances. + * + * @param class-string<\Cake\Console\CommandInterface> $className Command class name. + * @return \Cake\Console\CommandInterface + */ + protected function createCommand(string $className): CommandInterface + { + if (!$this->factory) { + $container = null; + if ($this->app instanceof ContainerApplicationInterface) { + $container = $this->app->getContainer(); + } + + $this->factory = new CommandFactory($container); + $container?->add(CommandFactoryInterface::class, $this->factory); + } + + return $this->factory->create($className); + } + + /** + * Ensure that the application's routes are loaded. + * + * Console commands and shells often need to generate URLs. + * + * @return void + */ + protected function loadRoutes(): void + { + if (!($this->app instanceof RoutingApplicationInterface)) { + return; + } + $builder = Router::createRouteBuilder('/'); + + $this->app->routes($builder); + if ($this->app instanceof PluginApplicationInterface) { + $this->app->pluginRoutes($builder); + } } } diff --git a/src/Console/CommandScanner.php b/src/Console/CommandScanner.php index 44a317a7bf6..fc34a5dca4c 100644 --- a/src/Console/CommandScanner.php +++ b/src/Console/CommandScanner.php @@ -1,109 +1,147 @@ scanDir( - App::path('Shell')[0], - $appNamespace . '\Shell\\', + return $this->scanDir( + dirname(__DIR__) . DIRECTORY_SEPARATOR . 'Command' . DIRECTORY_SEPARATOR, + 'Cake\Command\\', '', - ['app'] + ['command_list'], ); + } - $shellList['CORE'] = $this->scanDir( - dirname(__DIR__) . DIRECTORY_SEPARATOR . 'Shell' . DIRECTORY_SEPARATOR, - 'Cake\Shell\\', - '', - ['command_list'] + /** + * Scan the application for shells & commands. + * + * @return array A list of command metadata. + */ + public function scanApp(): array + { + $appNamespace = Configure::read('App.namespace'); + + return $this->scanDir( + App::classPath('Command')[0], + $appNamespace . '\Command\\', ); + } - $plugins = []; - foreach (Plugin::loaded() as $plugin) { - $plugins[$plugin] = $this->scanDir( - Plugin::classPath($plugin) . 'Shell', - str_replace('/', '\\', $plugin) . '\Shell\\', - Inflector::underscore($plugin) . '.', - [] - ); + /** + * Scan the named plugin for shells and commands + * + * @param string $plugin The named plugin. + * @return array A list of command metadata. + */ + public function scanPlugin(string $plugin): array + { + if (!Plugin::isLoaded($plugin)) { + return []; } - $shellList['plugins'] = $plugins; + $path = Plugin::classPath($plugin); + $namespace = str_replace('/', '\\', $plugin); + $prefix = Inflector::underscore($plugin) . '.'; - return $shellList; + return $this->scanDir($path . 'Command', $namespace . '\Command\\', $prefix); } /** * Scan a directory for .php files and return the class names that * should be within them. * - * @param string $path The directory to read. + * @param string $path The directory to read. Must end with a trailing directory separator. * @param string $namespace The namespace the shells live in. * @param string $prefix The prefix to apply to commands for their full name. - * @param array $hide A list of command names to hide as they are internal commands. + * @param array $hide A list of command names to hide as they are internal commands. * @return array The list of shell info arrays based on scanning the filesystem and inflection. + * @internal Reachable via CommandCollection discovery; not a supported entry point on its own. */ - protected function scanDir($path, $namespace, $prefix, array $hide) + public function scanDir(string $path, string $namespace, string $prefix = '', array $hide = []): array { - $dir = new Folder($path); - $contents = $dir->read(true, true); - if (empty($contents[1])) { + if (!is_dir($path)) { return []; } - $shells = []; - foreach ($contents[1] as $file) { - if (substr($file, -4) !== '.php') { - continue; - } + $path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + + // This ensures `Command` class is not added to the list. + $hide[] = ''; + + $classPattern = '/Command\.php$/'; + /** @var \Iterator<\SplFileInfo> $files */ + $files = (new Finder()) + ->in($path) + ->recursive(false) + ->name('*Command.php') + ->files(); - $shell = substr($file, 0, -4); - $name = Inflector::underscore(str_replace('Shell', '', $shell)); + $commands = []; + foreach ($files as $fileInfo) { + $file = $fileInfo->getFilename(); + + $name = Inflector::underscore((string)preg_replace($classPattern, '', $file)); if (in_array($name, $hide, true)) { continue; } - $shells[] = [ + $class = $namespace . $fileInfo->getBasename('.php'); + if (!is_subclass_of($class, CommandInterface::class)) { + continue; + } + $reflection = new ReflectionClass($class); + if ($reflection->isAbstract()) { + continue; + } + if (is_subclass_of($class, BaseCommand::class)) { + $name = $class::defaultName(); + } + $commands[$path . $file] = [ 'file' => $path . $file, 'fullName' => $prefix . $name, 'name' => $name, - 'class' => $namespace . $shell + 'class' => $class, ]; } - return $shells; + ksort($commands); + + return array_values($commands); } } diff --git a/src/Console/ConsoleErrorHandler.php b/src/Console/ConsoleErrorHandler.php deleted file mode 100644 index 798cffa2c17..00000000000 --- a/src/Console/ConsoleErrorHandler.php +++ /dev/null @@ -1,137 +0,0 @@ -_stderr = $options['stderr']; - $this->_options = $options; - } - - /** - * Handle errors in the console environment. Writes errors to stderr, - * and logs messages if Configure::read('debug') is false. - * - * @param \Exception $exception Exception instance. - * @return void - * @throws \Exception When renderer class not found - * @see https://secure.php.net/manual/en/function.set-exception-handler.php - */ - public function handleException(Exception $exception) - { - $this->_displayException($exception); - $this->_logException($exception); - $code = $exception->getCode(); - $code = ($code && is_int($code)) ? $code : 1; - $this->_stop($code); - } - - /** - * Prints an exception to stderr. - * - * @param \Exception $exception The exception to handle - * @return void - */ - protected function _displayException($exception) - { - $errorName = 'Exception:'; - if ($exception instanceof FatalErrorException) { - $errorName = 'Fatal Error:'; - } - - if ($exception instanceof PHP7ErrorException) { - $exception = $exception->getError(); - } - - $message = sprintf( - '%s %s in [%s, line %s]', - $errorName, - $exception->getMessage(), - $exception->getFile(), - $exception->getLine() - ); - $this->_stderr->write($message); - } - - /** - * Prints an error to stderr. - * - * Template method of BaseErrorHandler. - * - * @param array $error An array of error data. - * @param bool $debug Whether or not the app is in debug mode. - * @return void - */ - protected function _displayError($error, $debug) - { - $message = sprintf( - '%s in [%s, line %s]', - $error['description'], - $error['file'], - $error['line'] - ); - $message = sprintf( - "%s Error: %s\n", - $error['error'], - $message - ); - $this->_stderr->write($message); - } - - /** - * Stop the execution and set the exit code for the process. - * - * @param int $code The exit code. - * @return void - */ - protected function _stop($code) - { - exit($code); - } -} diff --git a/src/Console/ConsoleInput.php b/src/Console/ConsoleInput.php index 118cc1fea32..5621d6efbe9 100644 --- a/src/Console/ConsoleInput.php +++ b/src/Console/ConsoleInput.php @@ -1,4 +1,6 @@ _canReadline = (extension_loaded('readline') && $handle === 'php://stdin'); - $this->_input = fopen($handle, 'rb'); + $input = fopen($handle, 'rb'); + if ($input === false) { + throw new CakeException(sprintf('Cannot open handle `%s`', $handle)); + } + + $this->_input = $input; + } + + /** + * Destruct and free resources + */ + public function __destruct() + { + // @phpstan-ignore isset.property (property may not be set if constructor throws) + if (isset($this->_input) && is_resource($this->_input)) { + fclose($this->_input); + } + unset($this->_input); } /** * Read a value from the stream * - * @return mixed The value of the stream + * @return string|null The value of the stream. Null on EOF. */ - public function read() + public function read(): ?string { if ($this->_canReadline) { $line = readline(''); - if (strlen($line) > 0) { + + if ($line !== false && $line !== '') { readline_add_history($line); } + } else { + $line = fgets($this->_input); + } - return $line; + if ($line === false) { + return null; } - return fgets($this->_input); + return $line; } /** @@ -74,11 +100,25 @@ public function read() * @param int $timeout An optional time to wait for data * @return bool True for data available, false otherwise */ - public function dataAvailable($timeout = 0) + public function dataAvailable(int $timeout = 0): bool { $readFds = [$this->_input]; + $writeFds = null; + $errorFds = null; + + /** @var string|null $error */ + $error = null; + set_error_handler(function (int $code, string $message) use (&$error) { + $error = "stream_select failed with code={$code} message={$message}."; + + return true; + }); $readyFds = stream_select($readFds, $writeFds, $errorFds, $timeout); + restore_error_handler(); + if ($error !== null) { + throw new ConsoleException($error); + } - return ($readyFds > 0); + return $readyFds > 0; } } diff --git a/src/Console/ConsoleInputArgument.php b/src/Console/ConsoleInputArgument.php index bc0314dfd9f..c59c960563c 100644 --- a/src/Console/ConsoleInputArgument.php +++ b/src/Console/ConsoleInputArgument.php @@ -1,4 +1,6 @@ + */ + protected array $_choices; + + /** + * Default value for this argument. + * + * @var string|null + */ + protected ?string $_default = null; + + /** + * The multiple separator. + * + * @var string|null */ - protected $_choices; + protected ?string $_separator = null; /** * Make a new Input Argument * - * @param string|array $name The long name of the option, or an array with all the properties. + * @param array|string $name The long name of the option, or an array with all the properties. * @param string $help The help text for this option * @param bool $required Whether this argument is required. Missing required args will trigger exceptions - * @param array $choices Valid choices for this option. + * @param array $choices Valid choices for this option. + * @param string|null $default The default value for this argument. */ - public function __construct($name, $help = '', $required = false, $choices = []) - { - if (is_array($name) && isset($name['name'])) { + public function __construct( + array|string $name, + string $help = '', + bool $required = false, + array $choices = [], + ?string $default = null, + ?string $separator = null, + ) { + if (is_array($name)) { + if (!isset($name['name'])) { + throw new CakeException('You must provide a `name` for the argument.'); + } + foreach ($name as $key => $value) { $this->{'_' . $key} = $value; } @@ -73,6 +100,17 @@ public function __construct($name, $help = '', $required = false, $choices = []) $this->_help = $help; $this->_required = $required; $this->_choices = $choices; + $this->_default = $default; + $this->_separator = $separator; + } + + if ($this->_separator !== null && str_contains($this->_separator, ' ')) { + throw new ConsoleException( + sprintf( + 'The argument separator must not contain spaces for `%s`.', + $this->_name, + ), + ); } } @@ -81,7 +119,7 @@ public function __construct($name, $help = '', $required = false, $choices = []) * * @return string Value of this->_name. */ - public function name() + public function name(): string { return $this->_name; } @@ -92,9 +130,10 @@ public function name() * @param \Cake\Console\ConsoleInputArgument $argument ConsoleInputArgument to compare to. * @return bool */ - public function isEqualTo(ConsoleInputArgument $argument) + public function isEqualTo(ConsoleInputArgument $argument): bool { - return $this->usage() === $argument->usage(); + return $this->name() === $argument->name() && + $this->usage() === $argument->usage(); } /** @@ -103,7 +142,7 @@ public function isEqualTo(ConsoleInputArgument $argument) * @param int $width The width to make the name of the option. * @return string */ - public function help($width = 0) + public function help(int $width = 0): string { $name = $this->_name; if (strlen($name) < $width) { @@ -116,6 +155,12 @@ public function help($width = 0) if ($this->_choices) { $optional .= sprintf(' (choices: %s)', implode('|', $this->_choices)); } + if ($this->_default !== null) { + $optional .= sprintf(' default: "%s"', $this->_default); + } + if ($this->_separator) { + $optional .= sprintf(' (separator: "%s")', $this->_separator); + } return sprintf('%s%s%s', $name, $this->_help, $optional); } @@ -125,7 +170,7 @@ public function help($width = 0) * * @return string */ - public function usage() + public function usage(): string { $name = $this->_name; if ($this->_choices) { @@ -133,42 +178,69 @@ public function usage() } $name = '<' . $name . '>'; if (!$this->isRequired()) { - $name = '[' . $name . ']'; + return '[' . $name . ']'; } return $name; } + /** + * Get the default value for this argument + * + * @return string|null + */ + public function defaultValue(): ?string + { + return $this->_default; + } + /** * Check if this argument is a required argument * * @return bool */ - public function isRequired() + public function isRequired(): bool { - return (bool)$this->_required; + return $this->_required; + } + + /** + * Get the value of the separator. + * + * @return string|null Value of this->_separator. + */ + public function separator(): ?string + { + return $this->_separator; } /** * Check that $value is a valid choice for this argument. * * @param string $value The choice to validate. - * @return bool + * @return true * @throws \Cake\Console\Exception\ConsoleException */ - public function validChoice($value) + public function validChoice(string $value): bool { - if (empty($this->_choices)) { + if ($this->_choices === []) { return true; } - if (!in_array($value, $this->_choices)) { + if ($value && $this->_separator) { + $values = explode($this->_separator, $value); + } else { + $values = [$value]; + } + + $unwanted = array_filter($values, fn(string $value) => !in_array($value, $this->_choices, true)); + if ($unwanted) { throw new ConsoleException( sprintf( - '"%s" is not a valid value for %s. Please use one of "%s"', + '`%s` is not a valid value for `%s`. Please use one of `%s`', $value, $this->_name, - implode(', ', $this->_choices) - ) + implode('|', $this->_choices), + ), ); } @@ -181,16 +253,23 @@ public function validChoice($value) * @param \SimpleXMLElement $parent The parent element. * @return \SimpleXMLElement The parent with this argument appended. */ - public function xml(SimpleXMLElement $parent) + public function xml(SimpleXMLElement $parent): SimpleXMLElement { $option = $parent->addChild('argument'); + assert($option !== null); $option->addAttribute('name', $this->_name); $option->addAttribute('help', $this->_help); - $option->addAttribute('required', (int)$this->isRequired()); + $option->addAttribute('required', (string)(int)$this->isRequired()); + if ($this->separator() !== null) { + $option->addAttribute('separator', $this->separator()); + } $choices = $option->addChild('choices'); foreach ($this->_choices as $valid) { $choices->addChild('choice', $valid); } + if ($this->_default !== null) { + $option->addAttribute('default', $this->_default); + } return $parent; } diff --git a/src/Console/ConsoleInputOption.php b/src/Console/ConsoleInputOption.php index 87d280a9725..6d6d8c826a5 100644 --- a/src/Console/ConsoleInputOption.php +++ b/src/Console/ConsoleInputOption.php @@ -1,4 +1,6 @@ */ - protected $_choices; + protected array $_choices; + + /** + * The prompt string + * + * @var string|null + */ + protected ?string $prompt = null; + + /** + * Is the option required. + * + * @var bool + */ + protected bool $required; + + /** + * The multiple separator. + * + * @var string|null + */ + protected ?string $_separator = null; /** * Make a new Input Option * - * @param string|array $name The long name of the option, or an array with all the properties. + * @param string $name The long name of the option, or an array with all the properties. * @param string $short The short alias for this option * @param string $help The help text for this option - * @param bool $boolean Whether this option is a boolean option. Boolean options don't consume extra tokens - * @param string $default The default value for this option. - * @param array $choices Valid choices for this option. + * @param bool $isBoolean Whether this option is a boolean option. Boolean options don't consume extra tokens + * @param string|bool|null $default The default value for this option. + * @param array $choices Valid choices for this option. * @param bool $multiple Whether this option can accept multiple value definition. + * @param bool $required Whether this option is required or not. + * @param string|null $prompt The prompt string. * @throws \Cake\Console\Exception\ConsoleException */ public function __construct( - $name, - $short = '', - $help = '', - $boolean = false, - $default = '', - $choices = [], - $multiple = false + string $name, + string $short = '', + string $help = '', + bool $isBoolean = false, + string|bool|null $default = null, + array $choices = [], + bool $multiple = false, + bool $required = false, + ?string $prompt = null, + ?string $separator = null, ) { - if (is_array($name) && isset($name['name'])) { - foreach ($name as $key => $value) { - $this->{'_' . $key} = $value; - } - } else { - $this->_name = $name; - $this->_short = $short; - $this->_help = $help; - $this->_boolean = $boolean; - $this->_default = $default; - $this->_choices = $choices; - $this->_multiple = $multiple; + $this->_name = $name; + $this->_short = $short; + $this->_help = $help; + $this->_boolean = $isBoolean; + $this->_choices = $choices; + $this->_multiple = $multiple; + $this->required = $required; + $this->prompt = $prompt; + $this->_separator = $separator; + + if ($isBoolean) { + $this->_default = (bool)$default; + } elseif ($default !== null) { + $this->_default = (string)$default; } + if (strlen($this->_short) > 1) { throw new ConsoleException( - sprintf('Short option "%s" is invalid, short options must be one letter.', $this->_short) + sprintf('Short option `%s` is invalid, short options must be one letter.', $this->_short), + ); + } + if ($this->_default !== null && $this->prompt) { + throw new ConsoleException( + 'You cannot set both `prompt` and `default` options. ' . + 'Use either a static `default` or interactive `prompt`', + ); + } + + if ($this->_separator !== null && str_contains($this->_separator, ' ')) { + throw new ConsoleException( + sprintf( + 'The option separator must not contain spaces for `%s`.', + $this->_name, + ), ); } } @@ -121,7 +166,7 @@ public function __construct( * * @return string Value of this->_name. */ - public function name() + public function name(): string { return $this->_name; } @@ -131,35 +176,44 @@ public function name() * * @return string Value of this->_short. */ - public function short() + public function short(): string { return $this->_short; } /** - * Generate the help for this this option. + * Generate the help for this option. * * @param int $width The width to make the name of the option. * @return string */ - public function help($width = 0) + public function help(int $width = 0): string { - $default = $short = ''; + $default = ''; + $short = ''; if ($this->_default && $this->_default !== true) { $default = sprintf(' (default: %s)', $this->_default); } if ($this->_choices) { $default .= sprintf(' (choices: %s)', implode('|', $this->_choices)); } - if (strlen($this->_short) > 0) { + if ($this->_multiple && $this->_separator) { + $default .= sprintf(' (separator: `%s`)', $this->_separator); + } + + if ($this->_short !== '') { $short = ', -' . $this->_short; } $name = sprintf('--%s%s', $this->_name, $short); if (strlen($name) < $width) { $name = str_pad($name, $width, ' '); } + $required = ''; + if ($this->isRequired()) { + $required = ' (required)'; + } - return sprintf('%s%s%s', $name, $this->_help, $default); + return sprintf('%s%s%s%s', $name, $this->_help, $default, $required); } /** @@ -167,38 +221,52 @@ public function help($width = 0) * * @return string */ - public function usage() + public function usage(): string { - $name = (strlen($this->_short) > 0) ? ('-' . $this->_short) : ('--' . $this->_name); + $name = $this->_short === '' ? '--' . $this->_name : '-' . $this->_short; $default = ''; - if (strlen($this->_default) > 0 && $this->_default !== true) { + if ($this->_default !== null && !is_bool($this->_default) && $this->_default !== '') { $default = ' ' . $this->_default; } if ($this->_choices) { $default = ' ' . implode('|', $this->_choices); } + $template = '[%s%s]'; + if ($this->isRequired()) { + $template = '%s%s'; + } - return sprintf('[%s%s]', $name, $default); + return sprintf($template, $name, $default); } /** * Get the default value for this option * - * @return mixed + * @return string|bool|null */ - public function defaultValue() + public function defaultValue(): string|bool|null { return $this->_default; } + /** + * Check if this option is required + * + * @return bool + */ + public function isRequired(): bool + { + return $this->required; + } + /** * Check if this option is a boolean option * * @return bool */ - public function isBoolean() + public function isBoolean(): bool { - return (bool)$this->_boolean; + return $this->_boolean; } /** @@ -206,31 +274,41 @@ public function isBoolean() * * @return bool */ - public function acceptsMultiple() + public function acceptsMultiple(): bool { - return (bool)$this->_multiple; + return $this->_multiple; } /** * Check that a value is a valid choice for this option. * - * @param string $value The choice to validate. - * @return bool + * @param string|bool $value The choice to validate. + * @return true * @throws \Cake\Console\Exception\ConsoleException */ - public function validChoice($value) + public function validChoice(string|bool $value): bool { - if (empty($this->_choices)) { + if ($this->_choices === []) { return true; } - if (!in_array($value, $this->_choices)) { + if (is_string($value) && $this->_separator) { + $values = explode($this->_separator, $value); + } else { + $values = [$value]; + } + if ($this->_boolean) { + $values = array_map('boolval', $values); + } + + $unwanted = array_filter($values, fn(bool|string $value) => !in_array($value, $this->_choices, true)); + if ($unwanted) { throw new ConsoleException( sprintf( - '"%s" is not a valid value for --%s. Please use one of "%s"', + '`%s` is not a valid value for `--%s`. Please use one of `%s`', $value, $this->_name, - implode(', ', $this->_choices) - ) + implode('|', $this->_choices), + ), ); } @@ -238,23 +316,50 @@ public function validChoice($value) } /** - * Append the option's xml into the parent. + * Get the list of choices this option has. + * + * @return array + */ + public function choices(): array + { + return $this->_choices; + } + + /** + * Get the prompt string + * + * @return string + */ + public function prompt(): string + { + return (string)$this->prompt; + } + + /** + * Append the option's XML into the parent. * * @param \SimpleXMLElement $parent The parent element. * @return \SimpleXMLElement The parent with this option appended. */ - public function xml(SimpleXMLElement $parent) + public function xml(SimpleXMLElement $parent): SimpleXMLElement { $option = $parent->addChild('option'); $option->addAttribute('name', '--' . $this->_name); $short = ''; - if (strlen($this->_short) > 0) { + if ($this->_short !== '') { $short = '-' . $this->_short; } + $default = $this->_default; + if ($default === true) { + $default = 'true'; + } elseif ($default === false) { + $default = 'false'; + } $option->addAttribute('short', $short); $option->addAttribute('help', $this->_help); - $option->addAttribute('boolean', (int)$this->_boolean); - $option->addChild('default', $this->_default); + $option->addAttribute('boolean', (string)(int)$this->_boolean); + $option->addAttribute('required', (string)(int)$this->required); + $option->addChild('default', (string)$default); $choices = $option->addChild('choices'); foreach ($this->_choices as $valid) { $choices->addChild('choice', $valid); @@ -262,4 +367,14 @@ public function xml(SimpleXMLElement $parent) return $parent; } + + /** + * Get the value of the separator. + * + * @return string|null Value of this->_separator. + */ + public function separator(): ?string + { + return $this->_separator; + } } diff --git a/src/Console/ConsoleInputSubcommand.php b/src/Console/ConsoleInputSubcommand.php deleted file mode 100644 index 1330098c367..00000000000 --- a/src/Console/ConsoleInputSubcommand.php +++ /dev/null @@ -1,140 +0,0 @@ - $value) { - $this->{'_' . $key} = $value; - } - } else { - $this->_name = $name; - $this->_help = $help; - $this->_parser = $parser; - } - if (is_array($this->_parser)) { - $this->_parser['command'] = $this->_name; - $this->_parser = ConsoleOptionParser::buildFromArray($this->_parser); - } - } - - /** - * Get the value of the name attribute. - * - * @return string Value of this->_name. - */ - public function name() - { - return $this->_name; - } - - /** - * Get the raw help string for this command - * - * @return string - */ - public function getRawHelp() - { - return $this->_help; - } - - /** - * Generate the help for this this subcommand. - * - * @param int $width The width to make the name of the subcommand. - * @return string - */ - public function help($width = 0) - { - $name = $this->_name; - if (strlen($name) < $width) { - $name = str_pad($name, $width, ' '); - } - - return $name . $this->_help; - } - - /** - * Get the usage value for this option - * - * @return \Cake\Console\ConsoleOptionParser|bool Either false or a ConsoleOptionParser - */ - public function parser() - { - if ($this->_parser instanceof ConsoleOptionParser) { - return $this->_parser; - } - - return false; - } - - /** - * Append this subcommand to the Parent element - * - * @param \SimpleXMLElement $parent The parent element. - * @return \SimpleXMLElement The parent with this subcommand appended. - */ - public function xml(SimpleXMLElement $parent) - { - $command = $parent->addChild('command'); - $command->addAttribute('name', $this->_name); - $command->addAttribute('help', $this->_help); - - return $parent; - } -} diff --git a/src/Console/ConsoleIo.php b/src/Console/ConsoleIo.php index ac82eef83ca..202ab42fe04 100644 --- a/src/Console/ConsoleIo.php +++ b/src/Console/ConsoleIo.php @@ -1,4 +1,6 @@ _out = $out ?: new ConsoleOutput('php://stdout'); $this->_err = $err ?: new ConsoleOutput('php://stderr'); $this->_in = $in ?: new ConsoleInput('php://stdin'); @@ -108,13 +128,22 @@ public function __construct(ConsoleOutput $out = null, ConsoleOutput $err = null $this->_helpers->setIo($this); } + /** + * @param bool $value Value + * @return void + */ + public function setInteractive(bool $value): void + { + $this->interactive = $value; + } + /** * Get/set the current output level. * - * @param null|int $level The current output level. + * @param int|null $level The current output level. * @return int The current output level. */ - public function level($level = null) + public function level(?int $level = null): int { if ($level !== null) { $this->_level = $level; @@ -126,11 +155,12 @@ public function level($level = null) /** * Output at the verbose level. * - * @param string|array $message A string or an array of strings to output + * @param array|string $message A string or an array of strings to output * @param int $newlines Number of newlines to append - * @return int|bool The number of bytes returned from writing to stdout. + * @return int|null The number of bytes returned from writing to stdout + * or null if current level is less than ConsoleIo::VERBOSE */ - public function verbose($message, $newlines = 1) + public function verbose(array|string $message, int $newlines = 1): ?int { return $this->out($message, $newlines, self::VERBOSE); } @@ -138,11 +168,12 @@ public function verbose($message, $newlines = 1) /** * Output at all levels. * - * @param string|array $message A string or an array of strings to output + * @param array|string $message A string or an array of strings to output * @param int $newlines Number of newlines to append - * @return int|bool The number of bytes returned from writing to stdout. + * @return int|null The number of bytes returned from writing to stdout + * or null if current level is less than ConsoleIo::QUIET */ - public function quiet($message, $newlines = 1) + public function quiet(array|string $message, int $newlines = 1): ?int { return $this->out($message, $newlines, self::QUIET); } @@ -153,25 +184,148 @@ public function quiet($message, $newlines = 1) * * ### Output levels * - * There are 3 built-in output level. Shell::QUIET, Shell::NORMAL, Shell::VERBOSE. + * There are 3 built-in output level. ConsoleIo::QUIET, ConsoleIo::NORMAL, ConsoleIo::VERBOSE. * The verbose and quiet output levels, map to the `verbose` and `quiet` output switches - * present in most shells. Using Shell::QUIET for a message means it will always display. - * While using Shell::VERBOSE means it will only display when verbose output is toggled. + * present in most shells. Using ConsoleIo::QUIET for a message means it will always display. + * While using ConsoleIo::VERBOSE means it will only display when verbose output is toggled. + * + * @param array|string $message A string or an array of strings to output + * @param int $newlines Number of newlines to append + * @param int $level The message's output level, see above. + * @return int|null The number of bytes returned from writing to stdout + * or null if provided $level is greater than current level. + * @link https://book.cakephp.org/5/en/console-commands/input-output.html#creating-output + */ + public function out(array|string $message = '', int $newlines = 1, int $level = self::NORMAL): ?int + { + if ($level > $this->_level) { + return null; + } + + $this->_lastWritten = $this->_out->write($message, $newlines); + + return $this->_lastWritten; + } + + /** + * Convenience method for out() that wraps message between tag + * + * @param array|string $message A string or an array of strings to output + * @param int $newlines Number of newlines to append + * @param int $level The message's output level, see above. + * @return int|null The number of bytes returned from writing to stdout + * or null if provided $level is greater than current level. + * @link https://book.cakephp.org/5/en/console-commands/input-output.html#creating-output + */ + public function info(array|string $message, int $newlines = 1, int $level = self::NORMAL): ?int + { + $messageType = 'info'; + $message = $this->wrapMessageWithType($messageType, $message); + + return $this->out($message, $newlines, $level); + } + + /** + * Convenience method for out() that wraps message between tag * - * @param string|array $message A string or an array of strings to output + * @param array|string $message A string or an array of strings to output * @param int $newlines Number of newlines to append * @param int $level The message's output level, see above. - * @return int|bool The number of bytes returned from writing to stdout. + * @return int|null The number of bytes returned from writing to stdout + * or null if provided $level is greater than current level. + * @link https://book.cakephp.org/5/en/console-commands/input-output.html#creating-output + */ + public function comment(array|string $message, int $newlines = 1, int $level = self::NORMAL): ?int + { + $messageType = 'comment'; + $message = $this->wrapMessageWithType($messageType, $message); + + return $this->out($message, $newlines, $level); + } + + /** + * Convenience method for err() that wraps message between tag + * + * @param array|string $message A string or an array of strings to output + * @param int $newlines Number of newlines to append + * @return int The number of bytes returned from writing to stderr. + * @link https://book.cakephp.org/5/en/console-commands/input-output.html#creating-output */ - public function out($message = '', $newlines = 1, $level = ConsoleIo::NORMAL) + public function warning(array|string $message, int $newlines = 1): int { - if ($level <= $this->_level) { - $this->_lastWritten = (int)$this->_out->write($message, $newlines); + $messageType = 'warning'; + $message = $this->wrapMessageWithType($messageType, $message); + + return $this->err($message, $newlines); + } - return $this->_lastWritten; + /** + * Convenience method for err() that wraps message between tag + * + * @param array|string $message A string or an array of strings to output + * @param int $newlines Number of newlines to append + * @return int The number of bytes returned from writing to stderr. + * @link https://book.cakephp.org/5/en/console-commands/input-output.html#creating-output + */ + public function error(array|string $message, int $newlines = 1): int + { + $messageType = 'error'; + $message = $this->wrapMessageWithType($messageType, $message); + + return $this->err($message, $newlines); + } + + /** + * Convenience method for out() that wraps message between tag + * + * @param array|string $message A string or an array of strings to output + * @param int $newlines Number of newlines to append + * @param int $level The message's output level, see above. + * @return int|null The number of bytes returned from writing to stdout + * or null if provided $level is greater than current level. + * @link https://book.cakephp.org/5/en/console-commands/input-output.html#creating-output + */ + public function success(array|string $message, int $newlines = 1, int $level = self::NORMAL): ?int + { + $messageType = 'success'; + $message = $this->wrapMessageWithType($messageType, $message); + + return $this->out($message, $newlines, $level); + } + + /** + * Halts the current process with a StopException. + * + * @param string $message Error message. + * @param int $code Error code. + * @return never + * @throws \Cake\Console\Exception\StopException + */ + public function abort(string $message, int $code = CommandInterface::CODE_ERROR): never + { + $this->error($message); + + throw new StopException($message, $code); + } + + /** + * Wraps a message with a given message type, e.g. + * + * @param string $messageType The message type, e.g. "warning". + * @param array|string $message The message to wrap. + * @return array|string The message wrapped with the given message type. + */ + protected function wrapMessageWithType(string $messageType, array|string $message): array|string + { + if (is_array($message)) { + foreach ($message as $k => $v) { + $message[$k] = "<{$messageType}>{$v}"; + } + } else { + $message = "<{$messageType}>{$message}"; } - return true; + return $message; } /** @@ -182,20 +336,20 @@ public function out($message = '', $newlines = 1, $level = ConsoleIo::NORMAL) * * **Warning** You cannot overwrite text that contains newlines. * - * @param array|string $message The message to output. + * @param array|string $message The message to output. * @param int $newlines Number of newlines to append. * @param int|null $size The number of bytes to overwrite. Defaults to the * length of the last message output. * @return void */ - public function overwrite($message, $newlines = 1, $size = null) + public function overwrite(array|string $message, int $newlines = 1, ?int $size = null): void { $size = $size ?: $this->_lastWritten; // Output backspaces. $this->out(str_repeat("\x08", $size), 0); - $newBytes = $this->out($message, 0); + $newBytes = (int)$this->out($message, 0); // Fill any remaining bytes with spaces. $fill = $size - $newBytes; @@ -218,11 +372,12 @@ public function overwrite($message, $newlines = 1, $size = null) * Outputs a single or multiple error messages to stderr. If no parameters * are passed outputs just a newline. * - * @param string|array $message A string or an array of strings to output + * @param array|string $message A string or an array of strings to output * @param int $newlines Number of newlines to append - * @return int|bool The number of bytes returned from writing to stderr. + * @return int The number of bytes returned from writing to stderr. + * @link https://book.cakephp.org/5/en/console-commands/input-output.html#creating-output */ - public function err($message = '', $newlines = 1) + public function err(array|string $message = '', int $newlines = 1): int { return $this->_err->write($message, $newlines); } @@ -233,7 +388,7 @@ public function err($message = '', $newlines = 1) * @param int $multiplier Number of times the linefeed sequence should be repeated * @return string */ - public function nl($multiplier = 1) + public function nl(int $multiplier = 1): string { return str_repeat(ConsoleOutput::LF, $multiplier); } @@ -245,11 +400,11 @@ public function nl($multiplier = 1) * @param int $width Width of the line, defaults to 79 * @return void */ - public function hr($newlines = 0, $width = 79) + public function hr(int $newlines = 0, int $width = 79): void { - $this->out(null, $newlines); + $this->out('', $newlines); $this->out(str_repeat('-', $width)); - $this->out(null, $newlines); + $this->out('', $newlines); } /** @@ -257,9 +412,9 @@ public function hr($newlines = 0, $width = 79) * * @param string $prompt Prompt text. * @param string|null $default Default input value. - * @return mixed Either the default value, or the user-provided input. + * @return string Either the default value, or the user-provided input. */ - public function ask($prompt, $default = null) + public function ask(string $prompt, ?string $default = null): string { return $this->_getInput($prompt, null, $default); } @@ -271,53 +426,61 @@ public function ask($prompt, $default = null) * @return void * @see \Cake\Console\ConsoleOutput::setOutputAs() */ - public function setOutputAs($mode) + public function setOutputAs(int $mode): void { $this->_out->setOutputAs($mode); } /** - * Change the output mode of the stdout stream + * Gets defined styles. * - * @deprecated 3.5.0 Use setOutputAs() instead. - * @param int $mode The output mode. - * @return void - * @see \Cake\Console\ConsoleOutput::outputAs() + * @return array + * @see \Cake\Console\ConsoleOutput::styles() */ - public function outputAs($mode) + public function styles(): array { - $this->_out->setOutputAs($mode); + return $this->_out->styles(); } /** - * Add a new output style or get defined styles. + * Get defined style. * - * @param string|null $style The style to get or create. - * @param array|bool|null $definition The array definition of the style to change or create a style - * or false to remove a style. - * @return mixed If you are getting styles, the style or null will be returned. If you are creating/modifying - * styles true will be returned. - * @see \Cake\Console\ConsoleOutput::styles() + * @param string $style The style to get. + * @return array + * @see \Cake\Console\ConsoleOutput::getStyle() */ - public function styles($style = null, $definition = null) + public function getStyle(string $style): array { - $this->_out->styles($style, $definition); + return $this->_out->getStyle($style); + } + + /** + * Adds a new output style. + * + * @param string $style The style to set. + * @param array $definition The array definition of the style to change or create. + * @return void + * @see \Cake\Console\ConsoleOutput::setStyle() + */ + public function setStyle(string $style, array $definition): void + { + $this->_out->setStyle($style, $definition); } /** * Prompts the user for input based on a list of options, and returns it. * * @param string $prompt Prompt text. - * @param string|array $options Array or string of options. + * @param array|string $options Array or string of options. * @param string|null $default Default input value. - * @return mixed Either the default value, or the user-provided input. + * @return string Either the default value, or the user-provided input. */ - public function askChoice($prompt, $options, $default = null) + public function askChoice(string $prompt, array|string $options, ?string $default = null): string { - if ($options && is_string($options)) { - if (strpos($options, ',')) { + if (is_string($options)) { + if (str_contains($options, ',')) { $options = explode(',', $options); - } elseif (strpos($options, '/')) { + } elseif (str_contains($options, '/')) { $options = explode('/', $options); } else { $options = [$options]; @@ -328,10 +491,10 @@ public function askChoice($prompt, $options, $default = null) $options = array_merge( array_map('strtolower', $options), array_map('strtoupper', $options), - $options + $options, ); $in = ''; - while ($in === '' || !in_array($in, $options)) { + while ($in === '' || !in_array($in, $options, true)) { $in = $this->_getInput($prompt, $printOptions, $default); } @@ -346,22 +509,26 @@ public function askChoice($prompt, $options, $default = null) * @param string|null $default Default input value. Pass null to omit. * @return string Either the default value, or the user-provided input. */ - protected function _getInput($prompt, $options, $default) + protected function _getInput(string $prompt, ?string $options, ?string $default): string { + if (!$this->interactive) { + return (string)$default; + } + $optionsText = ''; - if (isset($options)) { - $optionsText = " $options "; + if ($options !== null) { + $optionsText = " {$options} "; } $defaultText = ''; if ($default !== null) { - $defaultText = "[$default] "; + $defaultText = "[{$default}] "; } - $this->_out->write('' . $prompt . "$optionsText\n$defaultText> ", 0); + $this->_out->write('' . $prompt . "{$optionsText}\n{$defaultText}> ", 0); $result = $this->_in->read(); - $result = trim($result); - if ($default !== null && ($result === '' || $result === null)) { + $result = $result === null ? '' : trim($result); + if ($default !== null && $result === '') { return $default; } @@ -375,19 +542,32 @@ protected function _getInput($prompt, $options, $default) * If you don't wish all log output in stdout or stderr * through Cake's Log class, call this function with `$enable=false`. * + * If you would like to take full control of how console application logging + * to stdout works add a logger that uses `'className' => 'Console'`. By + * providing a console logger you replace the framework default behavior. + * * @param int|bool $enable Use a boolean to enable/toggle all logging. Use * one of the verbosity constants (self::VERBOSE, self::QUIET, self::NORMAL) * to control logging levels. VERBOSE enables debug logs, NORMAL does not include debug logs, * QUIET disables notice, info and debug logs. * @return void */ - public function setLoggers($enable) + public function setLoggers(int|bool $enable): void { Log::drop('stdout'); Log::drop('stderr'); if ($enable === false) { return; } + // If the application has configured a console logger + // we don't add a redundant one. + foreach (Log::configured() as $loggerName) { + $log = Log::engine($loggerName); + if ($log instanceof ConsoleLog) { + return; + } + } + $outLevels = ['notice', 'info']; if ($enable === static::VERBOSE || $enable === true) { $outLevels[] = 'debug'; @@ -395,7 +575,7 @@ public function setLoggers($enable) if ($enable !== static::QUIET) { $stdout = new ConsoleLog([ 'types' => $outLevels, - 'stream' => $this->_out + 'stream' => $this->_out, ]); Log::setConfig('stdout', ['engine' => $stdout]); } @@ -413,13 +593,85 @@ public function setLoggers($enable) * object has not already been loaded, it will be loaded and constructed. * * @param string $name The name of the helper to render - * @param array $settings Configuration data for the helper. + * @param array $config Configuration data for the helper. * @return \Cake\Console\Helper The created helper instance. */ - public function helper($name, array $settings = []) + public function helper(string $name, array $config = []): Helper { $name = ucfirst($name); - return $this->_helpers->load($name, $settings); + /** @var \Cake\Console\Helper */ + return $this->_helpers->load($name, $config); + } + + /** + * Create a file at the given path. + * + * This method will prompt the user if a file will be overwritten. + * Setting `forceOverwrite` to true will suppress this behavior + * and always overwrite the file. + * + * If the user replies `a` subsequent `forceOverwrite` parameters will + * be coerced to true and all files will be overwritten. + * + * @param string $path The path to create the file at. + * @param string $contents The contents to put into the file. + * @param bool $forceOverwrite Whether the file should be overwritten. + * If true, no question will be asked about whether to overwrite existing files. + * @return bool Success. + * @throws \Cake\Console\Exception\StopException When `q` is given as an answer + * to whether a file should be overwritten. + */ + public function createFile(string $path, string $contents, bool $forceOverwrite = false): bool + { + $this->out(); + $forceOverwrite = $forceOverwrite || $this->forceOverwrite; + + if (file_exists($path) && $forceOverwrite === false) { + $this->warning("File `{$path}` exists"); + $key = $this->askChoice('Do you want to overwrite?', ['y', 'n', 'a', 'q'], 'n'); + $key = strtolower($key); + + if ($key === 'q') { + $this->error('Quitting.', 2); + throw new StopException('Not creating file. Quitting.'); + } + if ($key === 'a') { + $this->forceOverwrite = true; + $key = 'y'; + } + if ($key !== 'y') { + $this->out("Skip `{$path}`", 2); + + return false; + } + } else { + $this->out("Creating file {$path}"); + } + + try { + // Create the directory using the current user permissions. + $directory = dirname($path); + if (!file_exists($directory)) { + mkdir($directory, 0777 ^ umask(), true); + } + + $file = new SplFileObject($path, 'w'); + } catch (RuntimeException) { + $this->error("Could not write to `{$path}`. Permission denied.", 2); + + return false; + } + + $file->rewind(); + $file->fwrite($contents); + if (file_exists($path)) { + $this->out("Wrote `{$path}`"); + + return true; + } + $this->error("Could not write to `{$path}`.", 2); + + return false; } } diff --git a/src/Console/ConsoleOptionParser.php b/src/Console/ConsoleOptionParser.php index 702e68524fd..d8049d662fc 100644 --- a/src/Console/ConsoleOptionParser.php +++ b/src/Console/ConsoleOptionParser.php @@ -1,4 +1,6 @@ */ - protected $_options = []; + protected array $_options = []; /** * Map of short -> long options, generated when using addOption() * - * @var array + * @var array */ - protected $_shortOptions = []; + protected array $_shortOptions = []; /** * Positional argument definitions. * * @see \Cake\Console\ConsoleOptionParser::addArgument() - * @var \Cake\Console\ConsoleInputArgument[] - */ - protected $_args = []; - - /** - * Subcommands for this Shell. - * - * @see \Cake\Console\ConsoleOptionParser::addSubcommand() - * @var \Cake\Console\ConsoleInputSubcommand[] + * @var array<\Cake\Console\ConsoleInputArgument> */ - protected $_subcommands = []; + protected array $_args = []; /** * Command name. * * @var string */ - protected $_command = ''; + protected string $_command = ''; /** * Array of args (argv). * * @var array */ - protected $_tokens = []; + protected array $_tokens = []; /** * Root alias used in help output @@ -140,34 +134,34 @@ class ConsoleOptionParser * @see \Cake\Console\HelpFormatter::setAlias() * @var string */ - protected $rootName = 'cake'; + protected string $rootName = 'cake'; /** * Construct an OptionParser so you can define its behavior * - * @param string|null $command The command name this parser is for. The command name is used for generating help. + * @param string $command The command name this parser is for. The command name is used for generating help. * @param bool $defaultOptions Whether you want the verbose and quiet options set. Setting * this to false will prevent the addition of `--verbose` & `--quiet` options. */ - public function __construct($command = null, $defaultOptions = true) + public function __construct(string $command = '', bool $defaultOptions = true) { $this->setCommand($command); $this->addOption('help', [ 'short' => 'h', 'help' => 'Display this help.', - 'boolean' => true + 'boolean' => true, ]); if ($defaultOptions) { $this->addOption('verbose', [ 'short' => 'v', 'help' => 'Enable verbose output.', - 'boolean' => true + 'boolean' => true, ])->addOption('quiet', [ 'short' => 'q', - 'help' => 'Enable quiet output.', - 'boolean' => true + 'help' => 'Enable quiet output and non-interactive mode.', + 'boolean' => true, ]); } } @@ -175,11 +169,11 @@ public function __construct($command = null, $defaultOptions = true) /** * Static factory method for creating new OptionParsers so you can chain methods off of them. * - * @param string|null $command The command name this parser is for. The command name is used for generating help. + * @param string $command The command name this parser is for. The command name is used for generating help. * @param bool $defaultOptions Whether you want the verbose and quiet options set. * @return static */ - public static function create($command, $defaultOptions = true) + public static function create(string $command, bool $defaultOptions = true): static { return new static($command, $defaultOptions); } @@ -196,18 +190,15 @@ public static function create($command, $defaultOptions = true) * ], * 'options' => [ * // list of options compatible with addOptions - * ], - * 'subcommands' => [ - * // list of subcommands to add. * ] * ]; * ``` * - * @param array $spec The spec to build the OptionParser with. + * @param array $spec The spec to build the OptionParser with. * @param bool $defaultOptions Whether you want the verbose and quiet options set. * @return static */ - public static function buildFromArray($spec, $defaultOptions = true) + public static function buildFromArray(array $spec, bool $defaultOptions = true): static { $parser = new static($spec['command'], $defaultOptions); if (!empty($spec['arguments'])) { @@ -216,9 +207,6 @@ public static function buildFromArray($spec, $defaultOptions = true) if (!empty($spec['options'])) { $parser->addOptions($spec['options']); } - if (!empty($spec['subcommands'])) { - $parser->addSubcommands($spec['subcommands']); - } if (!empty($spec['description'])) { $parser->setDescription($spec['description']); } @@ -232,29 +220,26 @@ public static function buildFromArray($spec, $defaultOptions = true) /** * Returns an array representation of this parser. * - * @return array + * @return array */ - public function toArray() + public function toArray(): array { - $result = [ + return [ 'command' => $this->_command, 'arguments' => $this->_args, 'options' => $this->_options, - 'subcommands' => $this->_subcommands, 'description' => $this->_description, - 'epilog' => $this->_epilog + 'epilog' => $this->_epilog, ]; - - return $result; } /** * Get or set the command name for shell/task. * - * @param array|\Cake\Console\ConsoleOptionParser $spec ConsoleOptionParser or spec to merge with. + * @param \Cake\Console\ConsoleOptionParser|array $spec ConsoleOptionParser or spec to merge with. * @return $this */ - public function merge($spec) + public function merge(ConsoleOptionParser|array $spec) { if ($spec instanceof ConsoleOptionParser) { $spec = $spec->toArray(); @@ -263,11 +248,15 @@ public function merge($spec) $this->addArguments($spec['arguments']); } if (!empty($spec['options'])) { + foreach ($spec['options'] as $name => $params) { + if ($params instanceof ConsoleInputOption) { + $name = $params->name(); + } + $this->removeOption($name); + } + $this->addOptions($spec['options']); } - if (!empty($spec['subcommands'])) { - $this->addSubcommands($spec['subcommands']); - } if (!empty($spec['description'])) { $this->setDescription($spec['description']); } @@ -284,7 +273,7 @@ public function merge($spec) * @param string $text The text to set. * @return $this */ - public function setCommand($text) + public function setCommand(string $text) { $this->_command = Inflector::underscore($text); @@ -296,35 +285,19 @@ public function setCommand($text) * * @return string The value of the command. */ - public function getCommand() + public function getCommand(): string { return $this->_command; } - /** - * Gets or sets the command name for shell/task. - * - * @deprecated 3.4.0 Use setCommand()/getCommand() instead. - * @param string|null $text The text to set, or null if you want to read - * @return string|$this If reading, the value of the command. If setting $this will be returned. - */ - public function command($text = null) - { - if ($text !== null) { - return $this->setCommand($text); - } - - return $this->getCommand(); - } - /** * Sets the description text for shell/task. * - * @param string|array $text The text to set. If an array the + * @param array|string $text The text to set. If an array the * text will be imploded with "\n". * @return $this */ - public function setDescription($text) + public function setDescription(array|string $text) { if (is_array($text)) { $text = implode("\n", $text); @@ -339,37 +312,20 @@ public function setDescription($text) * * @return string The value of the description */ - public function getDescription() + public function getDescription(): string { return $this->_description; } - /** - * Get or set the description text for shell/task. - * - * @deprecated 3.4.0 Use setDescription()/getDescription() instead. - * @param string|array|null $text The text to set, or null if you want to read. If an array the - * text will be imploded with "\n". - * @return string|$this If reading, the value of the description. If setting $this will be returned. - */ - public function description($text = null) - { - if ($text !== null) { - return $this->setDescription($text); - } - - return $this->getDescription(); - } - /** * Sets an epilog to the parser. The epilog is added to the end of * the options and arguments listing when help is generated. * - * @param string|array $text The text to set. If an array the text will + * @param array|string $text The text to set. If an array the text will * be imploded with "\n". * @return $this */ - public function setEpilog($text) + public function setEpilog(array|string $text) { if (is_array($text)) { $text = implode("\n", $text); @@ -384,29 +340,11 @@ public function setEpilog($text) * * @return string The value of the epilog. */ - public function getEpilog() + public function getEpilog(): string { return $this->_epilog; } - /** - * Gets or sets an epilog to the parser. The epilog is added to the end of - * the options and arguments listing when help is generated. - * - * @deprecated 3.4.0 Use setEpilog()/getEpilog() instead. - * @param string|array|null $text Text when setting or null when reading. If an array the text will - * be imploded with "\n". - * @return string|$this If reading, the value of the epilog. If setting $this will be returned. - */ - public function epilog($text = null) - { - if ($text !== null) { - return $this->setEpilog($text); - } - - return $this->getEpilog(); - } - /** * Add an option to the option parser. Options allow you to define optional or required * parameters for your console application. Options are defined by the parameters they use. @@ -426,31 +364,59 @@ public function epilog($text = null) * - `choices` A list of valid choices for this option. If left empty all values are valid.. * An exception will be raised when parse() encounters an invalid value. * - * @param \Cake\Console\ConsoleInputOption|string $name The long name you want to the value to be parsed out as when options are parsed. - * Will also accept an instance of ConsoleInputOption - * @param array $options An array of parameters that define the behavior of the option + * @param \Cake\Console\ConsoleInputOption|string $name The long name you want to the value to be parsed out + * as when options are parsed. Will also accept an instance of ConsoleInputOption. + * @param array $options An array of parameters that define the behavior of the option * @return $this */ - public function addOption($name, array $options = []) + public function addOption(ConsoleInputOption|string $name, array $options = []) { if ($name instanceof ConsoleInputOption) { $option = $name; $name = $option->name(); } else { $defaults = [ - 'name' => $name, - 'short' => null, + 'short' => '', 'help' => '', 'default' => null, 'boolean' => false, - 'choices' => [] + 'multiple' => false, + 'separator' => null, + 'choices' => [], + 'required' => false, + 'prompt' => null, ]; + $options += $defaults; - $option = new ConsoleInputOption($options); + + if ($options['default'] && (is_int($options['default']) || is_float($options['default']))) { + $options['default'] = (string)$options['default']; + } + + $option = new ConsoleInputOption( + $name, + $options['short'], + $options['help'], + $options['boolean'], + $options['default'], + $options['choices'], + $options['multiple'], + $options['required'], + $options['prompt'], + $options['separator'], + ); } $this->_options[$name] = $option; asort($this->_options); - if ($option->short() !== null) { + if ($option->short()) { + if (isset($this->_shortOptions[$option->short()])) { + throw new LogicException(sprintf( + 'Short option `%s` is already defined for option `%s`. You cannot redefine short options.', + $option->short(), + $this->_shortOptions[$option->short()], + )); + } + $this->_shortOptions[$option->short()] = $name; asort($this->_shortOptions); } @@ -464,10 +430,15 @@ public function addOption($name, array $options = []) * @param string $name The option name to remove. * @return $this */ - public function removeOption($name) + public function removeOption(string $name) { unset($this->_options[$name]); + $key = array_search($name, $this->_shortOptions, true); + if ($key !== false) { + unset($this->_shortOptions[$key]); + } + return $this; } @@ -483,13 +454,14 @@ public function removeOption($name) * option will be overwritten. * - `choices` A list of valid choices for this argument. If left empty all values are valid.. * An exception will be raised when parse() encounters an invalid value. + * - `separator` A separator to allow writing argument in a list form. * * @param \Cake\Console\ConsoleInputArgument|string $name The name of the argument. * Will also accept an instance of ConsoleInputArgument. - * @param array $params Parameters for the argument, see above. + * @param array $params Parameters for the argument, see above. * @return $this */ - public function addArgument($name, array $params = []) + public function addArgument(ConsoleInputArgument|string $name, array $params = []) { if ($name instanceof ConsoleInputArgument) { $arg = $name; @@ -500,14 +472,15 @@ public function addArgument($name, array $params = []) 'help' => '', 'index' => count($this->_args), 'required' => false, - 'choices' => [] + 'choices' => [], + 'separator' => null, ]; $options = $params + $defaults; $index = $options['index']; unset($options['index']); $arg = new ConsoleInputArgument($options); } - foreach ($this->_args as $k => $a) { + foreach ($this->_args as $a) { if ($a->isEqualTo($arg)) { return $this; } @@ -525,7 +498,7 @@ public function addArgument($name, array $params = []) * Add multiple arguments at once. Take an array of argument definitions. * The keys are used as the argument names, and the values as params for the argument. * - * @param array $args Array of arguments to add. + * @param array|\Cake\Console\ConsoleInputArgument> $args Array of arguments to add. * @see \Cake\Console\ConsoleOptionParser::addArgument() * @return $this */ @@ -546,7 +519,7 @@ public function addArguments(array $args) * Add multiple options at once. Takes an array of option definitions. * The keys are used as option names, and the values as params for the option. * - * @param array $options Array of options to add. + * @param array $options Array of options to add. * @see \Cake\Console\ConsoleOptionParser::addOption() * @return $this */ @@ -564,141 +537,90 @@ public function addOptions(array $options) } /** - * Append a subcommand to the subcommand list. - * Subcommands are usually methods on your Shell, but can also be used to document Tasks. - * - * ### Options - * - * - `help` - Help text for the subcommand. - * - `parser` - A ConsoleOptionParser for the subcommand. This allows you to create method - * specific option parsers. When help is generated for a subcommand, if a parser is present - * it will be used. - * - * @param \Cake\Console\ConsoleInputSubcommand|string $name Name of the subcommand. Will also accept an instance of ConsoleInputSubcommand - * @param array $options Array of params, see above. - * @return $this - */ - public function addSubcommand($name, array $options = []) - { - if ($name instanceof ConsoleInputSubcommand) { - $command = $name; - $name = $command->name(); - } else { - $name = Inflector::underscore($name); - $defaults = [ - 'name' => $name, - 'help' => '', - 'parser' => null - ]; - $options += $defaults; - - $command = new ConsoleInputSubcommand($options); - } - $this->_subcommands[$name] = $command; - asort($this->_subcommands); - - return $this; - } - - /** - * Remove a subcommand from the option parser. + * Gets the arguments defined in the parser. * - * @param string $name The subcommand name to remove. - * @return $this + * @return array<\Cake\Console\ConsoleInputArgument> */ - public function removeSubcommand($name) + public function arguments(): array { - unset($this->_subcommands[$name]); - - return $this; + return $this->_args; } /** - * Add multiple subcommands at once. + * Get the list of argument names. * - * @param array $commands Array of subcommands. - * @return $this + * @return array */ - public function addSubcommands(array $commands) + public function argumentNames(): array { - foreach ($commands as $name => $params) { - if ($params instanceof ConsoleInputSubcommand) { - $name = $params; - $params = []; - } - $this->addSubcommand($name, $params); + $out = []; + foreach ($this->_args as $arg) { + $out[] = $arg->name(); } - return $this; - } - - /** - * Gets the arguments defined in the parser. - * - * @return \Cake\Console\ConsoleInputArgument[] - */ - public function arguments() - { - return $this->_args; + return $out; } /** * Get the defined options in the parser. * - * @return \Cake\Console\ConsoleInputOption[] + * @return array */ - public function options() + public function options(): array { return $this->_options; } /** - * Get the array of defined subcommands - * - * @return \Cake\Console\ConsoleInputSubcommand[] - */ - public function subcommands() - { - return $this->_subcommands; - } - - /** - * Parse the argv array into a set of params and args. If $command is not null - * and $command is equal to a subcommand that has a parser, that parser will be used - * to parse the $argv + * Parse the argv array into a set of params and args. * * @param array $argv Array of args (argv) to parse. + * @param \Cake\Console\ConsoleIo|null $io A ConsoleIo instance or null. If null prompt options will error. * @return array [$params, $args] * @throws \Cake\Console\Exception\ConsoleException When an invalid parameter is encountered. */ - public function parse($argv) + public function parse(array $argv, ?ConsoleIo $io = null): array { - $command = isset($argv[0]) ? Inflector::underscore($argv[0]) : null; - if (isset($this->_subcommands[$command])) { - array_shift($argv); - } - if (isset($this->_subcommands[$command]) && $this->_subcommands[$command]->parser()) { - return $this->_subcommands[$command]->parser()->parse($argv); - } - $params = $args = []; + $params = []; + $args = []; $this->_tokens = $argv; + + $afterDoubleDash = false; while (($token = array_shift($this->_tokens)) !== null) { - if (isset($this->_subcommands[$token])) { + $token = (string)$token; + if ($token === '--') { + $afterDoubleDash = true; + continue; + } + if ($afterDoubleDash) { + // only positional arguments after -- + $args = $this->_parseArg($token, $args); continue; } - if (substr($token, 0, 2) === '--') { + + if (str_starts_with($token, '--')) { $params = $this->_parseLongOption($token, $params); - } elseif (substr($token, 0, 1) === '-') { + } elseif (str_starts_with($token, '-')) { $params = $this->_parseShortOption($token, $params); } else { $args = $this->_parseArg($token, $args); } } + + if (isset($params['help'])) { + return [$params, $args]; + } + foreach ($this->_args as $i => $arg) { - if ($arg->isRequired() && !isset($args[$i]) && empty($params['help'])) { - throw new ConsoleException( - sprintf('Missing required arguments. %s is required.', $arg->name()) - ); + if (!isset($args[$i])) { + if ($arg->isRequired()) { + throw new ConsoleException( + sprintf('Missing required argument. The `%s` argument is required.', $arg->name()), + ); + } + if ($arg->defaultValue() !== null) { + $args[$i] = $arg->defaultValue(); + } } } foreach ($this->_options as $option) { @@ -706,12 +628,34 @@ public function parse($argv) $isBoolean = $option->isBoolean(); $default = $option->defaultValue(); - if ($default !== null && !isset($params[$name]) && !$isBoolean) { + $useDefault = !isset($params[$name]); + if ($default !== null && $useDefault && !$isBoolean) { $params[$name] = $default; } - if ($isBoolean && !isset($params[$name])) { + if ($isBoolean && $useDefault) { $params[$name] = false; } + $prompt = $option->prompt(); + if (!isset($params[$name]) && $prompt) { + if (!$io) { + throw new ConsoleException( + 'Cannot use interactive option prompts without a ConsoleIo instance. ' . + 'Please provide a `$io` parameter to `parse()`.', + ); + } + $choices = $option->choices(); + if ($choices) { + $value = $io->askChoice($prompt, $choices); + } else { + $value = $io->ask($prompt); + } + $params[$name] = $value; + } + if ($option->isRequired() && !isset($params[$name])) { + throw new ConsoleException( + sprintf('Missing required option. The `%s` option is required and has no default value.', $name), + ); + } } return [$params, $args]; @@ -720,57 +664,26 @@ public function parse($argv) /** * Gets formatted help for this parser object. * - * Generates help text based on the description, options, arguments, subcommands and epilog + * Generates help text based on the description, options, arguments and epilog * in the parser. * - * @param string|null $subcommand If present and a valid subcommand that has a linked parser. - * That subcommands help will be shown instead. - * @param string $format Define the output format, can be text or xml + * @param string $format Define the output format, can be text or XML * @param int $width The width to format user content to. Defaults to 72 * @return string Generated help. */ - public function help($subcommand = null, $format = 'text', $width = 72) + public function help(string $format = 'text', int $width = 72): string { - if ($subcommand === null) { - $formatter = new HelpFormatter($this); - $formatter->setAlias($this->rootName); + $formatter = new HelpFormatter($this); + $formatter->setAlias($this->rootName); - if ($format === 'text') { - return $formatter->text($width); - } - if ($format === 'xml') { - return (string)$formatter->xml(); - } + if ($format === 'text') { + return $formatter->text($width); } - - if (isset($this->_subcommands[$subcommand])) { - $command = $this->_subcommands[$subcommand]; - $subparser = $command->parser(); - if (!($subparser instanceof self)) { - $subparser = clone $this; - } - if (strlen($subparser->getDescription()) === 0) { - $subparser->setDescription($command->getRawHelp()); - } - $subparser->setCommand($this->getCommand() . ' ' . $subcommand); - $subparser->setRootName($this->rootName); - - return $subparser->help(null, $format, $width); + if ($format === 'xml') { + return (string)$formatter->xml(); } - return $this->getCommandError($subcommand); - } - - /** - * Set the alias used in the HelpFormatter - * - * @param string $alias The alias - * @return void - * @deprecated 3.5.0 Use setRootName() instead. - */ - public function setHelpAlias($alias) - { - $this->rootName = $alias; + throw new ConsoleException('Invalid format. Output format can be text or xml.'); } /** @@ -779,147 +692,26 @@ public function setHelpAlias($alias) * @param string $name The root command name * @return $this */ - public function setRootName($name) + public function setRootName(string $name) { - $this->rootName = (string)$name; + $this->rootName = $name; return $this; } - /** - * Get the message output in the console stating that the command can not be found and tries to guess what the user - * wanted to say. Output a list of available subcommands as well. - * - * @param string $command Unknown command name trying to be dispatched. - * @return string The message to be displayed in the console. - */ - protected function getCommandError($command) - { - $rootCommand = $this->getCommand(); - $subcommands = array_keys((array)$this->subcommands()); - $bestGuess = $this->findClosestItem($command, $subcommands); - - $out = [ - sprintf( - 'Unable to find the `%s %s` subcommand. See `bin/%s %s --help`.', - $rootCommand, - $command, - $this->rootName, - $rootCommand - ), - '' - ]; - - if ($bestGuess !== null) { - $out[] = sprintf('Did you mean : `%s %s` ?', $rootCommand, $bestGuess); - $out[] = ''; - } - $out[] = sprintf('Available subcommands for the `%s` command are : ', $rootCommand); - $out[] = ''; - foreach ($subcommands as $subcommand) { - $out[] = ' - ' . $subcommand; - } - - return implode("\n", $out); - } - - /** - * Get the message output in the console stating that the option can not be found and tries to guess what the user - * wanted to say. Output a list of available options as well. - * - * @param string $option Unknown option name trying to be used. - * @return string The message to be displayed in the console. - */ - protected function getOptionError($option) - { - $availableOptions = array_keys($this->_options); - $bestGuess = $this->findClosestItem($option, $availableOptions); - $out = [ - sprintf('Unknown option `%s`.', $option), - '' - ]; - - if ($bestGuess !== null) { - $out[] = sprintf('Did you mean `%s` ?', $bestGuess); - $out[] = ''; - } - - $out[] = 'Available options are :'; - $out[] = ''; - foreach ($availableOptions as $availableOption) { - $out[] = ' - ' . $availableOption; - } - - return implode("\n", $out); - } - - /** - * Get the message output in the console stating that the short option can not be found. Output a list of available - * short options and what option they refer to as well. - * - * @param string $option Unknown short option name trying to be used. - * @return string The message to be displayed in the console. - */ - protected function getShortOptionError($option) - { - $out = [sprintf('Unknown short option `%s`', $option)]; - $out[] = ''; - $out[] = 'Available short options are :'; - $out[] = ''; - - foreach ($this->_shortOptions as $short => $long) { - $out[] = sprintf(' - `%s` (short for `--%s`)', $short, $long); - } - - return implode("\n", $out); - } - - /** - * Tries to guess the item name the user originally wanted using the some regex pattern and the levenshtein - * algorithm. - * - * @param string $needle Unknown item (either a subcommand name or an option for instance) trying to be used. - * @param array $haystack List of items available for the type $needle belongs to. - * @return string|null The closest name to the item submitted by the user. - */ - protected function findClosestItem($needle, $haystack) - { - $bestGuess = null; - foreach ($haystack as $item) { - if (preg_match('/^' . $needle . '/', $item)) { - return $item; - } - } - - foreach ($haystack as $item) { - if (preg_match('/' . $needle . '/', $item)) { - return $item; - } - - $score = levenshtein($needle, $item); - - if (!isset($bestScore) || $score < $bestScore) { - $bestScore = $score; - $bestGuess = $item; - } - } - - return $bestGuess; - } - /** * Parse the value for a long option out of $this->_tokens. Will handle * options with an `=` in them. * * @param string $option The option to parse. - * @param array $params The params to append the parsed value into + * @param array $params The params to append the parsed value into * @return array Params with $option added in. */ - protected function _parseLongOption($option, $params) + protected function _parseLongOption(string $option, array $params): array { $name = substr($option, 2); - if (strpos($name, '=') !== false) { - list($name, $value) = explode('=', $name, 2); + if (str_contains($name, '=')) { + [$name, $value] = explode('=', $name, 2); array_unshift($this->_tokens, $value); } @@ -932,11 +724,11 @@ protected function _parseLongOption($option, $params) * they will be shifted onto the token stack and parsed individually. * * @param string $option The option to parse. - * @param array $params The params to append the parsed value into - * @return array Params with $option added in. + * @param array $params The params to append the parsed value into + * @return array Params with $option added in. * @throws \Cake\Console\Exception\ConsoleException When unknown short options are encountered. */ - protected function _parseShortOption($option, $params) + protected function _parseShortOption(string $option, array $params): array { $key = substr($option, 1); if (strlen($key) > 1) { @@ -947,7 +739,15 @@ protected function _parseShortOption($option, $params) } } if (!isset($this->_shortOptions[$key])) { - throw new ConsoleException($this->getShortOptionError($key)); + $options = []; + foreach ($this->_shortOptions as $short => $long) { + $options[] = "{$short} (short for `--{$long}`)"; + } + throw new MissingOptionException( + sprintf('Unknown short option `%s`.', $key), + $key, + $options, + ); } $name = $this->_shortOptions[$key]; @@ -958,38 +758,44 @@ protected function _parseShortOption($option, $params) * Parse an option by its name index. * * @param string $name The name to parse. - * @param array $params The params to append the parsed value into - * @return array Params with $option added in. + * @param array $params The params to append the parsed value into + * @return array Params with $option added in. * @throws \Cake\Console\Exception\ConsoleException */ - protected function _parseOption($name, $params) + protected function _parseOption(string $name, array $params): array { if (!isset($this->_options[$name])) { - throw new ConsoleException($this->getOptionError($name)); + throw new MissingOptionException( + sprintf('Unknown option `%s`.', $name), + $name, + array_keys($this->_options), + ); } $option = $this->_options[$name]; $isBoolean = $option->isBoolean(); $nextValue = $this->_nextToken(); - $emptyNextValue = (empty($nextValue) && $nextValue !== '0'); + $emptyNextValue = (!$nextValue && $nextValue !== '0'); if (!$isBoolean && !$emptyNextValue && !$this->_optionExists($nextValue)) { array_shift($this->_tokens); $value = $nextValue; } elseif ($isBoolean) { $value = true; } else { - $value = $option->defaultValue(); + $value = (string)$option->defaultValue(); } - if ($option->validChoice($value)) { - if ($option->acceptsMultiple()) { - $params[$name][] = $value; - } else { - $params[$name] = $value; - } - return $params; + $option->validChoice($value); + if ($option->acceptsMultiple()) { + $values = [$value]; + if (is_string($value) && $option->separator()) { + $values = explode($option->separator(), $value); + } + $params[$name] = array_merge($params[$name] ?? [], $values); + } else { + $params[$name] = $value; } - return []; + return $params; } /** @@ -998,13 +804,13 @@ protected function _parseOption($name, $params) * @param string $name The name of the option. * @return bool */ - protected function _optionExists($name) + protected function _optionExists(string $name): bool { - if (substr($name, 0, 2) === '--') { + if (str_starts_with($name, '--')) { return isset($this->_options[substr($name, 2)]); } - if ($name{0} === '-' && $name{1} !== '-') { - return isset($this->_shortOptions[$name{1}]); + if (str_starts_with($name, '-')) { + return isset($this->_shortOptions[$name[1]]); } return false; @@ -1016,26 +822,36 @@ protected function _optionExists($name) * * @param string $argument The argument to append * @param array $args The array of parsed args to append to. - * @return array Args + * @return array Args * @throws \Cake\Console\Exception\ConsoleException */ - protected function _parseArg($argument, $args) + protected function _parseArg(string $argument, array $args): array { - if (empty($this->_args)) { + if (!$this->_args) { $args[] = $argument; return $args; } $next = count($args); if (!isset($this->_args[$next])) { - throw new ConsoleException('Too many arguments.'); + $expected = count($this->_args); + throw new ConsoleException(sprintf( + 'Received too many arguments. Got `%s` (or more) but only `%s` arguments are defined.', + $next + 1, + $expected, + )); } - if ($this->_args[$next]->validChoice($argument)) { - $args[] = $argument; + $arg = $this->_args[$next]; - return $args; + $arg->validChoice($argument); + if ($arg->separator()) { + $args[] = explode($arg->separator(), $argument); + } else { + $args[] = $argument; } + + return $args; } /** @@ -1043,8 +859,8 @@ protected function _parseArg($argument, $args) * * @return string next token or '' */ - protected function _nextToken() + protected function _nextToken(): string { - return isset($this->_tokens[0]) ? $this->_tokens[0] : ''; + return $this->_tokens[0] ?? ''; } } diff --git a/src/Console/ConsoleOutput.php b/src/Console/ConsoleOutput.php index ca70a0fca2b..2ed86196356 100644 --- a/src/Console/ConsoleOutput.php +++ b/src/Console/ConsoleOutput.php @@ -1,4 +1,6 @@ */ - protected static $_foregroundColors = [ + protected static array $_foregroundColors = [ 'black' => 30, 'red' => 31, 'green' => 32, @@ -101,15 +110,15 @@ class ConsoleOutput 'blue' => 34, 'magenta' => 35, 'cyan' => 36, - 'white' => 37 + 'white' => 37, ]; /** * background colors used in colored output. * - * @var array + * @var array */ - protected static $_backgroundColors = [ + protected static array $_backgroundColors = [ 'black' => 40, 'red' => 41, 'green' => 42, @@ -117,15 +126,15 @@ class ConsoleOutput 'blue' => 44, 'magenta' => 45, 'cyan' => 46, - 'white' => 47 + 'white' => 47, ]; /** * Formatting options for colored output. * - * @var array + * @var array */ - protected static $_options = [ + protected static array $_options = [ 'bold' => 1, 'underline' => 4, 'blink' => 5, @@ -136,36 +145,63 @@ class ConsoleOutput * Styles that are available as tags in console output. * You can modify these styles with ConsoleOutput::styles() * - * @var array + * @var array */ - protected static $_styles = [ + protected static array $_styles = [ 'emergency' => ['text' => 'red'], 'alert' => ['text' => 'red'], 'critical' => ['text' => 'red'], 'error' => ['text' => 'red'], + 'error.bg' => ['background' => 'red', 'text' => 'black'], 'warning' => ['text' => 'yellow'], + 'warning.bg' => ['background' => 'yellow', 'text' => 'black'], 'info' => ['text' => 'cyan'], + 'info.bg' => ['background' => 'white', 'text' => 'cyan'], 'debug' => ['text' => 'yellow'], 'success' => ['text' => 'green'], + 'success.bg' => ['background' => 'green', 'text' => 'black'], + 'notice' => ['text' => 'cyan'], + 'notice.bg' => ['background' => 'cyan', 'text' => 'black'], 'comment' => ['text' => 'blue'], 'question' => ['text' => 'magenta'], - 'notice' => ['text' => 'cyan'] ]; /** * Construct the output object. * * Checks for a pretty console environment. Ansicon and ConEmu allows - * pretty consoles on windows, and is supported. + * pretty consoles on Windows, and is supported. * - * @param string $stream The identifier of the stream to write output to. + * @param resource|string $stream The identifier of the stream to write output to. + * @throws \Cake\Console\Exception\ConsoleException If the given stream is not a valid resource. */ public function __construct($stream = 'php://stdout') { - $this->_output = fopen($stream, 'wb'); + if (is_string($stream)) { + $stream = fopen($stream, 'wb'); + } + + if (!is_resource($stream)) { + throw new ConsoleException('Invalid stream in constructor. It is not a valid resource.'); + } - if ((DIRECTORY_SEPARATOR === '\\' && !(bool)env('ANSICON') && env('ConEmuANSI') !== 'ON') || - (function_exists('posix_isatty') && !posix_isatty($this->_output)) + $this->_output = $stream; + + if ( + ( + DIRECTORY_SEPARATOR === '\\' && + !str_contains(strtolower(php_uname('v')), 'windows 10') && + !str_contains(strtolower((string)env('SHELL')), 'bash.exe') && + !env('ANSICON') && + env('ConEmuANSI') !== 'ON' + ) || + ( + function_exists('posix_isatty') && + !posix_isatty($this->_output) + ) || + ( + env('NO_COLOR') !== null + ) ) { $this->_outputAs = self::PLAIN; } @@ -175,11 +211,11 @@ public function __construct($stream = 'php://stdout') * Outputs a single or multiple messages to stdout or stderr. If no parameters * are passed, outputs just a newline. * - * @param string|array $message A string or an array of strings to output + * @param array|string $message A string or an array of strings to output * @param int $newlines Number of newlines to append - * @return int|bool The number of bytes returned from writing to output. + * @return int The number of bytes returned from writing to output. */ - public function write($message, $newlines = 1) + public function write(array|string $message, int $newlines = 1): int { if (is_array($message)) { $message = implode(static::LF, $message); @@ -194,34 +230,40 @@ public function write($message, $newlines = 1) * @param string $text Text with styling tags. * @return string String with color codes added. */ - public function styleText($text) + public function styleText(string $text): string { - if ($this->_outputAs == static::RAW) { + if ($this->_outputAs === static::RAW) { return $text; } - if ($this->_outputAs == static::PLAIN) { - $tags = implode('|', array_keys(static::$_styles)); + if ($this->_outputAs !== static::PLAIN) { + $replaceTags = $this->_replaceTags(...); - return preg_replace('##', '', $text); + $output = preg_replace_callback( + '/<(?P[a-z0-9-_.]+)>(?P.*?)<\/(\1)>/ims', + $replaceTags, + $text, + ); + if ($output !== null) { + return $output; + } } - return preg_replace_callback( - '/<(?P[a-z0-9-_]+)>(?P.*?)<\/(\1)>/ims', - [$this, '_replaceTags'], - $text - ); + $tags = implode('|', array_keys(static::$_styles)); + $output = preg_replace('##', '', $text); + + return $output ?? $text; } /** * Replace tags with color codes. * - * @param array $matches An array of matches to replace. + * @param array $matches An array of matches to replace. * @return string */ - protected function _replaceTags($matches) + protected function _replaceTags(array $matches): string { - $style = $this->styles($matches['tag']); - if (empty($style)) { + $style = $this->getStyle($matches['tag']); + if (!$style) { return '<' . $matches['tag'] . '>' . $matches['text'] . ''; } @@ -239,69 +281,74 @@ protected function _replaceTags($matches) } } - return "\033[" . implode($styleInfo, ';') . 'm' . $matches['text'] . "\033[0m"; + return "\033[" . implode(';', $styleInfo) . 'm' . $matches['text'] . "\033[0m"; } /** * Writes a message to the output stream. * * @param string $message Message to write. - * @return int|bool The number of bytes returned from writing to output. + * @return int The number of bytes returned from writing to output. */ - protected function _write($message) + protected function _write(string $message): int { - return fwrite($this->_output, $message); + // @phpstan-ignore isset.property (property may not be set: ConsoleOutput::__destruct() unsets _output) + if (!isset($this->_output) || !is_resource($this->_output)) { + return 0; + } + + return (int)fwrite($this->_output, $message); } /** - * Get the current styles offered, or append new ones in. + * Gets the current styles offered * - * ### Get a style definition - * - * ``` - * $output->styles('error'); - * ``` - * - * ### Get all the style definitions - * - * ``` - * $output->styles(); - * ``` + * @param string $style The style to get. + * @return array The style or empty array. + */ + public function getStyle(string $style): array + { + return static::$_styles[$style] ?? []; + } + + /** + * Sets style. * - * ### Create or modify an existing style + * ### Creates or modifies an existing style. * * ``` - * $output->styles('annoy', ['text' => 'purple', 'background' => 'yellow', 'blink' => true]); + * $output->setStyle('annoy', ['text' => 'purple', 'background' => 'yellow', 'blink' => true]); * ``` * * ### Remove a style * * ``` - * $this->output->styles('annoy', false); + * $this->output->setStyle('annoy', []); * ``` * - * @param string|null $style The style to get or create. - * @param array|bool|null $definition The array definition of the style to change or create a style - * or false to remove a style. - * @return mixed If you are getting styles, the style or null will be returned. If you are creating/modifying - * styles true will be returned. + * @param string $style The style to set. + * @param array $definition The array definition of the style to change or create. + * @return void */ - public function styles($style = null, $definition = null) + public function setStyle(string $style, array $definition): void { - if ($style === null && $definition === null) { - return static::$_styles; - } - if (is_string($style) && $definition === null) { - return isset(static::$_styles[$style]) ? static::$_styles[$style] : null; - } - if ($definition === false) { + if (!$definition) { unset(static::$_styles[$style]); - return true; + return; } + static::$_styles[$style] = $definition; + } - return true; + /** + * Gets all the style definitions. + * + * @return array + */ + public function styles(): array + { + return static::$_styles; } /** @@ -309,7 +356,7 @@ public function styles($style = null, $definition = null) * * @return int */ - public function getOutputAs() + public function getOutputAs(): int { return $this->_outputAs; } @@ -321,37 +368,24 @@ public function getOutputAs() * @return void * @throws \InvalidArgumentException in case of a not supported output type. */ - public function setOutputAs($type) + public function setOutputAs(int $type): void { if (!in_array($type, [self::RAW, self::PLAIN, self::COLOR], true)) { - throw new InvalidArgumentException(sprintf('Invalid output type "%s".', $type)); + throw new InvalidArgumentException(sprintf('Invalid output type `%s`.', $type)); } $this->_outputAs = $type; } - /** - * Get/Set the output type to use. The output type how formatting tags are treated. - * - * @deprecated 3.5.0 Use getOutputAs()/setOutputAs() instead. - * @param int|null $type The output type to use. Should be one of the class constants. - * @return int|null Either null or the value if getting. - */ - public function outputAs($type = null) - { - if ($type === null) { - return $this->_outputAs; - } - $this->_outputAs = $type; - } - /** * Clean up and close handles */ public function __destruct() { - if (is_resource($this->_output)) { + // @phpstan-ignore isset.property (property may not be set if constructor throws) + if (isset($this->_output) && is_resource($this->_output)) { fclose($this->_output); } + unset($this->_output); } } diff --git a/src/Console/Exception/ConsoleException.php b/src/Console/Exception/ConsoleException.php index 445af8d902e..054ce9c2e0d 100644 --- a/src/Console/Exception/ConsoleException.php +++ b/src/Console/Exception/ConsoleException.php @@ -1,4 +1,6 @@ + */ + protected array $suggestions = []; + + /** + * Constructor. + * + * @param string $message The string message. + * @param string $requested The requested value. + * @param array $suggestions The list of potential values that were valid. + * @param int|null $code The exception code if relevant. + * @param \Throwable|null $previous the previous exception. + */ + public function __construct( + string $message, + string $requested = '', + array $suggestions = [], + ?int $code = null, + ?Throwable $previous = null, + ) { + $this->suggestions = $suggestions; + $this->requested = $requested; + parent::__construct($message, $code, $previous); + } + + /** + * Get the message with suggestions + * + * @return string + */ + public function getFullMessage(): string + { + $out = $this->getMessage(); + $bestGuess = $this->findClosestItem($this->requested, $this->suggestions); + if ($bestGuess) { + $out .= "\nDid you mean: `{$bestGuess}`?"; + } + $good = []; + foreach ($this->suggestions as $option) { + if (levenshtein($option, $this->requested) < 8) { + $good[] = '- ' . $option; + } + } + + if ($good) { + $out .= "\n\nOther valid choices:\n\n" . implode("\n", $good); + } + + return $out; + } + + /** + * Find the best match for requested in suggestions + * + * @param string $needle Unknown option name trying to be used. + * @param array $haystack Suggestions to look through. + * @return string|null The best match + */ + protected function findClosestItem(string $needle, array $haystack): ?string + { + $bestGuess = null; + foreach ($haystack as $item) { + if (str_starts_with($item, $needle)) { + return $item; + } + } + + $bestScore = 4; + foreach ($haystack as $item) { + $score = levenshtein($needle, $item); + + if ($score < $bestScore) { + $bestScore = $score; + $bestGuess = $item; + } + } + + return $bestGuess; + } +} diff --git a/src/Console/Exception/MissingShellException.php b/src/Console/Exception/MissingShellException.php deleted file mode 100644 index c6d51001c50..00000000000 --- a/src/Console/Exception/MissingShellException.php +++ /dev/null @@ -1,24 +0,0 @@ -_alias = $alias; - } else { - throw new ConsoleException('Alias must be of type string.'); - } + $this->_alias = $alias; } /** @@ -90,37 +85,21 @@ public function setAlias($alias) * @param int $width The width of the help output. * @return string */ - public function text($width = 72) + public function text(int $width = 72): string { $parser = $this->_parser; $out = []; $description = $parser->getDescription(); - if (!empty($description)) { + if ($description) { $out[] = Text::wrap($description, $width); $out[] = ''; } $out[] = 'Usage:'; $out[] = $this->_generateUsage(); $out[] = ''; - $subcommands = $parser->subcommands(); - if (!empty($subcommands)) { - $out[] = 'Subcommands:'; - $out[] = ''; - $max = $this->_getMaxLength($subcommands) + 2; - foreach ($subcommands as $command) { - $out[] = Text::wrapBlock($command->help($max), [ - 'width' => $width, - 'indent' => str_repeat(' ', $max), - 'indentAt' => 1 - ]); - } - $out[] = ''; - $out[] = sprintf('To see help on a subcommand use `' . $this->_alias . ' %s [subcommand] --help`', $parser->getCommand()); - $out[] = ''; - } $options = $parser->options(); - if (!empty($options)) { + if ($options) { $max = $this->_getMaxLength($options) + 8; $out[] = 'Options:'; $out[] = ''; @@ -128,14 +107,14 @@ public function text($width = 72) $out[] = Text::wrapBlock($option->help($max), [ 'width' => $width, 'indent' => str_repeat(' ', $max), - 'indentAt' => 1 + 'indentAt' => 1, ]); } $out[] = ''; } $arguments = $parser->arguments(); - if (!empty($arguments)) { + if ($arguments) { $max = $this->_getMaxLength($arguments) + 2; $out[] = 'Arguments:'; $out[] = ''; @@ -143,13 +122,13 @@ public function text($width = 72) $out[] = Text::wrapBlock($argument->help($max), [ 'width' => $width, 'indent' => str_repeat(' ', $max), - 'indentAt' => 1 + 'indentAt' => 1, ]); } $out[] = ''; } $epilog = $parser->getEpilog(); - if (!empty($epilog)) { + if ($epilog) { $out[] = Text::wrap($epilog, $width); $out[] = ''; } @@ -164,13 +143,9 @@ public function text($width = 72) * * @return string */ - protected function _generateUsage() + protected function _generateUsage(): string { $usage = [$this->_alias . ' ' . $this->_parser->getCommand()]; - $subcommands = $this->_parser->subcommands(); - if (!empty($subcommands)) { - $usage[] = '[subcommand]'; - } $options = []; foreach ($this->_parser->options() as $option) { $options[] = $option->usage(); @@ -194,46 +169,46 @@ protected function _generateUsage() /** * Iterate over a collection and find the longest named thing. * - * @param array $collection The collection to find a max length of. + * @param array<\Cake\Console\ConsoleInputOption|\Cake\Console\ConsoleInputArgument> $collection The collection to find a max length of. * @return int */ - protected function _getMaxLength($collection) + protected function _getMaxLength(array $collection): int { $max = 0; foreach ($collection as $item) { - $max = (strlen($item->name()) > $max) ? strlen($item->name()) : $max; + $max = max(strlen($item->name()), $max); } return $max; } /** - * Get the help as an xml string. + * Get the help as an XML string. * * @param bool $string Return the SimpleXml object or a string. Defaults to true. - * @return string|\SimpleXMLElement See $string + * @return \SimpleXMLElement|string See $string */ - public function xml($string = true) + public function xml(bool $string = true): SimpleXMLElement|string { $parser = $this->_parser; $xml = new SimpleXMLElement(''); $xml->addChild('command', $parser->getCommand()); $xml->addChild('description', $parser->getDescription()); - $subcommands = $xml->addChild('subcommands'); - foreach ($parser->subcommands() as $command) { - $command->xml($subcommands); - } $options = $xml->addChild('options'); - foreach ($parser->options() as $option) { - $option->xml($options); + if ($options !== null) { + foreach ($parser->options() as $option) { + $option->xml($options); + } } $arguments = $xml->addChild('arguments'); - foreach ($parser->arguments() as $argument) { - $argument->xml($arguments); + if ($arguments !== null) { + foreach ($parser->arguments() as $argument) { + $argument->xml($arguments); + } } $xml->addChild('epilog', $parser->getEpilog()); - return $string ? $xml->asXML() : $xml; + return $string ? (string)$xml->asXML() : $xml; } } diff --git a/src/Console/Helper.php b/src/Console/Helper.php index e498f3adb62..a21c29877f3 100644 --- a/src/Console/Helper.php +++ b/src/Console/Helper.php @@ -1,4 +1,6 @@ */ - protected $_defaultConfig = []; + protected array $_defaultConfig = []; /** * ConsoleIo instance. * * @var \Cake\Console\ConsoleIo */ - protected $_io; + protected ConsoleIo $_io; /** * Constructor. * * @param \Cake\Console\ConsoleIo $io The ConsoleIo instance to use. - * @param array $config The settings for this helper. + * @param array $config The settings for this helper. */ public function __construct(ConsoleIo $io, array $config = []) { @@ -59,5 +61,5 @@ public function __construct(ConsoleIo $io, array $config = []) * @param array $args The arguments for the helper. * @return void */ - abstract public function output($args); + abstract public function output(array $args): void; } diff --git a/src/Console/Helper/BannerHelper.php b/src/Console/Helper/BannerHelper.php new file mode 100644 index 00000000000..d34e26aeaff --- /dev/null +++ b/src/Console/Helper/BannerHelper.php @@ -0,0 +1,111 @@ +padding = $padding; + + return $this; + } + + /** + * Modify the padding of the helper + * + * @param string $style The style value to use. + * @return $this + */ + public function withStyle(string $style) + { + $this->style = $style; + + return $this; + } + + /** + * Output a banner + * + * @param array $args The messages to output + * @return void + */ + public function output(array $args): void + { + if ($args === []) { + throw new InvalidArgumentException('At least one argument is required'); + } + + $lengths = array_map(mb_strlen(...), $args); + $maxLength = max($lengths); + $bannerLength = $maxLength + $this->padding * 2; + $start = "<{$this->style}>"; + $end = "style}>"; + + $lines = [ + '', + $start . str_repeat(' ', $bannerLength) . $end, + ]; + foreach ($args as $line) { + $lineLength = mb_strlen($line); + $linePadding = (int)max($this->padding, $bannerLength - $lineLength - $this->padding); + + $lines[] = $start . + str_repeat(' ', $this->padding) . + $line . + str_repeat(' ', $linePadding) . + $end; + } + + $lines[] = $start . str_repeat(' ', $bannerLength) . $end; + $lines[] = ''; + + $this->_io->out($lines); + } +} + +// phpcs:disable +class_alias('Cake\Console\Helper\BannerHelper', 'Cake\Command\Helper\BannerHelper'); +// phpcs:enable diff --git a/src/Console/Helper/ProgressHelper.php b/src/Console/Helper/ProgressHelper.php new file mode 100644 index 00000000000..1d9077f28a5 --- /dev/null +++ b/src/Console/Helper/ProgressHelper.php @@ -0,0 +1,167 @@ +helper('Progress')->output(['callback' => function ($progress) { + * // Do work + * $progress->increment(); + * }]); + * ``` + */ +class ProgressHelper extends Helper +{ + /** + * Default value for progress bar total value. + * Percent completion is derived from progress/total + */ + protected const DEFAULT_TOTAL = 100; + + /** + * Default value for progress bar width + */ + protected const DEFAULT_WIDTH = 80; + + /** + * The current progress. + * + * @var float|int + */ + protected float|int $_progress = 0; + + /** + * The total number of 'items' to progress through. + * + * @var int + */ + protected int $_total = self::DEFAULT_TOTAL; + + /** + * The width of the bar. + * + * @var int + */ + protected int $_width = self::DEFAULT_WIDTH; + + /** + * Output a progress bar. + * + * Takes a number of options to customize the behavior: + * + * - `total` The total number of items in the progress bar. Defaults + * to 100. + * - `width` The width of the progress bar. Defaults to 80. + * - `callback` The callback that will be called in a loop to advance the progress bar. + * + * @param array $args The arguments/options to use when outputting the progress bar. + * @return void + */ + public function output(array $args): void + { + $args += ['callback' => null]; + if (isset($args[0])) { + $args['callback'] = $args[0]; + } + if (!$args['callback'] || !is_callable($args['callback'])) { + throw new InvalidArgumentException('Callback option must be a callable.'); + } + $this->init($args); + + $callback = $args['callback']; + + $this->_io->out('', 0); + while ($this->_progress < $this->_total) { + $callback($this); + $this->draw(); + } + $this->_io->out(''); + } + + /** + * Initialize the progress bar for use. + * + * - `total` The total number of items in the progress bar. Defaults + * to 100. + * - `width` The width of the progress bar. Defaults to 80. + * + * @param array $args The initialization data. + * @return $this + */ + public function init(array $args = []) + { + $args += ['total' => self::DEFAULT_TOTAL, 'width' => self::DEFAULT_WIDTH]; + $this->_progress = 0; + $this->_width = $args['width']; + $this->_total = $args['total']; + + return $this; + } + + /** + * Increment the progress bar. + * + * @param float|int $num The amount of progress to advance by. + * @return $this + */ + public function increment(float|int $num = 1) + { + $this->_progress = min(max(0, $this->_progress + $num), $this->_total); + + return $this; + } + + /** + * Render the progress bar based on the current state. + * + * @return $this + */ + public function draw() + { + $numberLen = strlen(' 100%'); + $complete = round($this->_progress / $this->_total, 2); + $barLen = ($this->_width - $numberLen) * $this->_progress / $this->_total; + $bar = ''; + if ($barLen > 1) { + $bar = str_repeat('=', (int)$barLen - 1) . '>'; + } + + $pad = ceil($this->_width - $numberLen - $barLen); + if ($pad > 0) { + $bar .= str_repeat(' ', (int)$pad); + } + $percent = ($complete * 100) . '%'; + $bar .= str_pad($percent, $numberLen, ' ', STR_PAD_LEFT); + + $this->_io->overwrite($bar, 0); + + return $this; + } +} + +// phpcs:disable +class_alias('Cake\Console\Helper\ProgressHelper', 'Cake\Command\Helper\ProgressHelper'); +// phpcs:enable diff --git a/src/Console/Helper/TableHelper.php b/src/Console/Helper/TableHelper.php new file mode 100644 index 00000000000..6d2606a5c5c --- /dev/null +++ b/src/Console/Helper/TableHelper.php @@ -0,0 +1,189 @@ + + */ + protected array $_defaultConfig = [ + 'headers' => true, + 'rowSeparator' => false, + 'headerStyle' => 'info', + ]; + + /** + * Calculate the column widths + * + * @param array $rows The rows on which the column's width will be calculated on. + * @return array + */ + protected function _calculateWidths(array $rows): array + { + $widths = []; + foreach ($rows as $line) { + foreach (array_values($line) as $k => $v) { + $columnLength = $this->_cellWidth((string)$v); + if ($columnLength >= ($widths[$k] ?? 0)) { + $widths[$k] = $columnLength; + } + } + } + + return $widths; + } + + /** + * Get the width of a cell exclusive of style tags. + * + * @param string $text The text to calculate a width for. + * @return int The width of the textual content in visible characters. + */ + protected function _cellWidth(string $text): int + { + if ($text === '') { + return 0; + } + + if (!str_contains($text, '<') && !str_contains($text, '>')) { + return mb_strwidth($text); + } + + $styles = $this->_io->styles(); + $tags = implode('|', array_keys($styles)); + $text = (string)preg_replace('##', '', $text); + + return mb_strwidth($text); + } + + /** + * Output a row separator. + * + * @param array $widths The widths of each column to output. + * @return void + */ + protected function _rowSeparator(array $widths): void + { + $out = ''; + foreach ($widths as $column) { + $out .= '+' . str_repeat('-', $column + 2); + } + $out .= '+'; + $this->_io->out($out); + } + + /** + * Output a row. + * + * @param array $row The row to output. + * @param array $widths The widths of each column to output. + * @param array $options Options to be passed. + * @return void + */ + protected function _render(array $row, array $widths, array $options = []): void + { + if ($row === []) { + return; + } + + $out = ''; + foreach (array_values($row) as $i => $column) { + $column = (string)$column; + $pad = $widths[$i] - $this->_cellWidth($column); + if (!empty($options['style'])) { + $column = $this->_addStyle($column, $options['style']); + } + if ($column !== '' && preg_match('#(.*).+(.*)#', $column, $matches)) { + if ($matches[1] !== '' || $matches[2] !== '') { + throw new UnexpectedValueException('You cannot include text before or after the text-right tag.'); + } + $column = str_replace(['', ''], '', $column); + $out .= '| ' . str_repeat(' ', $pad) . $column . ' '; + } else { + $out .= '| ' . $column . str_repeat(' ', $pad) . ' '; + } + } + $out .= '|'; + $this->_io->out($out); + } + + /** + * Output a table. + * + * Data will be output based on the order of the values + * in the array. The keys will not be used to align data. + * + * @param array $args The data to render out. + * @return void + */ + public function output(array $args): void + { + if (!$args) { + return; + } + + $this->_io->setStyle('text-right', ['text' => null]); + + $config = $this->getConfig(); + $widths = $this->_calculateWidths($args); + + $this->_rowSeparator($widths); + if ($config['headers'] === true) { + $this->_render(array_shift($args), $widths, ['style' => $config['headerStyle']]); + $this->_rowSeparator($widths); + } + + if (!$args) { + return; + } + + foreach ($args as $line) { + $this->_render($line, $widths); + if ($config['rowSeparator'] === true) { + $this->_rowSeparator($widths); + } + } + if ($config['rowSeparator'] !== true) { + $this->_rowSeparator($widths); + } + } + + /** + * Add style tags + * + * @param string $text The text to be surrounded + * @param string $style The style to be applied + * @return string + */ + protected function _addStyle(string $text, string $style): string + { + return '<' . $style . '>' . $text . ''; + } +} + +// phpcs:disable +class_alias('Cake\Console\Helper\TableHelper', 'Cake\Command\Helper\TableHelper'); +// phpcs:enable diff --git a/src/Console/Helper/TreeHelper.php b/src/Console/Helper/TreeHelper.php new file mode 100644 index 00000000000..c6ea47ba428 --- /dev/null +++ b/src/Console/Helper/TreeHelper.php @@ -0,0 +1,128 @@ + 0, + 'elementIndent' => 0, + ]; + + /** + * Outputs an array in tree form. + * + * @param array $args Tree array + * @return void + */ + public function output(array $args): void + { + $prefix = str_repeat(' ', $this->_config['baseIndent']); + $this->outputArray($args, $prefix, topLevel: true); + } + + /** + * Output an array in a tree. + * + * @param array $array + * @param string $prefix + * @param bool $topLevel + * @return void + */ + protected function outputArray(array $array, string $prefix, bool $topLevel): void + { + $i = 1; + $numValues = count($array); + $elementPrefix = $topLevel ? '' : str_repeat(' ', $this->_config['elementIndent']); + foreach ($array as $key => $value) { + $isLast = $i++ === $numValues; + $marker = $isLast ? '└── ' : '├── '; + $indent = $isLast ? ' ' : '│ '; + $this->outputElement($key, $value, $prefix . $elementPrefix, $marker, $indent); + } + } + + /** + * Output an array element. + * + * @param string|int $key + * @param mixed $value + * @param string $prefix + * @param string $marker + * @param string $indent + * @return void + */ + protected function outputElement( + string|int $key, + mixed $value, + string $prefix, + string $marker, + string $indent, + ): void { + if (is_array($value)) { + $this->_io->out($prefix . $marker . $key); + $this->outputArray($value, $prefix . $indent, topLevel: false); + } elseif (is_string($key)) { + $this->_io->out($prefix . $marker . $key); + $this->outputValue($value, $prefix . $indent . '└── '); + } else { + $this->outputValue($value, $prefix . $marker); + } + } + + /** + * Output a value in a tree. + * + * @param mixed $value + * @param string $prefix + * @return void + */ + protected function outputValue(mixed $value, string $prefix): void + { + if ($value instanceof Closure) { + $this->_io->out($prefix . $value()); + } elseif (interface_exists(EnumLabelInterface::class) && $value instanceof EnumLabelInterface) { + $this->_io->out($prefix . $value->label()); + } elseif ($value instanceof BackedEnum) { + $this->_io->out($prefix . $value->value); + } elseif ($value instanceof UnitEnum) { + $this->_io->out($prefix . $value->name); + } elseif (is_bool($value)) { + $this->_io->out($prefix . ($value ? 'true' : 'false')); + } else { + $this->_io->out($prefix . $value); + } + } +} + +// phpcs:disable +class_alias('Cake\Console\Helper\TreeHelper', 'Cake\Command\Helper\TreeHelper'); +// phpcs:enable diff --git a/src/Console/HelperRegistry.php b/src/Console/HelperRegistry.php index b0b821988d1..e3672c684c2 100644 --- a/src/Console/HelperRegistry.php +++ b/src/Console/HelperRegistry.php @@ -1,4 +1,6 @@ */ class HelperRegistry extends ObjectRegistry { - /** - * Shell to use to set params to tasks. + * IO instance. * * @var \Cake\Console\ConsoleIo */ - protected $_io; + protected ConsoleIo $_io; /** - * Sets The IO instance that should be passed to the shell helpers + * Sets the IO instance that should be passed to the shell helpers * * @param \Cake\Console\ConsoleIo $io An io instance. * @return void */ - public function setIo(ConsoleIo $io) + public function setIo(ConsoleIo $io): void { $this->_io = $io; } @@ -46,14 +50,32 @@ public function setIo(ConsoleIo $io) /** * Resolve a helper classname. * - * Part of the template method for Cake\Core\ObjectRegistry::load() + * Part of the template method for {@link \Cake\Core\ObjectRegistry::load()}. * * @param string $class Partial classname to resolve. - * @return string|false Either the correct classname or false. + * @return class-string<\Cake\Console\Helper>|null Either the correct class name or null. */ - protected function _resolveClassName($class) + protected function _resolveClassName(string $class): ?string { - return App::className($class, 'Shell/Helper', 'Helper'); + /** @var class-string<\Cake\Console\Helper>|null $result */ + $result = App::className($class, 'Console/Helper', 'Helper'); + if ($result !== null) { + return $result; + } + + /** @var class-string<\Cake\Console\Helper>|null $result */ + $result = App::className($class, 'Command/Helper', 'Helper'); + if ($result !== null) { + deprecationWarning( + '5.4.0', + sprintf( + 'Helpers in `Command/Helper` are deprecated. Move `%s` to `Console/Helper`.', + $class, + ), + ); + } + + return $result; } /** @@ -63,15 +85,15 @@ protected function _resolveClassName($class) * and Cake\Core\ObjectRegistry::unload() * * @param string $class The classname that is missing. - * @param string $plugin The plugin the helper is missing in. + * @param string|null $plugin The plugin the helper is missing in. * @return void * @throws \Cake\Console\Exception\MissingHelperException */ - protected function _throwMissingClassError($class, $plugin) + protected function _throwMissingClassError(string $class, ?string $plugin): void { throw new MissingHelperException([ 'class' => $class, - 'plugin' => $plugin + 'plugin' => $plugin, ]); } @@ -80,13 +102,17 @@ protected function _throwMissingClassError($class, $plugin) * * Part of the template method for Cake\Core\ObjectRegistry::load() * - * @param string $class The classname to create. + * @param \Cake\Console\Helper|class-string<\Cake\Console\Helper> $class The classname to create. * @param string $alias The alias of the helper. - * @param array $settings An array of settings to use for the helper. + * @param array $config An array of settings to use for the helper. * @return \Cake\Console\Helper The constructed helper class. */ - protected function _create($class, $alias, $settings) + protected function _create(object|string $class, string $alias, array $config): Helper { - return new $class($this->_io, $settings); + if (is_object($class)) { + return $class; + } + + return new $class($this->_io, $config); } } diff --git a/src/Console/LICENSE.txt b/src/Console/LICENSE.txt new file mode 100644 index 00000000000..b938c9e8ed3 --- /dev/null +++ b/src/Console/LICENSE.txt @@ -0,0 +1,22 @@ +The MIT License (MIT) + +CakePHP(tm) : The Rapid Development PHP Framework (https://cakephp.org) +Copyright (c) 2005-2020, Cake Software Foundation, Inc. (https://cakefoundation.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/Console/README.md b/src/Console/README.md new file mode 100644 index 00000000000..ed9ca5a6349 --- /dev/null +++ b/src/Console/README.md @@ -0,0 +1,125 @@ +[![Total Downloads](https://img.shields.io/packagist/dt/cakephp/http.svg?style=flat-square)](https://packagist.org/packages/cakephp/console) +[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE.txt) + +# CakePHP Console Library + +This library provides a framework for building command line applications from a +set of commands. It provides abstractions for defining option and argument +parsers, and dispatching commands. + +# installation + +You can install it from Composer. In your project: + +``` +composer require cakephp/console +``` + +# Getting Started + +To start, define an entry point script and Application class which defines +bootstrap logic, and binds your commands. Lets put our entrypoint script in +`bin/tool.php`: + +```php +#!/usr/bin/php -q +run($argv)); +```` + +For our `Application` class we can start with: + +```php +add('hello', HelloCommand::class); + + return $commands; + } +} +``` + +Next we'll build a very simple `HelloCommand`: + +```php +addArgument('name', [ + 'required' => true, + 'help' => 'The name to say hello to', + ]) + ->addOption('color', [ + 'choices' => ['none', 'green'], + 'default' => 'none', + 'help' => 'The color to use.' + ]); + + return $parser; + } + + public function execute(Arguments $args, ConsoleIo $io): ?int + { + $color = $args->getOption('color'); + if ($color === 'none') { + $io->out("Hello {$args->getArgument('name')}"); + } elseif ($color == 'green') { + $io->out("Hello {$args->getArgument('name')}"); + } + + return static::CODE_SUCCESS; + } +} +``` + +Next we can run our command with `php bin/tool.php hello Syd`. To learn more +about the various features we've used in this example read the docs: + +* [Option Parsing](https://book.cakephp.org/5/en/console-commands/option-parsers.html) +* [Input & Output](https://book.cakephp.org/5/en/console-commands/input-output.html) + diff --git a/src/Console/Shell.php b/src/Console/Shell.php deleted file mode 100644 index 5d6d6513c01..00000000000 --- a/src/Console/Shell.php +++ /dev/null @@ -1,992 +0,0 @@ -name) { - list(, $class) = namespaceSplit(get_class($this)); - $this->name = str_replace(['Shell', 'Task'], '', $class); - } - $this->_io = $io ?: new ConsoleIo(); - - $locator = $this->getTableLocator() ? : 'Cake\ORM\TableRegistry'; - $this->modelFactory('Table', [$locator, 'get']); - $this->Tasks = new TaskRegistry($this); - - $this->_mergeVars( - ['tasks'], - ['associative' => ['tasks']] - ); - - if (isset($this->modelClass)) { - $this->loadModel(); - } - } - - /** - * Set the root command name for help output. - * - * @param string $name The name of the root command. - * @return $this - */ - public function setRootName($name) - { - $this->rootName = (string)$name; - - return $this; - } - - /** - * Get the io object for this shell. - * - * @return \Cake\Console\ConsoleIo The current ConsoleIo object. - */ - public function getIo() - { - return $this->_io; - } - - /** - * Set the io object for this shell. - * - * @param \Cake\Console\ConsoleIo $io The ConsoleIo object to use. - * @return void - */ - public function setIo(ConsoleIo $io) - { - $this->_io = $io; - } - - /** - * Get/Set the io object for this shell. - * - * @deprecated 3.5.0 Use getIo()/setIo() instead. - * @param \Cake\Console\ConsoleIo|null $io The ConsoleIo object to use. - * @return \Cake\Console\ConsoleIo The current ConsoleIo object. - */ - public function io(ConsoleIo $io = null) - { - if ($io !== null) { - $this->_io = $io; - } - - return $this->_io; - } - - /** - * Initializes the Shell - * acts as constructor for subclasses - * allows configuration of tasks prior to shell execution - * - * @return void - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#Cake\Console\ConsoleOptionParser::initialize - */ - public function initialize() - { - $this->loadTasks(); - } - - /** - * Starts up the Shell and displays the welcome message. - * Allows for checking and configuring prior to command or main execution - * - * Override this method if you want to remove the welcome information, - * or otherwise modify the pre-command flow. - * - * @return void - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#Cake\Console\ConsoleOptionParser::startup - */ - public function startup() - { - if (!$this->param('requested')) { - $this->_welcome(); - } - } - - /** - * Displays a header for the shell - * - * @return void - */ - protected function _welcome() - { - } - - /** - * Loads tasks defined in public $tasks - * - * @return bool - */ - public function loadTasks() - { - if ($this->tasks === true || empty($this->tasks) || empty($this->Tasks)) { - return true; - } - $this->_taskMap = $this->Tasks->normalizeArray((array)$this->tasks); - $this->taskNames = array_merge($this->taskNames, array_keys($this->_taskMap)); - - return true; - } - - /** - * Check to see if this shell has a task with the provided name. - * - * @param string $task The task name to check. - * @return bool Success - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#shell-tasks - */ - public function hasTask($task) - { - return isset($this->_taskMap[Inflector::camelize($task)]); - } - - /** - * Check to see if this shell has a callable method by the given name. - * - * @param string $name The method name to check. - * @return bool - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#shell-tasks - */ - public function hasMethod($name) - { - try { - $method = new ReflectionMethod($this, $name); - if (!$method->isPublic()) { - return false; - } - - return $method->getDeclaringClass()->name !== 'Cake\Console\Shell'; - } catch (ReflectionException $e) { - return false; - } - } - - /** - * Dispatch a command to another Shell. Similar to Object::requestAction() - * but intended for running shells from other shells. - * - * ### Usage: - * - * With a string command: - * - * ``` - * return $this->dispatchShell('schema create DbAcl'); - * ``` - * - * Avoid using this form if you have string arguments, with spaces in them. - * The dispatched will be invoked incorrectly. Only use this form for simple - * command dispatching. - * - * With an array command: - * - * ``` - * return $this->dispatchShell('schema', 'create', 'i18n', '--dry'); - * ``` - * - * With an array having two key / value pairs: - * - `command` can accept either a string or an array. Represents the command to dispatch - * - `extra` can accept an array of extra parameters to pass on to the dispatcher. This - * parameters will be available in the `param` property of the called `Shell` - * - * `return $this->dispatchShell([ - * 'command' => 'schema create DbAcl', - * 'extra' => ['param' => 'value'] - * ]);` - * - * or - * - * `return $this->dispatchShell([ - * 'command' => ['schema', 'create', 'DbAcl'], - * 'extra' => ['param' => 'value'] - * ]);` - * - * @return int The cli command exit code. 0 is success. - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#invoking-other-shells-from-your-shell - */ - public function dispatchShell() - { - list($args, $extra) = $this->parseDispatchArguments(func_get_args()); - - if (!isset($extra['requested'])) { - $extra['requested'] = true; - } - - $dispatcher = new ShellDispatcher($args, false); - - return $dispatcher->dispatch($extra); - } - - /** - * Parses the arguments for the dispatchShell() method. - * - * @param array $args Arguments fetch from the dispatchShell() method with - * func_get_args() - * @return array First value has to be an array of the command arguments. - * Second value has to be an array of extra parameter to pass on to the dispatcher - */ - public function parseDispatchArguments($args) - { - $extra = []; - - if (is_string($args[0]) && count($args) === 1) { - $args = explode(' ', $args[0]); - - return [$args, $extra]; - } - - if (is_array($args[0]) && !empty($args[0]['command'])) { - $command = $args[0]['command']; - if (is_string($command)) { - $command = explode(' ', $command); - } - - if (!empty($args[0]['extra'])) { - $extra = $args[0]['extra']; - } - - return [$command, $extra]; - } - - return [$args, $extra]; - } - - /** - * Runs the Shell with the provided argv. - * - * Delegates calls to Tasks and resolves methods inside the class. Commands are looked - * up with the following order: - * - * - Method on the shell. - * - Matching task name. - * - `main()` method. - * - * If a shell implements a `main()` method, all missing method calls will be sent to - * `main()` with the original method name in the argv. - * - * For tasks to be invoked they *must* be exposed as subcommands. If you define any subcommands, - * you must define all the subcommands your shell needs, whether they be methods on this class - * or methods on tasks. - * - * @param array $argv Array of arguments to run the shell with. This array should be missing the shell name. - * @param bool $autoMethod Set to true to allow any public method to be called even if it - * was not defined as a subcommand. This is used by ShellDispatcher to make building simple shells easy. - * @param array $extra Extra parameters that you can manually pass to the Shell - * to be dispatched. - * Built-in extra parameter is : - * - `requested` : if used, will prevent the Shell welcome message to be displayed - * @return int|bool|null - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#the-cakephp-console - */ - public function runCommand($argv, $autoMethod = false, $extra = []) - { - $command = isset($argv[0]) ? Inflector::underscore($argv[0]) : null; - $this->OptionParser = $this->getOptionParser(); - try { - list($this->params, $this->args) = $this->OptionParser->parse($argv); - } catch (ConsoleException $e) { - $this->err('Error: ' . $e->getMessage()); - - return false; - } - - if (!empty($extra) && is_array($extra)) { - $this->params = array_merge($this->params, $extra); - } - $this->_setOutputLevel(); - if (!empty($this->params['plugin']) && !Plugin::loaded($this->params['plugin'])) { - Plugin::load($this->params['plugin']); - } - $this->command = $command; - if (!empty($this->params['help'])) { - return $this->_displayHelp($command); - } - - $subcommands = $this->OptionParser->subcommands(); - $method = Inflector::camelize($command); - $isMethod = $this->hasMethod($method); - - if ($isMethod && $autoMethod && count($subcommands) === 0) { - array_shift($this->args); - $this->startup(); - - return $this->$method(...$this->args); - } - - if ($isMethod && isset($subcommands[$command])) { - $this->startup(); - - return $this->$method(...$this->args); - } - - if ($this->hasTask($command) && isset($subcommands[$command])) { - $this->startup(); - array_shift($argv); - - return $this->{$method}->runCommand($argv, false, ['requested' => true]); - } - - if ($this->hasMethod('main')) { - $this->command = 'main'; - $this->startup(); - - return $this->main(...$this->args); - } - - $this->err('No subcommand provided. Choose one of the available subcommands.', 2); - $this->_io->err($this->OptionParser->help($command)); - - return false; - } - - /** - * Set the output level based on the parameters. - * - * This reconfigures both the output level for out() - * and the configured stdout/stderr logging - * - * @return void - */ - protected function _setOutputLevel() - { - $this->_io->setLoggers(ConsoleIo::NORMAL); - if (!empty($this->params['quiet'])) { - $this->_io->level(ConsoleIo::QUIET); - $this->_io->setLoggers(ConsoleIo::QUIET); - } - if (!empty($this->params['verbose'])) { - $this->_io->level(ConsoleIo::VERBOSE); - $this->_io->setLoggers(ConsoleIo::VERBOSE); - } - } - - /** - * Display the help in the correct format - * - * @param string $command The command to get help for. - * @return int|bool The number of bytes returned from writing to stdout. - */ - protected function _displayHelp($command) - { - $format = 'text'; - if (!empty($this->args[0]) && $this->args[0] === 'xml') { - $format = 'xml'; - $this->_io->setOutputAs(ConsoleOutput::RAW); - } else { - $this->_welcome(); - } - - $subcommands = $this->OptionParser->subcommands(); - $command = isset($subcommands[$command]) ? $command : null; - - return $this->out($this->OptionParser->help($command, $format)); - } - - /** - * Gets the option parser instance and configures it. - * - * By overriding this method you can configure the ConsoleOptionParser before returning it. - * - * @return \Cake\Console\ConsoleOptionParser - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#configuring-options-and-generating-help - */ - public function getOptionParser() - { - $name = ($this->plugin ? $this->plugin . '.' : '') . $this->name; - $parser = new ConsoleOptionParser($name); - $parser->setRootName($this->rootName); - - return $parser; - } - - /** - * Overload get for lazy building of tasks - * - * @param string $name The task to get. - * @return \Cake\Console\Shell Object of Task - */ - public function __get($name) - { - if (empty($this->{$name}) && in_array($name, $this->taskNames)) { - $properties = $this->_taskMap[$name]; - $this->{$name} = $this->Tasks->load($properties['class'], $properties['config']); - $this->{$name}->args =& $this->args; - $this->{$name}->params =& $this->params; - $this->{$name}->initialize(); - $this->{$name}->loadTasks(); - } - - return $this->{$name}; - } - - /** - * Safely access the values in $this->params. - * - * @param string $name The name of the parameter to get. - * @return string|bool|null Value. Will return null if it doesn't exist. - */ - public function param($name) - { - if (!isset($this->params[$name])) { - return null; - } - - return $this->params[$name]; - } - - /** - * Prompts the user for input, and returns it. - * - * @param string $prompt Prompt text. - * @param string|array|null $options Array or string of options. - * @param string|null $default Default input value. - * @return mixed Either the default value, or the user-provided input. - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#Shell::in - */ - public function in($prompt, $options = null, $default = null) - { - if (!$this->interactive) { - return $default; - } - if ($options) { - return $this->_io->askChoice($prompt, $options, $default); - } - - return $this->_io->ask($prompt, $default); - } - - /** - * Wrap a block of text. - * Allows you to set the width, and indenting on a block of text. - * - * ### Options - * - * - `width` The width to wrap to. Defaults to 72 - * - `wordWrap` Only wrap on words breaks (spaces) Defaults to true. - * - `indent` Indent the text with the string provided. Defaults to null. - * - * @param string $text Text the text to format. - * @param int|array $options Array of options to use, or an integer to wrap the text to. - * @return string Wrapped / indented text - * @see \Cake\Utility\Text::wrap() - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#Shell::wrapText - */ - public function wrapText($text, $options = []) - { - return Text::wrap($text, $options); - } - - /** - * Output at the verbose level. - * - * @param string|array $message A string or an array of strings to output - * @param int $newlines Number of newlines to append - * @return int|bool The number of bytes returned from writing to stdout. - */ - public function verbose($message, $newlines = 1) - { - return $this->_io->verbose($message, $newlines); - } - - /** - * Output at all levels. - * - * @param string|array $message A string or an array of strings to output - * @param int $newlines Number of newlines to append - * @return int|bool The number of bytes returned from writing to stdout. - */ - public function quiet($message, $newlines = 1) - { - return $this->_io->quiet($message, $newlines); - } - - /** - * Outputs a single or multiple messages to stdout. If no parameters - * are passed outputs just a newline. - * - * ### Output levels - * - * There are 3 built-in output level. Shell::QUIET, Shell::NORMAL, Shell::VERBOSE. - * The verbose and quiet output levels, map to the `verbose` and `quiet` output switches - * present in most shells. Using Shell::QUIET for a message means it will always display. - * While using Shell::VERBOSE means it will only display when verbose output is toggled. - * - * @param string|array|null $message A string or an array of strings to output - * @param int $newlines Number of newlines to append - * @param int $level The message's output level, see above. - * @return int|bool The number of bytes returned from writing to stdout. - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#Shell::out - */ - public function out($message = null, $newlines = 1, $level = Shell::NORMAL) - { - return $this->_io->out($message, $newlines, $level); - } - - /** - * Outputs a single or multiple error messages to stderr. If no parameters - * are passed outputs just a newline. - * - * @param string|array|null $message A string or an array of strings to output - * @param int $newlines Number of newlines to append - * @return int|bool The number of bytes returned from writing to stderr. - */ - public function err($message = null, $newlines = 1) - { - $messageType = 'error'; - $message = $this->wrapMessageWithType($messageType, $message); - - return $this->_io->err($message, $newlines); - } - - /** - * Convenience method for out() that wraps message between tag - * - * @param string|array|null $message A string or an array of strings to output - * @param int $newlines Number of newlines to append - * @param int $level The message's output level, see above. - * @return int|bool The number of bytes returned from writing to stdout. - * @see https://book.cakephp.org/3.0/en/console-and-shells.html#Shell::out - */ - public function info($message = null, $newlines = 1, $level = Shell::NORMAL) - { - $messageType = 'info'; - $message = $this->wrapMessageWithType($messageType, $message); - - return $this->out($message, $newlines, $level); - } - - /** - * Convenience method for err() that wraps message between tag - * - * @param string|array|null $message A string or an array of strings to output - * @param int $newlines Number of newlines to append - * @return int|bool The number of bytes returned from writing to stderr. - * @see https://book.cakephp.org/3.0/en/console-and-shells.html#Shell::err - */ - public function warn($message = null, $newlines = 1) - { - $messageType = 'warning'; - $message = $this->wrapMessageWithType($messageType, $message); - - return $this->_io->err($message, $newlines); - } - - /** - * Convenience method for out() that wraps message between tag - * - * @param string|array|null $message A string or an array of strings to output - * @param int $newlines Number of newlines to append - * @param int $level The message's output level, see above. - * @return int|bool The number of bytes returned from writing to stdout. - * @see https://book.cakephp.org/3.0/en/console-and-shells.html#Shell::out - */ - public function success($message = null, $newlines = 1, $level = Shell::NORMAL) - { - $messageType = 'success'; - $message = $this->wrapMessageWithType($messageType, $message); - - return $this->out($message, $newlines, $level); - } - - /** - * Wraps a message with a given message type, e.g. - * - * @param string $messageType The message type, e.g. "warning". - * @param string|array $message The message to wrap. - * @return array|string The message wrapped with the given message type. - */ - protected function wrapMessageWithType($messageType, $message) - { - if (is_array($message)) { - foreach ($message as $k => $v) { - $message[$k] = "<$messageType>" . $v . ""; - } - } else { - $message = "<$messageType>" . $message . ""; - } - - return $message; - } - - /** - * Returns a single or multiple linefeeds sequences. - * - * @param int $multiplier Number of times the linefeed sequence should be repeated - * @return string - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#Shell::nl - */ - public function nl($multiplier = 1) - { - return $this->_io->nl($multiplier); - } - - /** - * Outputs a series of minus characters to the standard output, acts as a visual separator. - * - * @param int $newlines Number of newlines to pre- and append - * @param int $width Width of the line, defaults to 63 - * @return void - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#Shell::hr - */ - public function hr($newlines = 0, $width = 63) - { - $this->_io->hr($newlines, $width); - } - - /** - * Displays a formatted error message - * and exits the application with status code 1 - * - * @param string $message The error message - * @param int $exitCode The exit code for the shell task. - * @throws \Cake\Console\Exception\StopException - * @return void - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#styling-output - */ - public function abort($message, $exitCode = self::CODE_ERROR) - { - $this->_io->err('' . $message . ''); - throw new StopException($message, $exitCode); - } - - /** - * Displays a formatted error message - * and exits the application with status code 1 - * - * @param string $title Title of the error - * @param string|null $message An optional error message - * @param int $exitCode The exit code for the shell task. - * @throws \Cake\Console\Exception\StopException - * @return int Error code - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#styling-output - * @deprecated 3.2.0 Use Shell::abort() instead. - */ - public function error($title, $message = null, $exitCode = self::CODE_ERROR) - { - $this->_io->err(sprintf('Error: %s', $title)); - - if (!empty($message)) { - $this->_io->err($message); - } - - $this->_stop($exitCode); - - return $exitCode; - } - - /** - * Clear the console - * - * @return void - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#console-output - */ - public function clear() - { - if (empty($this->params['noclear'])) { - if (DIRECTORY_SEPARATOR === '/') { - passthru('clear'); - } else { - passthru('cls'); - } - } - } - - /** - * Creates a file at given path - * - * @param string $path Where to put the file. - * @param string $contents Content to put in the file. - * @return bool Success - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#creating-files - */ - public function createFile($path, $contents) - { - $path = str_replace(DIRECTORY_SEPARATOR . DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR, $path); - - $this->_io->out(); - - $fileExists = is_file($path); - if ($fileExists && empty($this->params['force']) && !$this->interactive) { - $this->_io->out('File exists, skipping.'); - - return false; - } - - if ($fileExists && $this->interactive && empty($this->params['force'])) { - $this->_io->out(sprintf('File `%s` exists', $path)); - $key = $this->_io->askChoice('Do you want to overwrite?', ['y', 'n', 'a', 'q'], 'n'); - - if (strtolower($key) === 'q') { - $this->_io->out('Quitting.', 2); - $this->_stop(); - - return false; - } - if (strtolower($key) === 'a') { - $this->params['force'] = true; - $key = 'y'; - } - if (strtolower($key) !== 'y') { - $this->_io->out(sprintf('Skip `%s`', $path), 2); - - return false; - } - } else { - $this->out(sprintf('Creating file %s', $path)); - } - - $File = new File($path, true); - - try { - if ($File->exists() && $File->writable()) { - $File->write($contents); - $this->_io->out(sprintf('Wrote `%s`', $path)); - - return true; - } - - $this->_io->err(sprintf('Could not write to `%s`.', $path), 2); - - return false; - } finally { - $File->close(); - } - } - - /** - * Makes absolute file path easier to read - * - * @param string $file Absolute file path - * @return string short path - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#Shell::shortPath - */ - public function shortPath($file) - { - $shortPath = str_replace(ROOT, null, $file); - $shortPath = str_replace('..' . DIRECTORY_SEPARATOR, '', $shortPath); - $shortPath = str_replace(DIRECTORY_SEPARATOR, '/', $shortPath); - - return str_replace('//', DIRECTORY_SEPARATOR, $shortPath); - } - - /** - * Render a Console Helper - * - * Create and render the output for a helper object. If the helper - * object has not already been loaded, it will be loaded and constructed. - * - * @param string $name The name of the helper to render - * @param array $settings Configuration data for the helper. - * @return \Cake\Console\Helper The created helper instance. - */ - public function helper($name, array $settings = []) - { - return $this->_io->helper($name, $settings); - } - - /** - * Stop execution of the current script. - * Raises a StopException to try and halt the execution. - * - * @param int|string $status see https://secure.php.net/exit for values - * @throws \Cake\Console\Exception\StopException - * @return void - */ - protected function _stop($status = self::CODE_SUCCESS) - { - throw new StopException('Halting error reached', $status); - } - - /** - * Returns an array that can be used to describe the internal state of this - * object. - * - * @return array - */ - public function __debugInfo() - { - return [ - 'name' => $this->name, - 'plugin' => $this->plugin, - 'command' => $this->command, - 'tasks' => $this->tasks, - 'params' => $this->params, - 'args' => $this->args, - 'interactive' => $this->interactive, - ]; - } -} diff --git a/src/Console/ShellDispatcher.php b/src/Console/ShellDispatcher.php deleted file mode 100644 index 03399ca561f..00000000000 --- a/src/Console/ShellDispatcher.php +++ /dev/null @@ -1,416 +0,0 @@ -args = (array)$args; - - $this->addShortPluginAliases(); - - if ($bootstrap) { - $this->_initEnvironment(); - } - } - - /** - * Add an alias for a shell command. - * - * Aliases allow you to call shells by alternate names. This is most - * useful when dealing with plugin shells that you want to have shorter - * names for. - * - * If you re-use an alias the last alias set will be the one available. - * - * ### Usage - * - * Aliasing a shell named ClassName: - * - * ``` - * $this->alias('alias', 'ClassName'); - * ``` - * - * Getting the original name for a given alias: - * - * ``` - * $this->alias('alias'); - * ``` - * - * @param string $short The new short name for the shell. - * @param string|null $original The original full name for the shell. - * @return string|false The aliased class name, or false if the alias does not exist - */ - public static function alias($short, $original = null) - { - $short = Inflector::camelize($short); - if ($original) { - static::$_aliases[$short] = $original; - } - - return isset(static::$_aliases[$short]) ? static::$_aliases[$short] : false; - } - - /** - * Clear any aliases that have been set. - * - * @return void - */ - public static function resetAliases() - { - static::$_aliases = []; - } - - /** - * Run the dispatcher - * - * @param array $argv The argv from PHP - * @param array $extra Extra parameters - * @return int The exit code of the shell process. - */ - public static function run($argv, $extra = []) - { - $dispatcher = new ShellDispatcher($argv); - - return $dispatcher->dispatch($extra); - } - - /** - * Defines current working environment. - * - * @return void - * @throws \Cake\Core\Exception\Exception - */ - protected function _initEnvironment() - { - if (!$this->_bootstrap()) { - $message = "Unable to load CakePHP core.\nMake sure Cake exists in " . CAKE_CORE_INCLUDE_PATH; - throw new Exception($message); - } - - if (function_exists('ini_set')) { - ini_set('html_errors', '0'); - ini_set('implicit_flush', '1'); - ini_set('max_execution_time', '0'); - } - - $this->shiftArgs(); - } - - /** - * Initializes the environment and loads the CakePHP core. - * - * @return bool Success. - */ - protected function _bootstrap() - { - if (!Configure::read('App.fullBaseUrl')) { - Configure::write('App.fullBaseUrl', 'http://localhost'); - } - - return true; - } - - /** - * Dispatches a CLI request - * - * Converts a shell command result into an exit code. Null/True - * are treated as success. All other return values are an error. - * - * @param array $extra Extra parameters that you can manually pass to the Shell - * to be dispatched. - * Built-in extra parameter is : - * - `requested` : if used, will prevent the Shell welcome message to be displayed - * @return int The cli command exit code. 0 is success. - */ - public function dispatch($extra = []) - { - try { - $result = $this->_dispatch($extra); - } catch (StopException $e) { - return $e->getCode(); - } - if ($result === null || $result === true) { - return Shell::CODE_SUCCESS; - } - if (is_int($result)) { - return $result; - } - - return Shell::CODE_ERROR; - } - - /** - * Dispatch a request. - * - * @param array $extra Extra parameters that you can manually pass to the Shell - * to be dispatched. - * Built-in extra parameter is : - * - `requested` : if used, will prevent the Shell welcome message to be displayed - * @return bool|int|null - * @throws \Cake\Console\Exception\MissingShellMethodException - */ - protected function _dispatch($extra = []) - { - $shell = $this->shiftArgs(); - - if (!$shell) { - $this->help(); - - return false; - } - if (in_array($shell, ['help', '--help', '-h'])) { - $this->help(); - - return true; - } - if (in_array($shell, ['version', '--version'])) { - $this->version(); - - return true; - } - - $Shell = $this->findShell($shell); - - $Shell->initialize(); - - return $Shell->runCommand($this->args, true, $extra); - } - - /** - * For all loaded plugins, add a short alias - * - * This permits a plugin which implements a shell of the same name to be accessed - * Using the shell name alone - * - * @return array the resultant list of aliases - */ - public function addShortPluginAliases() - { - $plugins = Plugin::loaded(); - - $io = new ConsoleIo(); - $task = new CommandTask($io); - $io->setLoggers(false); - $list = $task->getShellList() + ['app' => []]; - $fixed = array_flip($list['app']) + array_flip($list['CORE']); - $aliases = $others = []; - - foreach ($plugins as $plugin) { - if (!isset($list[$plugin])) { - continue; - } - - foreach ($list[$plugin] as $shell) { - $aliases += [$shell => $plugin]; - if (!isset($others[$shell])) { - $others[$shell] = [$plugin]; - } else { - $others[$shell] = array_merge($others[$shell], [$plugin]); - } - } - } - - foreach ($aliases as $shell => $plugin) { - if (isset($fixed[$shell])) { - Log::write( - 'debug', - "command '$shell' in plugin '$plugin' was not aliased, conflicts with another shell", - ['shell-dispatcher'] - ); - continue; - } - - $other = static::alias($shell); - if ($other) { - $other = $aliases[$shell]; - if ($other !== $plugin) { - Log::write( - 'debug', - "command '$shell' in plugin '$plugin' was not aliased, conflicts with '$other'", - ['shell-dispatcher'] - ); - } - continue; - } - - if (isset($others[$shell])) { - $conflicts = array_diff($others[$shell], [$plugin]); - if (count($conflicts) > 0) { - $conflictList = implode("', '", $conflicts); - Log::write( - 'debug', - "command '$shell' in plugin '$plugin' was not aliased, conflicts with '$conflictList'", - ['shell-dispatcher'] - ); - } - } - - static::alias($shell, "$plugin.$shell"); - } - - return static::$_aliases; - } - - /** - * Get shell to use, either plugin shell or application shell - * - * All paths in the loaded shell paths are searched, handles alias - * dereferencing - * - * @param string $shell Optionally the name of a plugin - * @return \Cake\Console\Shell A shell instance. - * @throws \Cake\Console\Exception\MissingShellException when errors are encountered. - */ - public function findShell($shell) - { - $className = $this->_shellExists($shell); - if (!$className) { - $shell = $this->_handleAlias($shell); - $className = $this->_shellExists($shell); - } - - if (!$className) { - throw new MissingShellException([ - 'class' => $shell, - ]); - } - - return $this->_createShell($className, $shell); - } - - /** - * If the input matches an alias, return the aliased shell name - * - * @param string $shell Optionally the name of a plugin or alias - * @return string Shell name with plugin prefix - */ - protected function _handleAlias($shell) - { - $aliased = static::alias($shell); - if ($aliased) { - $shell = $aliased; - } - - $class = array_map('Cake\Utility\Inflector::camelize', explode('.', $shell)); - - return implode('.', $class); - } - - /** - * Check if a shell class exists for the given name. - * - * @param string $shell The shell name to look for. - * @return string|bool Either the classname or false. - */ - protected function _shellExists($shell) - { - $class = App::className($shell, 'Shell', 'Shell'); - if (class_exists($class)) { - return $class; - } - - return false; - } - - /** - * Create the given shell name, and set the plugin property - * - * @param string $className The class name to instantiate - * @param string $shortName The plugin-prefixed shell name - * @return \Cake\Console\Shell A shell instance. - */ - protected function _createShell($className, $shortName) - { - list($plugin) = pluginSplit($shortName); - $instance = new $className(); - $instance->plugin = trim($plugin, '.'); - - return $instance; - } - - /** - * Removes first argument and shifts other arguments up - * - * @return mixed Null if there are no arguments otherwise the shifted argument - */ - public function shiftArgs() - { - return array_shift($this->args); - } - - /** - * Shows console help. Performs an internal dispatch to the CommandList Shell - * - * @return void - */ - public function help() - { - $this->args = array_merge(['command_list'], $this->args); - $this->dispatch(); - } - - /** - * Prints the currently installed version of CakePHP. Performs an internal dispatch to the CommandList Shell - * - * @return void - */ - public function version() - { - $this->args = array_merge(['command_list', '--version'], $this->args); - $this->dispatch(); - } -} diff --git a/src/Console/TaskRegistry.php b/src/Console/TaskRegistry.php deleted file mode 100644 index 1655d989a0d..00000000000 --- a/src/Console/TaskRegistry.php +++ /dev/null @@ -1,91 +0,0 @@ -_Shell = $Shell; - } - - /** - * Resolve a task classname. - * - * Part of the template method for Cake\Core\ObjectRegistry::load() - * - * @param string $class Partial classname to resolve. - * @return string|false Either the correct classname or false. - */ - protected function _resolveClassName($class) - { - return App::className($class, 'Shell/Task', 'Task'); - } - - /** - * Throws an exception when a task is missing. - * - * Part of the template method for Cake\Core\ObjectRegistry::load() - * and Cake\Core\ObjectRegistry::unload() - * - * @param string $class The classname that is missing. - * @param string $plugin The plugin the task is missing in. - * @return void - * @throws \Cake\Console\Exception\MissingTaskException - */ - protected function _throwMissingClassError($class, $plugin) - { - throw new MissingTaskException([ - 'class' => $class, - 'plugin' => $plugin - ]); - } - - /** - * Create the task instance. - * - * Part of the template method for Cake\Core\ObjectRegistry::load() - * - * @param string $class The classname to create. - * @param string $alias The alias of the task. - * @param array $settings An array of settings to use for the task. - * @return \Cake\Console\Shell The constructed task class. - */ - protected function _create($class, $alias, $settings) - { - return new $class($this->_Shell->getIo()); - } -} diff --git a/src/Console/TestSuite/ConsoleIntegrationTestTrait.php b/src/Console/TestSuite/ConsoleIntegrationTestTrait.php new file mode 100644 index 00000000000..5ab9b3e6759 --- /dev/null +++ b/src/Console/TestSuite/ConsoleIntegrationTestTrait.php @@ -0,0 +1,370 @@ +makeRunner(); + + $this->_out ??= new StubConsoleOutput(); + $this->_err ??= new StubConsoleOutput(); + if ($this->_in === null || $input) { + $this->_in = new StubConsoleInput($input); + } + $this->_out->clear(); + $this->_err->clear(); + + $args = $this->commandStringToArgs("cake {$command}"); + $io = new ConsoleIo($this->_out, $this->_err, $this->_in); + + try { + $this->_exitCode = $runner->run($args, $io); + } catch (MissingConsoleInputException $e) { + $messages = $this->_out->messages(); + if ($messages !== []) { + $e->setQuestion($messages[count($messages) - 1]); + } + throw $e; + } catch (StopException $exception) { + $this->_exitCode = $exception->getCode(); + } + } + + /** + * Cleans state to get ready for the next test + * + * @return void + */ + #[After] + public function cleanupConsoleTrait(): void + { + $this->_exitCode = null; + $this->_out = null; + $this->_err = null; + $this->_in = null; + } + + /** + * Asserts shell exited with the expected code + * + * @param int $expected Expected exit code + * @param string $message Failure message + * @return void + */ + public function assertExitCode(int $expected, string $message = ''): void + { + $this->assertThat( + $expected, + new ExitCode($this->_exitCode, $this->_out->messages(), $this->_err->messages()), + $message, + ); + } + + /** + * Asserts shell exited with the CommandInterface::CODE_SUCCESS + * + * @param string $message Failure message + * @return void + */ + public function assertExitSuccess(string $message = ''): void + { + $this->assertThat( + CommandInterface::CODE_SUCCESS, + new ExitCode($this->_exitCode, $this->_out->messages(), $this->_err->messages()), + $message, + ); + } + + /** + * Asserts shell exited with CommandInterface::CODE_ERROR + * + * @param string $message Failure message + * @return void + */ + public function assertExitError(string $message = ''): void + { + $this->assertThat( + CommandInterface::CODE_ERROR, + new ExitCode($this->_exitCode, $this->_out->messages(), $this->_err->messages()), + $message, + ); + } + + /** + * Asserts that `stdout` is empty + * + * @param string $message The message to output when the assertion fails. + * @return void + */ + public function assertOutputEmpty(string $message = ''): void + { + $this->assertThat(null, new ContentsEmpty($this->_out->messages(), 'output'), $message); + } + + /** + * Asserts `stdout` contains expected output + * + * @param string $expected Expected output + * @param string $message Failure message + * @return void + */ + public function assertOutputContains(string $expected, string $message = ''): void + { + $this->assertThat($expected, new ContentsContain($this->_out->messages(), 'output'), $message); + } + + /** + * Asserts `stdout` does not contain expected output + * + * @param string $expected Expected output + * @param string $message Failure message + * @return void + */ + public function assertOutputNotContains(string $expected, string $message = ''): void + { + $this->assertThat($expected, new ContentsNotContain($this->_out->messages(), 'output'), $message); + } + + /** + * Asserts `stdout` contains expected regexp + * + * @param string $pattern Expected pattern + * @param string $message Failure message + * @return void + */ + public function assertOutputRegExp(string $pattern, string $message = ''): void + { + $this->assertThat($pattern, new ContentsRegExp($this->_out->messages(), 'output'), $message); + } + + /** + * Check that a row of cells exists in the output. + * + * @param array $row Row of cells to ensure exist in the output. + * @param string $message Failure message. + * @return void + */ + protected function assertOutputContainsRow(array $row, string $message = ''): void + { + $this->assertThat($row, new ContentsContainRow($this->_out->messages(), 'output'), $message); + } + + /** + * Asserts `stderr` contains expected output + * + * @param string $expected Expected output + * @param string $message Failure message + * @return void + */ + public function assertErrorContains(string $expected, string $message = ''): void + { + $this->assertThat($expected, new ContentsContain($this->_err->messages(), 'error output'), $message); + } + + /** + * Asserts `stderr` contains expected regexp + * + * @param string $pattern Expected pattern + * @param string $message Failure message + * @return void + */ + public function assertErrorRegExp(string $pattern, string $message = ''): void + { + $this->assertThat($pattern, new ContentsRegExp($this->_err->messages(), 'error output'), $message); + } + + /** + * Asserts that `stderr` is empty + * + * @param string $message The message to output when the assertion fails. + * @return void + */ + public function assertErrorEmpty(string $message = ''): void + { + $this->assertThat(null, new ContentsEmpty($this->_err->messages(), 'error output'), $message); + } + + /** + * Dump the exit code, stdout and stderr from the most recently run command + * + * @param resource|null $stream The stream to write to. Defaults to STDOUT + * @return void + */ + public function debugOutput($stream = null): void + { + $output = new ConsoleOutput($stream ?? 'php://stdout'); + if (class_exists(Debugger::class)) { + $trace = Debugger::trace(['start' => 0, 'depth' => 1, 'format' => 'array']); + $file = $trace[0]['file']; + $line = $trace[0]['line']; + $output->write("{$file} on {$line}"); + } + $output->write('########## debugOutput() ##########'); + + if ($this->_exitCode !== null) { + $output->write('Exit Code'); + $output->write((string)$this->_exitCode, 2); + } + $output->write('STDOUT'); + $output->write($this->_out->messages(), 2); + + $output->write('STDERR'); + $output->write($this->_err->messages()); + $output->write('###################################'); + } + + /** + * Builds the appropriate command dispatcher + * + * @return \Cake\Console\CommandRunner + */ + protected function makeRunner(): CommandRunner + { + $app = $this->createApp(); + assert($app instanceof ConsoleApplicationInterface); + + return new CommandRunner($app); + } + + /** + * Creates an $argv array from a command string + * + * @param string $command Command string + * @return array + */ + protected function commandStringToArgs(string $command): array + { + $charCount = strlen($command); + $argv = []; + $arg = ''; + $inDQuote = false; + $inSQuote = false; + for ($i = 0; $i < $charCount; $i++) { + $char = substr($command, $i, 1); + + // end of argument + if ($char === ' ' && !$inDQuote && !$inSQuote) { + if ($arg !== '') { + $argv[] = $arg; + } + $arg = ''; + continue; + } + + // exiting single quote + if ($inSQuote && $char === "'") { + $inSQuote = false; + continue; + } + + // exiting double quote + if ($inDQuote && $char === '"') { + $inDQuote = false; + continue; + } + + // entering double quote + if ($char === '"' && !$inSQuote) { + $inDQuote = true; + continue; + } + + // entering single quote + if ($char === "'" && !$inDQuote) { + $inSQuote = true; + continue; + } + + $arg .= $char; + } + $argv[] = $arg; + + return $argv; + } +} + +// phpcs:disable +class_alias( + 'Cake\Console\TestSuite\ConsoleIntegrationTestTrait', + 'Cake\TestSuite\ConsoleIntegrationTestTrait' +); +// phpcs:enable diff --git a/src/Console/TestSuite/Constraint/ContentsBase.php b/src/Console/TestSuite/Constraint/ContentsBase.php new file mode 100644 index 00000000000..6078b7b5cd6 --- /dev/null +++ b/src/Console/TestSuite/Constraint/ContentsBase.php @@ -0,0 +1,55 @@ + $contents Contents + * @param string $output Output type + */ + public function __construct(array $contents, string $output) + { + $this->contents = implode(PHP_EOL, $contents); + $this->output = $output; + } +} + +// phpcs:disable +class_alias( + 'Cake\Console\TestSuite\Constraint\ContentsBase', + 'Cake\TestSuite\Constraint\Console\ContentsBase' +); +// phpcs:enable diff --git a/src/Console/TestSuite/Constraint/ContentsContain.php b/src/Console/TestSuite/Constraint/ContentsContain.php new file mode 100644 index 00000000000..c6b4cf0fddb --- /dev/null +++ b/src/Console/TestSuite/Constraint/ContentsContain.php @@ -0,0 +1,60 @@ +contents, $other) !== false; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return sprintf('is in %s.', $this->output); + } + + /** + * @inheritDoc + */ + protected function additionalFailureDescription(mixed $other): string + { + return sprintf("actual result:\n%s", $this->contents); + } +} + +// phpcs:disable +class_alias( + 'Cake\Console\TestSuite\Constraint\ContentsContain', + 'Cake\TestSuite\Constraint\Console\ContentsContain' +); +// phpcs:enable diff --git a/src/Console/TestSuite/Constraint/ContentsContainRow.php b/src/Console/TestSuite/Constraint/ContentsContainRow.php new file mode 100644 index 00000000000..29531acc026 --- /dev/null +++ b/src/Console/TestSuite/Constraint/ContentsContainRow.php @@ -0,0 +1,69 @@ +contents) > 0; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return sprintf('row was in %s', $this->output); + } + + /** + * @param mixed $other Expected content + * @return string + */ + public function failureDescription(mixed $other): string + { + return '`' . (new Exporter())->shortenedExport($other) . '` ' . $this->toString(); + } +} + +// phpcs:disable +class_alias( + 'Cake\Console\TestSuite\Constraint\ContentsContainRow', + 'Cake\TestSuite\Constraint\Console\ContentsContainRow' +); +// phpcs:enable diff --git a/src/Console/TestSuite/Constraint/ContentsEmpty.php b/src/Console/TestSuite/Constraint/ContentsEmpty.php new file mode 100644 index 00000000000..324338c709b --- /dev/null +++ b/src/Console/TestSuite/Constraint/ContentsEmpty.php @@ -0,0 +1,71 @@ +contents === ''; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return sprintf('%s is empty.', $this->output); + } + + /** + * Overwrites the descriptions so we can remove the automatic "expected" message + * + * @param mixed $other Value + * @return string + */ + protected function failureDescription(mixed $other): string + { + return $this->toString(); + } + + /** + * @inheritDoc + */ + protected function additionalFailureDescription(mixed $other): string + { + return sprintf("actual result:\n%s", $this->contents); + } +} + +// phpcs:disable +class_alias( + 'Cake\Console\TestSuite\Constraint\ContentsEmpty', + 'Cake\TestSuite\Constraint\Console\ContentsEmpty' +); +// phpcs:enable diff --git a/src/Console/TestSuite/Constraint/ContentsNotContain.php b/src/Console/TestSuite/Constraint/ContentsNotContain.php new file mode 100644 index 00000000000..4417fc134aa --- /dev/null +++ b/src/Console/TestSuite/Constraint/ContentsNotContain.php @@ -0,0 +1,60 @@ +contents, $other) === false; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return sprintf('is not in %s.', $this->output); + } + + /** + * @inheritDoc + */ + protected function additionalFailureDescription(mixed $other): string + { + return sprintf("actual result:\n%s", $this->contents); + } +} + +// phpcs:disable +class_alias( + 'Cake\Console\TestSuite\Constraint\ContentsNotContain', + 'Cake\TestSuite\Constraint\Console\ContentsNotContain' +); +// phpcs:enable diff --git a/src/Console/TestSuite/Constraint/ContentsRegExp.php b/src/Console/TestSuite/Constraint/ContentsRegExp.php new file mode 100644 index 00000000000..1ac3241119e --- /dev/null +++ b/src/Console/TestSuite/Constraint/ContentsRegExp.php @@ -0,0 +1,69 @@ +contents) > 0; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return sprintf('PCRE pattern found in %s', $this->output); + } + + /** + * @param mixed $other Expected + * @return string + */ + public function failureDescription(mixed $other): string + { + return '`' . $other . '` ' . $this->toString(); + } + + /** + * @inheritDoc + */ + protected function additionalFailureDescription(mixed $other): string + { + return sprintf("actual result:\n%s", $this->contents); + } +} + +// phpcs:disable +class_alias( + 'Cake\Console\TestSuite\Constraint\ContentsRegExp', + 'Cake\TestSuite\Constraint\Console\ContentsRegExp' +); +// phpcs:enable diff --git a/src/Console/TestSuite/Constraint/ExitCode.php b/src/Console/TestSuite/Constraint/ExitCode.php new file mode 100644 index 00000000000..1fda9964403 --- /dev/null +++ b/src/Console/TestSuite/Constraint/ExitCode.php @@ -0,0 +1,106 @@ +exitCode = $exitCode; + $this->out = $out; + $this->err = $err; + } + + /** + * Checks if event is in fired array + * + * @param mixed $other Constraint check + * @return bool + */ + public function matches(mixed $other): bool + { + return $other === $this->exitCode; + } + + /** + * Assertion message string + * + * @return string + */ + public function toString(): string + { + return sprintf('matches exit code `%s`', $this->exitCode ?? 'null'); + } + + /** + * Returns the description of the failure. + * + * @param mixed $other Expected + * @return string + */ + public function failureDescription(mixed $other): string + { + return '`' . $other . '` ' . $this->toString(); + } + + /** + * @inheritDoc + */ + public function additionalFailureDescription(mixed $other): string + { + return sprintf( + "STDOUT\n%s\n\nSTDERR\n%s\n", + implode("\n", $this->out), + implode("\n", $this->err), + ); + } +} + +// phpcs:disable +class_alias( + 'Cake\Console\TestSuite\Constraint\ExitCode', + 'Cake\TestSuite\Constraint\Console\ExitCode' +); +// phpcs:enable diff --git a/src/Console/TestSuite/MissingConsoleInputException.php b/src/Console/TestSuite/MissingConsoleInputException.php new file mode 100644 index 00000000000..fdd6b8c6cd0 --- /dev/null +++ b/src/Console/TestSuite/MissingConsoleInputException.php @@ -0,0 +1,42 @@ +message .= "\nThe question asked was: " . $question; + } +} + +// phpcs:disable +class_alias( + 'Cake\Console\TestSuite\MissingConsoleInputException', + 'Cake\TestSuite\Stub\MissingConsoleInputException' +); +// phpcs:enable diff --git a/src/Console/TestSuite/StubConsoleInput.php b/src/Console/TestSuite/StubConsoleInput.php new file mode 100644 index 00000000000..ab8213f489d --- /dev/null +++ b/src/Console/TestSuite/StubConsoleInput.php @@ -0,0 +1,96 @@ + + */ + protected array $replies = []; + + /** + * Current message index + * + * @var int + */ + protected int $currentIndex = -1; + + /** + * Constructor + * + * @param array $replies A list of replies for read() + */ + public function __construct(array $replies) + { + // Don't call parent on purpose as it opens php://stdin which doesn't + // always exist in RunInSeparateProcess tests. + $this->replies = $replies; + $this->_canReadline = false; + } + + /** + * Read a reply + * + * @return string The value of the reply + */ + public function read(): string + { + $this->currentIndex += 1; + + if (!isset($this->replies[$this->currentIndex])) { + $total = count($this->replies); + $formatter = new NumberFormatter('en', NumberFormatter::ORDINAL); + $nth = $formatter->format($this->currentIndex + 1); + + $replies = implode(', ', $this->replies); + $message = "There are no more input replies available. This is the {$nth} read operation, " . + "only {$total} replies were set.\nThe provided replies are: {$replies}"; + throw new MissingConsoleInputException($message); + } + + return $this->replies[$this->currentIndex]; + } + + /** + * Check if data is available on stdin + * + * @param int $timeout An optional time to wait for data + * @return bool True for data available, false otherwise + */ + public function dataAvailable(int $timeout = 0): bool + { + return true; + } +} + +// phpcs:disable +class_alias( + 'Cake\Console\TestSuite\StubConsoleInput', + 'Cake\TestSuite\Stub\ConsoleInput' +); +// phpcs:enable diff --git a/src/Console/TestSuite/StubConsoleOutput.php b/src/Console/TestSuite/StubConsoleOutput.php new file mode 100644 index 00000000000..2c7b8cd38a1 --- /dev/null +++ b/src/Console/TestSuite/StubConsoleOutput.php @@ -0,0 +1,111 @@ + + */ + protected array $_out = []; + + /** + * Constructor + */ + public function __construct() + { + // Don't call parent on purpose as it opens php://stdin which doesn't + // always exist in RunInSeparateProcess tests. + $this->_outputAs = self::PLAIN; + } + + /** + * Write output to the buffer. + * + * @param array|string $message A string or an array of strings to output + * @param int $newlines Number of newlines to append + * @return int + */ + public function write(array|string $message, int $newlines = 1): int + { + foreach ((array)$message as $line) { + $this->_out[] = $line; + } + + $newlines--; + while ($newlines > 0) { + $this->_out[] = ''; + $newlines--; + } + + return 0; + } + + /** + * Get the buffered output. + * + * @return array + */ + public function messages(): array + { + return $this->_out; + } + + /** + * Clear buffered output + * + * @return void + */ + public function clear(): void + { + $this->_out = []; + } + + /** + * Get the output as a string + * + * @return string + */ + public function output(): string + { + return implode("\n", $this->_out); + } +} + +// phpcs:disable +class_alias( + 'Cake\Console\TestSuite\StubConsoleOutput', + 'Cake\TestSuite\Stub\ConsoleOutput' +); +// phpcs:enable diff --git a/src/Console/composer.json b/src/Console/composer.json new file mode 100644 index 00000000000..ed995528d1e --- /dev/null +++ b/src/Console/composer.json @@ -0,0 +1,48 @@ +{ + "name": "cakephp/console", + "description": "Build beautiful console applications with CakePHP", + "type": "library", + "keywords": [ + "cakephp", + "console", + "cli", + "framework" + ], + "homepage": "https://cakephp.org", + "license": "MIT", + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/cache/graphs/contributors" + } + ], + "support": { + "issues": "https://github.com/cakephp/cakephp/issues", + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "source": "https://github.com/cakephp/console" + }, + "require": { + "php": ">=8.2", + "cakephp/core": "^5.4.0", + "cakephp/event": "^5.4.0", + "cakephp/log": "^5.4.0", + "cakephp/utility": "^5.4.0" + }, + "suggest": { + "cakephp/datasource": "To use the Command base classes", + "cakephp/orm": "To use the Command base classes" + }, + "autoload": { + "psr-4": { + "Cake\\Console\\": "." + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" + } + } +} diff --git a/src/Container/Argument/ArgumentInterface.php b/src/Container/Argument/ArgumentInterface.php new file mode 100644 index 00000000000..ab592c43524 --- /dev/null +++ b/src/Container/Argument/ArgumentInterface.php @@ -0,0 +1,12 @@ + $arguments + * @return array + */ + public function resolveArguments(array $arguments): array; + + /** + * @param \ReflectionFunctionAbstract $method + * @param array $args + * @return array + */ + public function reflectArguments(ReflectionFunctionAbstract $method, array $args = []): array; +} diff --git a/src/Container/Argument/ArgumentResolverTrait.php b/src/Container/Argument/ArgumentResolverTrait.php new file mode 100644 index 00000000000..049c9061ea5 --- /dev/null +++ b/src/Container/Argument/ArgumentResolverTrait.php @@ -0,0 +1,120 @@ +getContainer(); + } catch (ContainerException) { + $container = $this instanceof ReflectionContainer ? $this : null; + } + + foreach ($arguments as &$arg) { + // if we have a literal, we don't want to do anything more with it + if ($arg instanceof LiteralArgumentInterface) { + $arg = $arg->getValue(); + continue; + } + + if ($arg instanceof ArgumentInterface) { + $argValue = $arg->getValue(); + } else { + $argValue = $arg; + } + + if (!is_string($argValue)) { + continue; + } + + // resolve the argument from the container, if it happens to be another + // argument wrapper, use that value + if ($container instanceof ContainerInterface && $container->has($argValue)) { + try { + $arg = $container->get($argValue); + + if ($arg instanceof ArgumentInterface) { + $arg = $arg->getValue(); + } + + continue; + } catch (NotFoundException) { + } + } + + // if we have a default value, we use that, no more resolution as + // we expect a default/optional argument value to be literal + if ($arg instanceof DefaultValueInterface) { + $arg = $arg->getDefaultValue(); + } + } + + return $arguments; + } + + /** + * @inheritDoc + */ + public function reflectArguments(ReflectionFunctionAbstract $method, array $args = []): array + { + $params = $method->getParameters(); + $arguments = []; + + foreach ($params as $param) { + $name = $param->getName(); + + // if we've been given a value for the argument, treat as literal + if (array_key_exists($name, $args)) { + $arguments[] = new LiteralArgument($args[$name]); + continue; + } + + $type = $param->getType(); + + if ($type instanceof ReflectionNamedType) { + // in PHP 8, nullable arguments have "?" prefix + $typeHint = ltrim($type->getName(), '?'); + + if ($param->isDefaultValueAvailable()) { + $arguments[] = new DefaultValueArgument($typeHint, $param->getDefaultValue()); + continue; + } + + $arguments[] = new ResolvableArgument($typeHint); + continue; + } + + if ($param->isDefaultValueAvailable()) { + $arguments[] = new LiteralArgument($param->getDefaultValue()); + continue; + } + + throw new NotFoundException(sprintf( + 'Unable to resolve a value for parameter (%s) in the function/method (%s)', + $name, + $method->getName(), + )); + } + + return $this->resolveArguments($arguments); + } + + /** + * @inheritDoc + */ + abstract public function getContainer(): DefinitionContainerInterface; +} diff --git a/src/Container/Argument/DefaultValueArgument.php b/src/Container/Argument/DefaultValueArgument.php new file mode 100644 index 00000000000..8616c870c80 --- /dev/null +++ b/src/Container/Argument/DefaultValueArgument.php @@ -0,0 +1,27 @@ +defaultValue = $defaultValue; + parent::__construct($value); + } + + /** + * @inheritDoc + */ + public function getDefaultValue(): mixed + { + return $this->defaultValue; + } +} diff --git a/src/Container/Argument/DefaultValueInterface.php b/src/Container/Argument/DefaultValueInterface.php new file mode 100644 index 00000000000..bfc764060b9 --- /dev/null +++ b/src/Container/Argument/DefaultValueInterface.php @@ -0,0 +1,12 @@ +value = $value; + } else { + throw new InvalidArgumentException('Incorrect type for value.'); + } + } + + /** + * @inheritDoc + */ + public function getValue(): mixed + { + return $this->value; + } +} diff --git a/src/Container/Argument/LiteralArgumentInterface.php b/src/Container/Argument/LiteralArgumentInterface.php new file mode 100644 index 00000000000..a0238d0a6b0 --- /dev/null +++ b/src/Container/Argument/LiteralArgumentInterface.php @@ -0,0 +1,8 @@ +value = $value; + } + + /** + * @return string + */ + public function getValue(): string + { + return $this->value; + } +} diff --git a/src/Container/Argument/ResolvableArgumentInterface.php b/src/Container/Argument/ResolvableArgumentInterface.php new file mode 100644 index 00000000000..741e5864512 --- /dev/null +++ b/src/Container/Argument/ResolvableArgumentInterface.php @@ -0,0 +1,12 @@ + + */ + protected array $delegates = []; + + /** + * @param \Cake\Container\Definition\DefinitionAggregateInterface $definitions + * @param \Cake\Container\ServiceProvider\ServiceProviderAggregateInterface $providers + * @param \Cake\Container\Inflector\InflectorAggregateInterface $inflectors + */ + public function __construct( + protected DefinitionAggregateInterface $definitions = new DefinitionAggregate(), + protected ServiceProviderAggregateInterface $providers = new ServiceProviderAggregate(), + protected InflectorAggregateInterface $inflectors = new InflectorAggregate(), + ) { + $this->definitions->setContainer($this); + $this->providers->setContainer($this); + $this->inflectors->setContainer($this); + + $this->enableAutoWiring(); + } + + /** + * @inheritDoc + */ + public function add(string $id, $concrete = null): DefinitionInterface + { + $concrete ??= $id; + + if ($this->defaultToShared) { + return $this->addShared($id, $concrete); + } + + return $this->definitions->add($id, $concrete); + } + + /** + * @inheritDoc + */ + public function addShared(string $id, $concrete = null): DefinitionInterface + { + $concrete ??= $id; + + return $this->definitions->addShared($id, $concrete); + } + + /** + * Add multiple definitions at once. + * + * Examples: + * + * ``` + * $container->addDefinitions([ + * Foo::class, + * Bar::class + * ]); + * ``` + * + * ``` + * $container->addDefinitions([ + * Foo::class => [Bar::class], + * Bar::class + * ]); + * ``` + * + * ``` + * $container->addDefinitions([ + * 'foo' => Foo::class, + * 'bar' => Bar::class + * ]); + * ``` + * + * @param array|class-string> $definitions + * @return \Cake\Container\DefinitionContainerInterface + */ + public function addDefinitions(array $definitions): DefinitionContainerInterface + { + foreach ($definitions as $id => $definition) { + if (is_int($id) && is_string($definition)) { + $this->add($definition); + } elseif (is_string($id) && is_string($definition)) { + $this->add($id, $definition); + } elseif (is_string($id) && is_array($definition)) { // @phpstan-ignore-line + $this->add($id) + ->addArguments($definition); + } + } + + return $this; + } + + /** + * @param bool $shared + * @return \Psr\Container\ContainerInterface + */ + public function defaultToShared(bool $shared = true): ContainerInterface + { + $this->defaultToShared = $shared; + + return $this; + } + + /** + * @inheritDoc + */ + public function extend(string $id): DefinitionInterface + { + if ($this->providers->provides($id)) { + $this->providers->register($id); + } + + if ($this->definitions->has($id)) { + return $this->definitions->getDefinition($id); + } + + throw new NotFoundException(sprintf( + 'Unable to extend alias (%s) as it is not being managed as a definition', + $id, + )); + } + + /** + * @inheritDoc + */ + public function addServiceProvider(ServiceProviderInterface $provider): DefinitionContainerInterface + { + $this->providers->add($provider); + + return $this; + } + + /** + * @template RequestedType + * @param class-string|string $id + * @return RequestedType|mixed + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + public function get(string $id) + { + return $this->resolve($id); + } + + /** + * @template RequestedType + * @param class-string|string $id + * @return RequestedType|mixed + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + public function getNew(mixed $id): mixed + { + return $this->resolve($id, true); + } + + /** + * Resolve an entry with specific constructor arguments. + * + * Unlike `get()`, this method allows passing specific constructor arguments + * that will be used during autowiring. Arguments can be passed by name. + * + * Example: + * ``` + * $container->make(MyService::class, ['configValue' => 'foo']); + * ``` + * + * @template RequestedType + * @param class-string|string $id + * @param array $args Named arguments to pass to the constructor + * @return RequestedType|mixed + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + public function make(string $id, array $args = []): mixed + { + return $this->resolve($id, true, $args); + } + + /** + * @inheritDoc + */ + public function has($id): bool + { + if ($this->definitions->has($id)) { + return true; + } + + if ($this->definitions->hasTag($id)) { + return true; + } + + if ($this->providers->provides($id)) { + return true; + } + + foreach ($this->delegates as $delegate) { + if ($delegate->has($id)) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function hasDefinition(string $id): bool + { + return $this->definitions->has($id); + } + + /** + * @inheritDoc + */ + public function inflector(string $type, ?callable $callback = null): InflectorInterface + { + return $this->inflectors->add($type, $callback); + } + + /** + * @param \Psr\Container\ContainerInterface $container + * @return $this + */ + public function delegate(ContainerInterface $container) + { + $this->delegates[] = $container; + + if ($container instanceof ContainerAwareInterface) { + $container->setContainer($this); + } + + return $this; + } + + /** + * @param bool $cache + * @return void + */ + public function enableAutoWiring(bool $cache = true): void + { + $this->delegate(new ReflectionContainer($cache)); + } + + /** + * @return void + */ + public function disableAutoWiring(): void + { + $this->delegates = []; + } + + /** + * @param mixed $id + * @param bool $new + * @param array $args + * @return mixed|object|array|null|void + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + */ + protected function resolve(mixed $id, bool $new = false, array $args = []): mixed + { + if ($this->definitions->has($id)) { + $resolved = $new ? $this->definitions->resolveNew($id) : $this->definitions->resolve($id); + + return $this->inflectors->inflect($resolved); + } + + if ($this->definitions->hasTag($id)) { + $arrayOf = $new + ? $this->definitions->resolveTaggedNew($id) + : $this->definitions->resolveTagged($id); + + array_walk($arrayOf, function (object &$resolved): void { + $resolved = $this->inflectors->inflect($resolved); + }); + + return $arrayOf; + } + + if ($this->providers->provides($id)) { + $this->providers->register($id); + + if (!$this->definitions->has($id) && !$this->definitions->hasTag($id)) { + throw new ContainerException(sprintf('Service provider lied about providing (%s) service', $id)); + } + + return $this->resolve($id, $new, $args); + } + + foreach ($this->delegates as $delegate) { + if ($delegate->has($id)) { + // Use getNew() for ReflectionContainer when $new is true or args are provided + if ($delegate instanceof ReflectionContainer) { + $resolved = $new || $args !== [] + ? $delegate->getNew($id, $args) + : $delegate->get($id, $args); + } else { + $resolved = $delegate->get($id); + } + + return $this->inflectors->inflect($resolved); + } + } + + throw new NotFoundException(sprintf('Alias (%s) is not being managed by the container or delegates', $id)); + } +} diff --git a/src/Container/ContainerAwareInterface.php b/src/Container/ContainerAwareInterface.php new file mode 100644 index 00000000000..4817cc83ed7 --- /dev/null +++ b/src/Container/ContainerAwareInterface.php @@ -0,0 +1,18 @@ +container = $container; + + if ($this instanceof ContainerAwareInterface) { + return $this; + } + + throw new BadMethodCallException(sprintf( + 'Attempt to use (%s) while not implementing (%s)', + ContainerAwareTrait::class, + ContainerAwareInterface::class, + )); + } + + /** + * @inheritDoc + */ + public function getContainer(): DefinitionContainerInterface + { + if ($this->container instanceof DefinitionContainerInterface) { + return $this->container; + } + + throw new ContainerException('No container implementation has been set.'); + } +} diff --git a/src/Container/Definition/Definition.php b/src/Container/Definition/Definition.php new file mode 100644 index 00000000000..ebf7f2b7410 --- /dev/null +++ b/src/Container/Definition/Definition.php @@ -0,0 +1,344 @@ +alias = $id; + $this->concrete = $concrete; + } + + /** + * @inheritDoc + */ + public function addTag(string $tag): DefinitionInterface + { + $this->tags[$tag] = true; + + return $this; + } + + /** + * @inheritDoc + */ + public function getTags(): array + { + return array_keys($this->tags); + } + + /** + * @inheritDoc + */ + public function hasTag(string $tag): bool + { + return isset($this->tags[$tag]); + } + + /** + * @inheritDoc + */ + public function setAlias(string $id): DefinitionInterface + { + $id = static::normaliseAlias($id); + + $this->alias = $id; + + return $this; + } + + /** + * @inheritDoc + */ + public function getAlias(): string + { + return $this->alias; + } + + /** + * @inheritDoc + */ + public function setShared(bool $shared = true): DefinitionInterface + { + $this->shared = $shared; + + return $this; + } + + /** + * @inheritDoc + */ + public function isShared(): bool + { + return $this->shared; + } + + /** + * @inheritDoc + */ + public function getConcrete(): mixed + { + return $this->concrete; + } + + /** + * @inheritDoc + */ + public function setConcrete($concrete): DefinitionInterface + { + $this->concrete = $concrete; + $this->resolved = null; + + return $this; + } + + /** + * @inheritDoc + */ + public function addArgument($arg, ?string $name = null): DefinitionInterface + { + if ($name) { + $this->arguments[$name] = $arg; + } else { + $this->arguments[] = $arg; + } + + return $this; + } + + /** + * @inheritDoc + */ + public function addArguments(array $args): DefinitionInterface + { + foreach ($args as $argName => $arg) { + if (is_string($argName)) { + $this->addArgument($arg, $argName); + } else { + $this->addArgument($arg); + } + } + + return $this; + } + + /** + * @inheritDoc + */ + public function addMethodCall(string $method, array $args = []): DefinitionInterface + { + $this->methods[] = [ + 'method' => $method, + 'arguments' => $args, + ]; + + return $this; + } + + /** + * @inheritDoc + */ + public function addMethodCalls(array $methods = []): DefinitionInterface + { + foreach ($methods as $method => $args) { + $this->addMethodCall($method, $args); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function resolve(): mixed + { + if ($this->resolved !== null && $this->isShared()) { + return $this->resolved; + } + + return $this->resolveNew(); + } + + /** + * @inheritDoc + */ + public function resolveNew(): mixed + { + $concrete = $this->concrete; + + if (is_callable($concrete)) { + $concrete = $this->resolveCallable($concrete); + } + + if ($concrete instanceof LiteralArgumentInterface) { + $this->resolved = $concrete->getValue(); + + return $concrete->getValue(); + } + + if ($concrete instanceof ArgumentInterface) { + $concrete = $concrete->getValue(); + } + + // Check if the container has a registered definition for this concrete class + // before attempting to instantiate it directly. This ensures interface -> concrete + // bindings respect existing definitions for the concrete class (fixes #275, #278). + try { + $container = $this->getContainer(); + } catch (ContainerException) { + $container = null; + } + + if ( + is_string($concrete) + && $concrete !== $this->alias + && $container !== null + && $container->hasDefinition($concrete) + ) { + $this->recursiveCheck[] = $concrete; + $concrete = $container->get($concrete); + $this->resolved = $concrete; + + return $concrete; + } + + if (is_string($concrete) && class_exists($concrete)) { + $concrete = $this->resolveClass($concrete); + } + + if (is_object($concrete)) { + $concrete = $this->invokeMethods($concrete); + } + + // stop recursive resolving + if (is_string($concrete) && in_array($concrete, $this->recursiveCheck)) { + $this->resolved = $concrete; + + return $concrete; + } + + // if we still have a string, try to pull it from the container + // this allows for `alias -> alias -> ... -> concrete + if (is_string($concrete) && $container !== null && $container->has($concrete)) { + $this->recursiveCheck[] = $concrete; + $concrete = $container->get($concrete); + } + + $this->resolved = $concrete; + + return $concrete; + } + + /** + * @param callable $concrete + * @return mixed + */ + protected function resolveCallable(callable $concrete): mixed + { + $resolved = $this->resolveArguments($this->arguments); + + return call_user_func_array($concrete, $resolved); + } + + /** + * @param class-string $concrete + * @return object + * @throws \ReflectionException + */ + protected function resolveClass(string $concrete): object + { + $resolved = $this->resolveArguments($this->arguments); + $reflection = new ReflectionClass($concrete); + + return $reflection->newInstanceArgs($resolved); + } + + /** + * @param object $instance + * @return object + */ + protected function invokeMethods(object $instance): object + { + foreach ($this->methods as $method) { + $args = $this->resolveArguments($method['arguments']); + /** @var callable $callable */ + $callable = [$instance, $method['method']]; + call_user_func_array($callable, $args); + } + + return $instance; + } + + /** + * @param string $alias + * @return string + */ + public static function normaliseAlias(string $alias): string + { + if (str_starts_with($alias, '\\')) { + return substr($alias, 1); + } + + return $alias; + } +} diff --git a/src/Container/Definition/DefinitionAggregate.php b/src/Container/Definition/DefinitionAggregate.php new file mode 100644 index 00000000000..947ba50649c --- /dev/null +++ b/src/Container/Definition/DefinitionAggregate.php @@ -0,0 +1,147 @@ + + */ + protected array $definitions = []; + + /** + * @param array $definitions + */ + public function __construct(array $definitions = []) + { + foreach ($definitions as $definition) { + if ($definition instanceof DefinitionInterface) { + $this->definitions[$definition->getAlias()] = $definition; + } + } + } + + /** + * @inheritDoc + */ + public function add(string $id, $definition): DefinitionInterface + { + if (!($definition instanceof DefinitionInterface)) { + $definition = new Definition($id, $definition); + } + + $definition = $definition->setAlias($id); + $this->definitions[$definition->getAlias()] = $definition; + + return $definition; + } + + /** + * @inheritDoc + */ + public function addShared(string $id, $definition): DefinitionInterface + { + $definition = $this->add($id, $definition); + + return $definition->setShared(true); + } + + /** + * @inheritDoc + */ + public function has(string $id): bool + { + return isset($this->definitions[Definition::normaliseAlias($id)]); + } + + /** + * @inheritDoc + */ + public function hasTag(string $tag): bool + { + foreach ($this->getIterator() as $definition) { + if ($definition->hasTag($tag)) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function getDefinition(string $id): DefinitionInterface + { + $id = Definition::normaliseAlias($id); + + if (!isset($this->definitions[$id])) { + throw new NotFoundException(sprintf('Alias (%s) is not being handled as a definition.', $id)); + } + + return $this->definitions[$id]->setContainer($this->getContainer()); + } + + /** + * @inheritDoc + */ + public function resolve(string $id): mixed + { + return $this->getDefinition($id)->resolve(); + } + + /** + * @inheritDoc + */ + public function resolveNew(string $id): mixed + { + return $this->getDefinition($id)->resolveNew(); + } + + /** + * @inheritDoc + */ + public function resolveTagged(string $tag): array + { + $arrayOf = []; + + foreach ($this->getIterator() as $definition) { + if ($definition->hasTag($tag)) { + $arrayOf[] = $definition->setContainer($this->getContainer())->resolve(); + } + } + + return $arrayOf; + } + + /** + * @inheritDoc + */ + public function resolveTaggedNew(string $tag): array + { + $arrayOf = []; + + foreach ($this->getIterator() as $definition) { + if ($definition->hasTag($tag)) { + $arrayOf[] = $definition->setContainer($this->getContainer())->resolveNew(); + } + } + + return $arrayOf; + } + + /** + * @return \Generator + */ + public function getIterator(): Generator + { + yield from $this->definitions; + } +} diff --git a/src/Container/Definition/DefinitionAggregateInterface.php b/src/Container/Definition/DefinitionAggregateInterface.php new file mode 100644 index 00000000000..cb0dd079da0 --- /dev/null +++ b/src/Container/Definition/DefinitionAggregateInterface.php @@ -0,0 +1,69 @@ + + */ +interface DefinitionAggregateInterface extends ContainerAwareInterface, IteratorAggregate +{ + /** + * @param string $id + * @param mixed $definition + * @return \Cake\Container\Definition\DefinitionInterface + */ + public function add(string $id, mixed $definition): DefinitionInterface; + + /** + * @param string $id + * @param mixed $definition + * @return \Cake\Container\Definition\DefinitionInterface + */ + public function addShared(string $id, mixed $definition): DefinitionInterface; + + /** + * @param string $id + * @return \Cake\Container\Definition\DefinitionInterface + */ + public function getDefinition(string $id): DefinitionInterface; + + /** + * @param string $id + * @return bool + */ + public function has(string $id): bool; + + /** + * @param string $tag + * @return bool + */ + public function hasTag(string $tag): bool; + + /** + * @param string $id + * @return mixed + */ + public function resolve(string $id): mixed; + + /** + * @param string $id + * @return mixed + */ + public function resolveNew(string $id): mixed; + + /** + * @param string $tag + * @return array + */ + public function resolveTagged(string $tag): array; + + /** + * @param string $tag + * @return array + */ + public function resolveTaggedNew(string $tag): array; +} diff --git a/src/Container/Definition/DefinitionInterface.php b/src/Container/Definition/DefinitionInterface.php new file mode 100644 index 00000000000..e3996d8b33c --- /dev/null +++ b/src/Container/Definition/DefinitionInterface.php @@ -0,0 +1,95 @@ + + */ + public function getTags(): array; + + /** + * @param string $tag + * @return bool + */ + public function hasTag(string $tag): bool; + + /** + * @return bool + */ + public function isShared(): bool; + + /** + * @return mixed + */ + public function resolve(): mixed; + + /** + * @return mixed + */ + public function resolveNew(): mixed; + + /** + * @param string $id + * @return $this + */ + public function setAlias(string $id): DefinitionInterface; + + /** + * @param mixed $concrete + * @return $this + */ + public function setConcrete(mixed $concrete): DefinitionInterface; + + /** + * @param bool $shared + * @return $this + */ + public function setShared(bool $shared): DefinitionInterface; +} diff --git a/src/Container/DefinitionContainerInterface.php b/src/Container/DefinitionContainerInterface.php new file mode 100644 index 00000000000..3600e9b76c4 --- /dev/null +++ b/src/Container/DefinitionContainerInterface.php @@ -0,0 +1,75 @@ + Foo::class]` - alias as key, class as value + * - `[Foo::class => [Bar::class]]` - class with constructor arguments + * + * @param array|class-string> $definitions + * @return self + */ + public function addDefinitions(array $definitions): self; + + /** + * @param \Cake\Container\ServiceProvider\ServiceProviderInterface $provider + * @return self + */ + public function addServiceProvider(ServiceProviderInterface $provider): self; + + /** + * @param string $id + * @param mixed $concrete + * @return \Cake\Container\Definition\DefinitionInterface + */ + public function addShared(string $id, mixed $concrete = null): DefinitionInterface; + + /** + * @param string $id + * @return \Cake\Container\Definition\DefinitionInterface + */ + public function extend(string $id): DefinitionInterface; + + /** + * @param mixed $id + * @return mixed + */ + public function getNew(mixed $id): mixed; + + /** + * Check if the container has a registered definition for the given id. + * + * Unlike `has()`, this only checks explicit definitions, not service providers + * or delegate containers. + * + * @param string $id + * @return bool + */ + public function hasDefinition(string $id): bool; + + /** + * @param string $type + * @param callable|null $callback + * @return \Cake\Container\Inflector\InflectorInterface + */ + public function inflector(string $type, ?callable $callback = null): InflectorInterface; +} diff --git a/src/Container/Exception/ContainerException.php b/src/Container/Exception/ContainerException.php new file mode 100644 index 00000000000..3dc2c3200c5 --- /dev/null +++ b/src/Container/Exception/ContainerException.php @@ -0,0 +1,11 @@ +type = $type; + $this->callback = $callback; + } + + /** + * @inheritDoc + */ + public function getType(): string + { + return $this->type; + } + + /** + * @inheritDoc + */ + public function invokeMethod(string $name, array $args): InflectorInterface + { + $this->methods[$name] = $args; + + return $this; + } + + /** + * @inheritDoc + */ + public function invokeMethods(array $methods): InflectorInterface + { + foreach ($methods as $name => $args) { + $this->invokeMethod($name, $args); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function setProperty(string $property, $value): InflectorInterface + { + $this->properties[$property] = $this->resolveArguments([$value])[0]; + + return $this; + } + + /** + * @inheritDoc + */ + public function setProperties(array $properties): InflectorInterface + { + foreach ($properties as $property => $value) { + $this->setProperty($property, $value); + } + + return $this; + } + + /** + * @inheritDoc + */ + public function inflect(object $object): void + { + $properties = $this->resolveArguments(array_values($this->properties)); + $properties = array_combine(array_keys($this->properties), $properties); + + // array_combine() can technically return false + foreach ($properties ?: [] as $property => $value) { + $object->{$property} = $value; + } + + foreach ($this->methods as $method => $args) { + $args = $this->resolveArguments($args); + /** @var callable $callable */ + $callable = [$object, $method]; + call_user_func_array($callable, $args); + } + + if ($this->callback !== null) { + call_user_func($this->callback, $object); + } + } +} diff --git a/src/Container/Inflector/InflectorAggregate.php b/src/Container/Inflector/InflectorAggregate.php new file mode 100644 index 00000000000..118973dd162 --- /dev/null +++ b/src/Container/Inflector/InflectorAggregate.php @@ -0,0 +1,53 @@ + + */ + protected array $inflectors = []; + + /** + * @inheritDoc + */ + public function add(string $type, ?callable $callback = null): Inflector + { + $inflector = new Inflector($type, $callback); + $this->inflectors[] = $inflector; + + return $inflector; + } + + /** + * @inheritDoc + */ + public function inflect($object): mixed + { + foreach ($this->getIterator() as $inflector) { + $type = $inflector->getType(); + + if ($object instanceof $type) { + $inflector->setContainer($this->getContainer()); + $inflector->inflect($object); + } + } + + return $object; + } + + /** + * @inheritDoc + */ + public function getIterator(): Generator + { + yield from $this->inflectors; + } +} diff --git a/src/Container/Inflector/InflectorAggregateInterface.php b/src/Container/Inflector/InflectorAggregateInterface.php new file mode 100644 index 00000000000..f80d97b641a --- /dev/null +++ b/src/Container/Inflector/InflectorAggregateInterface.php @@ -0,0 +1,26 @@ + + */ +interface InflectorAggregateInterface extends ContainerAwareInterface, IteratorAggregate +{ + /** + * @param string $type + * @param callable|null $callback + * @return \Cake\Container\Inflector\Inflector + */ + public function add(string $type, ?callable $callback = null): Inflector; + + /** + * @param object $object + * @return mixed + */ + public function inflect(object $object): mixed; +} diff --git a/src/Container/Inflector/InflectorInterface.php b/src/Container/Inflector/InflectorInterface.php new file mode 100644 index 00000000000..c954e72a64e --- /dev/null +++ b/src/Container/Inflector/InflectorInterface.php @@ -0,0 +1,44 @@ + + +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. diff --git a/src/Container/README.md b/src/Container/README.md new file mode 100644 index 00000000000..53140838af1 --- /dev/null +++ b/src/Container/README.md @@ -0,0 +1,40 @@ +[![Total Downloads](https://img.shields.io/packagist/dt/cakephp/container.svg?style=flat-square)](https://packagist.org/packages/cakephp/container) +[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE.txt) + +# CakePHP Container Library + +The Container library provides a PSR-11 compatible dependency injection container +for defining application services and resolving their dependencies. + +## Usage + +Services can be defined with concrete classes, shared instances, factory +functions, or interface mappings: + +```php +use App\Service\AuditLogService; +use App\Service\AuditLogServiceInterface; +use App\Service\ApiClient; +use App\Service\BillingService; +use Cake\Container\Container; + +$container = new Container(); + +// Add a concrete class. +$container->add(BillingService::class); + +// Add a singleton service. +$container->addShared('apiClient', fn () => new ApiClient('https://example.com')); + +// Add an implementation for an interface. +$container->add(AuditLogServiceInterface::class, AuditLogService::class); + +$billing = $container->get(BillingService::class); +``` + +Definitions can also have arguments, tags, and service providers attached when +your application needs more control over how objects are built. + +## Documentation + +Please make sure you check the [official documentation](https://book.cakephp.org/5/en/development/dependency-injection.html) diff --git a/src/Container/ReflectionContainer.php b/src/Container/ReflectionContainer.php new file mode 100644 index 00000000000..864b621acd3 --- /dev/null +++ b/src/Container/ReflectionContainer.php @@ -0,0 +1,164 @@ +cacheResolutions = $cacheResolutions; + } + + /** + * @inheritDoc + */ + public function get(string $id, array $args = []) + { + // Only use cache when no custom args are provided + if ($this->cacheResolutions && $args === [] && array_key_exists($id, $this->cache)) { + return $this->cache[$id]; + } + + if (!$this->has($id)) { + throw new NotFoundException( + sprintf('Alias (%s) is not an existing class and therefore cannot be resolved', $id), + ); + } + + /** @var class-string $id */ + $reflector = new ReflectionClass($id); + $construct = $reflector->getConstructor(); + + if ($construct && !$construct->isPublic()) { + throw new NotFoundException( + sprintf('Alias (%s) has a non-public constructor and therefore cannot be instantiated', $id), + ); + } + + $resolution = $construct === null + ? new $id() + : $reflector->newInstanceArgs($this->reflectArguments($construct, $args)); + + // Only cache when no custom args are provided + if ($this->cacheResolutions && $args === []) { + $this->cache[$id] = $resolution; + } + + return $resolution; + } + + /** + * @inheritDoc + */ + public function has($id): bool + { + return class_exists($id); + } + + /** + * Get a new instance, bypassing the cache. + * + * @param string $id + * @param array $args + * @return mixed + */ + public function getNew(string $id, array $args = []): mixed + { + if (!$this->has($id)) { + throw new NotFoundException( + sprintf('Alias (%s) is not an existing class and therefore cannot be resolved', $id), + ); + } + + /** @var class-string $id */ + $reflector = new ReflectionClass($id); + $construct = $reflector->getConstructor(); + + if ($construct && !$construct->isPublic()) { + throw new NotFoundException( + sprintf('Alias (%s) has a non-public constructor and therefore cannot be instantiated', $id), + ); + } + + return $construct === null + ? new $id() + : $reflector->newInstanceArgs($this->reflectArguments($construct, $args)); + } + + /** + * @param callable|string $callable + * @param array $args + * @return mixed + * @throws \Psr\Container\ContainerExceptionInterface + * @throws \Psr\Container\NotFoundExceptionInterface + * @throws \ReflectionException + */ + public function call(callable|string $callable, array $args = []): mixed + { + if (is_string($callable) && str_contains($callable, '::')) { + $callable = explode('::', $callable); + } + + if (is_array($callable)) { + if (is_string($callable[0])) { + // if we have a definition container, try that first, otherwise, reflect + try { + $callable[0] = $this->getContainer()->get($callable[0]); + } catch (ContainerException) { + $callable[0] = $this->get($callable[0]); + } + } + + $reflection = new ReflectionMethod($callable[0], $callable[1]); + + if ($reflection->isStatic()) { + $callable[0] = null; + } + + return $reflection->invokeArgs($callable[0], $this->reflectArguments($reflection, $args)); + } + + if (is_object($callable)) { + $reflection = new ReflectionMethod($callable, '__invoke'); + + return $reflection->invokeArgs($callable, $this->reflectArguments($reflection, $args)); + } + + if (is_callable($callable)) { + $reflection = new ReflectionFunction($callable(...)); + + return $reflection->invokeArgs($this->reflectArguments($reflection, $args)); + } + + throw new NotFoundException(sprintf( + 'Callable (%s) is not a valid callable', + $callable, + )); + } +} diff --git a/src/Container/ServiceProvider/AbstractServiceProvider.php b/src/Container/ServiceProvider/AbstractServiceProvider.php new file mode 100644 index 00000000000..3afb809fdb4 --- /dev/null +++ b/src/Container/ServiceProvider/AbstractServiceProvider.php @@ -0,0 +1,34 @@ +identifier ?? static::class; + } + + /** + * @inheritDoc + */ + public function setIdentifier(string $id): ServiceProviderInterface + { + $this->identifier = $id; + + return $this; + } +} diff --git a/src/Container/ServiceProvider/BootableServiceProviderInterface.php b/src/Container/ServiceProvider/BootableServiceProviderInterface.php new file mode 100644 index 00000000000..7c8e71936a1 --- /dev/null +++ b/src/Container/ServiceProvider/BootableServiceProviderInterface.php @@ -0,0 +1,15 @@ + + */ + protected array $providers = []; + + /** + * @var array + */ + protected array $registered = []; + + /** + * @inheritDoc + */ + public function add(ServiceProviderInterface $provider): ServiceProviderAggregateInterface + { + if (in_array($provider, $this->providers, true)) { + return $this; + } + + $provider->setContainer($this->getContainer()); + + if ($provider instanceof BootableServiceProviderInterface) { + $provider->boot(); + } + + $this->providers[] = $provider; + + return $this; + } + + /** + * @inheritDoc + */ + public function provides(string $id): bool + { + foreach ($this->getIterator() as $provider) { + if ($provider->provides($id)) { + return true; + } + } + + return false; + } + + /** + * @inheritDoc + */ + public function getIterator(): Generator + { + yield from $this->providers; + } + + /** + * @inheritDoc + */ + public function register(string $service): void + { + if ($this->provides($service) === false) { + throw new ContainerException( + sprintf('(%s) is not provided by a service provider', $service), + ); + } + + foreach ($this->getIterator() as $provider) { + if (in_array($provider->getIdentifier(), $this->registered, true)) { + continue; + } + + if ($provider->provides($service)) { + $provider->register(); + $this->registered[] = $provider->getIdentifier(); + } + } + } +} diff --git a/src/Container/ServiceProvider/ServiceProviderAggregateInterface.php b/src/Container/ServiceProvider/ServiceProviderAggregateInterface.php new file mode 100644 index 00000000000..d4bdcfb8adc --- /dev/null +++ b/src/Container/ServiceProvider/ServiceProviderAggregateInterface.php @@ -0,0 +1,31 @@ + + */ +interface ServiceProviderAggregateInterface extends ContainerAwareInterface, IteratorAggregate +{ + /** + * @param \Cake\Container\ServiceProvider\ServiceProviderInterface $provider + * @return $this + */ + public function add(ServiceProviderInterface $provider): ServiceProviderAggregateInterface; + + /** + * @param string $id + * @return bool + */ + public function provides(string $id): bool; + + /** + * @param string $service + * @return void + */ + public function register(string $service): void; +} diff --git a/src/Container/ServiceProvider/ServiceProviderInterface.php b/src/Container/ServiceProvider/ServiceProviderInterface.php new file mode 100644 index 00000000000..39dd8fe276f --- /dev/null +++ b/src/Container/ServiceProvider/ServiceProviderInterface.php @@ -0,0 +1,31 @@ +=8.2", + "psr/container": "^1.1 || ^2.0" + }, + "provide": { + "psr/container-implementation": "^1.0" + }, + "autoload": { + "psr-4": { + "Cake\\Container\\": "." + } + } +} diff --git a/src/Controller/Attribute/Enum/RequestToDtoSource.php b/src/Controller/Attribute/Enum/RequestToDtoSource.php new file mode 100644 index 00000000000..4dc8293ec81 --- /dev/null +++ b/src/Controller/Attribute/Enum/RequestToDtoSource.php @@ -0,0 +1,15 @@ +class; + if ($dtoClass === null) { + $type = $parameter->getType(); + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $dtoClass = $type->getName(); + } + } + + if ($dtoClass === null || !class_exists($dtoClass)) { + throw new InvalidParameterException([ + 'template' => 'missing_dependency', + 'parameter' => $parameter->getName(), + 'type' => $dtoClass ?? 'Dto', + ]); + } + + if (!method_exists($dtoClass, 'createFromArray')) { + throw new InvalidParameterException([ + 'template' => 'missing_dependency', + 'parameter' => $parameter->getName(), + 'type' => $dtoClass, + ]); + } + + /** @var class-string $dtoClass */ + return $dtoClass::createFromArray($this->extractData($request)); + } + + /** + * Extract data from request based on source. + * + * @param \Cake\Http\ServerRequest $request The server request + * @return array + */ + protected function extractData(ServerRequest $request): array + { + return match ($this->source) { + RequestToDtoSource::Body => (array)$request->getData(), + RequestToDtoSource::Query => $request->getQueryParams(), + RequestToDtoSource::Request => array_merge( + $request->getQueryParams(), + (array)$request->getData(), + ), + RequestToDtoSource::Auto => $this->extractAutoData($request), + }; + } + + /** + * Auto-detect data source based on request method. + * + * @param \Cake\Http\ServerRequest $request The server request + * @return array + */ + protected function extractAutoData(ServerRequest $request): array + { + if ($request->is(['get', 'head'])) { + return $request->getQueryParams(); + } + + $data = (array)$request->getData(); + if ($data !== []) { + return $data; + } + + return $request->getQueryParams(); + } +} diff --git a/src/Controller/Component.php b/src/Controller/Component.php index 408d18c2e2e..8b67f44a272 100644 --- a/src/Controller/Component.php +++ b/src/Controller/Component.php @@ -1,4 +1,6 @@ getSubject() to access the controller & request instead. - */ - public $request; - - /** - * Response object - * - * @var \Cake\Http\Response - * @deprecated 3.4.0 Storing references to the response is deprecated. Use Component::getController() - * or callback $event->getSubject() to access the controller & response instead. - */ - public $response; - /** * Component registry class used to lazy load components. * * @var \Cake\Controller\ComponentRegistry */ - protected $_registry; + protected ComponentRegistry $_registry; /** * Other Components this component uses. * - * @var array + * @var array> */ - public $components = []; + protected array $components = []; /** * Default config * * These are merged with user-provided config when the component is used. * - * @var array + * @var array */ - protected $_defaultConfig = []; + protected array $_defaultConfig = []; /** - * A component lookup table used to lazy load component objects. + * Loaded component instances. * - * @var array + * @var array */ - protected $_componentMap = []; + protected array $componentInstances = []; /** * Constructor * - * @param \Cake\Controller\ComponentRegistry $registry A ComponentRegistry this component can use to lazy load its components - * @param array $config Array of configuration settings. + * @param \Cake\Controller\ComponentRegistry $registry A component registry + * this component can use to lazy load its components. + * @param array $config Array of configuration settings. */ public function __construct(ComponentRegistry $registry, array $config = []) { $this->_registry = $registry; - $controller = $registry->getController(); - if ($controller) { - $this->request =& $controller->request; - $this->response =& $controller->response; - } $this->setConfig($config); if ($this->components) { - $this->_componentMap = $registry->normalizeArray($this->components); + $this->components = $registry->normalizeArray($this->components); } $this->initialize($config); } @@ -139,7 +117,7 @@ public function __construct(ComponentRegistry $registry, array $config = []) * * @return \Cake\Controller\Controller The bound controller. */ - public function getController() + public function getController(): Controller { return $this->_registry->getController(); } @@ -150,10 +128,10 @@ public function getController() * Implement this method to avoid having to overwrite * the constructor and call parent. * - * @param array $config The configuration settings provided to this component. + * @param array $config The configuration settings provided to this component. * @return void */ - public function initialize(array $config) + public function initialize(array $config): void { } @@ -161,19 +139,24 @@ public function initialize(array $config) * Magic method for lazy loading $components. * * @param string $name Name of component to get. - * @return mixed A Component object or null. + * @return \Cake\Controller\Component|null A Component object or null. */ - public function __get($name) + public function __get(string $name): ?Component { - if (isset($this->_componentMap[$name]) && !isset($this->{$name})) { - $config = (array)$this->_componentMap[$name]['config'] + ['enabled' => false]; - $this->{$name} = $this->_registry->load($this->_componentMap[$name]['class'], $config); + if (isset($this->componentInstances[$name])) { + return $this->componentInstances[$name]; } - if (!isset($this->{$name})) { - return null; + + if (isset($this->components[$name])) { + $config = $this->components[$name] + ['enabled' => false]; + + return $this->componentInstances[$name] = $this->_registry->load( + $name, + $config, + ); } - return $this->{$name}; + return null; } /** @@ -186,16 +169,16 @@ public function __get($name) * Override this method if you need to add non-conventional event listeners. * Or if you want components to listen to non-standard events. * - * @return array + * @return array */ - public function implementedEvents() + public function implementedEvents(): array { $eventMap = [ 'Controller.initialize' => 'beforeFilter', 'Controller.startup' => 'startup', 'Controller.beforeRender' => 'beforeRender', 'Controller.beforeRedirect' => 'beforeRedirect', - 'Controller.shutdown' => 'shutdown', + 'Controller.shutdown' => 'afterFilter', ]; $events = []; foreach ($eventMap as $event => $method) { @@ -211,9 +194,9 @@ public function implementedEvents() * Returns an array that can be used to describe the internal state of this * object. * - * @return array + * @return array */ - public function __debugInfo() + public function __debugInfo(): array { return [ 'components' => $this->components, diff --git a/src/Controller/Component/AuthComponent.php b/src/Controller/Component/AuthComponent.php deleted file mode 100644 index c20961c8a27..00000000000 --- a/src/Controller/Component/AuthComponent.php +++ /dev/null @@ -1,1025 +0,0 @@ -Auth->setConfig('authenticate', [ - * 'Form' => [ - * 'userModel' => 'Users.Users' - * ] - * ]); - * ``` - * - * Using the class name without 'Authenticate' as the key, you can pass in an - * array of config for each authentication object. Additionally you can define - * config that should be set to all authentications objects using the 'all' key: - * - * ``` - * $this->Auth->setConfig('authenticate', [ - * AuthComponent::ALL => [ - * 'userModel' => 'Users.Users', - * 'scope' => ['Users.active' => 1] - * ], - * 'Form', - * 'Basic' - * ]); - * ``` - * - * - `authorize` - An array of authorization objects to use for authorizing users. - * You can configure multiple adapters and they will be checked sequentially - * when authorization checks are done. - * - * ``` - * $this->Auth->setConfig('authorize', [ - * 'Crud' => [ - * 'actionPath' => 'controllers/' - * ] - * ]); - * ``` - * - * Using the class name without 'Authorize' as the key, you can pass in an array - * of config for each authorization object. Additionally you can define config - * that should be set to all authorization objects using the AuthComponent::ALL key: - * - * ``` - * $this->Auth->setConfig('authorize', [ - * AuthComponent::ALL => [ - * 'actionPath' => 'controllers/' - * ], - * 'Crud', - * 'CustomAuth' - * ]); - * ``` - * - * - ~~`ajaxLogin`~~ - The name of an optional view element to render when an Ajax - * request is made with an invalid or expired session. - * **This option is deprecated since 3.3.6.** Your client side code should - * instead check for 403 status code and show appropriate login form. - * - * - `flash` - Settings to use when Auth needs to do a flash message with - * FlashComponent::set(). Available keys are: - * - * - `key` - The message domain to use for flashes generated by this component, - * defaults to 'auth'. - * - `element` - Flash element to use, defaults to 'default'. - * - `params` - The array of additional params to use, defaults to ['class' => 'error'] - * - * - `loginAction` - A URL (defined as a string or array) to the controller action - * that handles logins. Defaults to `/users/login`. - * - * - `loginRedirect` - Normally, if a user is redirected to the `loginAction` page, - * the location they were redirected from will be stored in the session so that - * they can be redirected back after a successful login. If this session value - * is not set, redirectUrl() method will return the URL specified in `loginRedirect`. - * - * - `logoutRedirect` - The default action to redirect to after the user is logged out. - * While AuthComponent does not handle post-logout redirection, a redirect URL - * will be returned from `AuthComponent::logout()`. Defaults to `loginAction`. - * - * - `authError` - Error to display when user attempts to access an object or - * action to which they do not have access. - * - * - `unauthorizedRedirect` - Controls handling of unauthorized access. - * - * - For default value `true` unauthorized user is redirected to the referrer URL - * or `$loginRedirect` or '/'. - * - If set to a string or array the value is used as a URL to redirect to. - * - If set to false a `ForbiddenException` exception is thrown instead of redirecting. - * - * - `storage` - Storage class to use for persisting user record. When using - * stateless authenticator you should set this to 'Memory'. Defaults to 'Session'. - * - * - `checkAuthIn` - Name of event for which initial auth checks should be done. - * Defaults to 'Controller.startup'. You can set it to 'Controller.initialize' - * if you want the check to be done before controller's beforeFilter() is run. - * - * @var array - */ - protected $_defaultConfig = [ - 'authenticate' => null, - 'authorize' => null, - 'ajaxLogin' => null, - 'flash' => null, - 'loginAction' => null, - 'loginRedirect' => null, - 'logoutRedirect' => null, - 'authError' => null, - 'unauthorizedRedirect' => true, - 'storage' => 'Session', - 'checkAuthIn' => 'Controller.startup' - ]; - - /** - * Other components utilized by AuthComponent - * - * @var array - */ - public $components = ['RequestHandler', 'Flash']; - - /** - * Objects that will be used for authentication checks. - * - * @var \Cake\Auth\BaseAuthenticate[] - */ - protected $_authenticateObjects = []; - - /** - * Objects that will be used for authorization checks. - * - * @var \Cake\Auth\BaseAuthorize[] - */ - protected $_authorizeObjects = []; - - /** - * Storage object. - * - * @var \Cake\Auth\Storage\StorageInterface|null - */ - protected $_storage; - - /** - * Controller actions for which user validation is not required. - * - * @var array - * @see \Cake\Controller\Component\AuthComponent::allow() - */ - public $allowedActions = []; - - /** - * Request object - * - * @var \Cake\Http\ServerRequest - */ - public $request; - - /** - * Response object - * - * @var \Cake\Http\Response - */ - public $response; - - /** - * Instance of the Session object - * - * @var \Cake\Network\Session - * @deprecated 3.1.0 Will be removed in 4.0 - */ - public $session; - - /** - * The instance of the Authenticate provider that was used for - * successfully logging in the current user after calling `login()` - * in the same request - * - * @var \Cake\Auth\BaseAuthenticate - */ - protected $_authenticationProvider; - - /** - * The instance of the Authorize provider that was used to grant - * access to the current user to the URL they are requesting. - * - * @var \Cake\Auth\BaseAuthorize - */ - protected $_authorizationProvider; - - /** - * Initialize properties. - * - * @param array $config The config data. - * @return void - */ - public function initialize(array $config) - { - $controller = $this->_registry->getController(); - $this->setEventManager($controller->getEventManager()); - $this->response =& $controller->response; - $this->session = $controller->request->getSession(); - } - - /** - * Callback for Controller.startup event. - * - * @param \Cake\Event\Event $event Event instance. - * @return \Cake\Http\Response|null - */ - public function startup(Event $event) - { - return $this->authCheck($event); - } - - /** - * Main execution method, handles initial authentication check and redirection - * of invalid users. - * - * The auth check is done when event name is same as the one configured in - * `checkAuthIn` config. - * - * @param \Cake\Event\Event $event Event instance. - * @return \Cake\Http\Response|null - */ - public function authCheck(Event $event) - { - if ($this->_config['checkAuthIn'] !== $event->getName()) { - return null; - } - - /** @var \Cake\Controller\Controller $controller */ - $controller = $event->getSubject(); - - $action = strtolower($controller->request->getParam('action')); - if (!$controller->isAction($action)) { - return null; - } - - $this->_setDefaults(); - - if ($this->_isAllowed($controller)) { - return null; - } - - $isLoginAction = $this->_isLoginAction($controller); - - if (!$this->_getUser()) { - if ($isLoginAction) { - return null; - } - $result = $this->_unauthenticated($controller); - if ($result instanceof Response) { - $event->stopPropagation(); - } - - return $result; - } - - if ($isLoginAction || - empty($this->_config['authorize']) || - $this->isAuthorized($this->user()) - ) { - return null; - } - - $event->stopPropagation(); - - return $this->_unauthorized($controller); - } - - /** - * Events supported by this component. - * - * @return array - */ - public function implementedEvents() - { - return [ - 'Controller.initialize' => 'authCheck', - 'Controller.startup' => 'startup', - ]; - } - - /** - * Checks whether current action is accessible without authentication. - * - * @param \Cake\Controller\Controller $controller A reference to the instantiating - * controller object - * @return bool True if action is accessible without authentication else false - */ - protected function _isAllowed(Controller $controller) - { - $action = strtolower($controller->request->getParam('action')); - - return in_array($action, array_map('strtolower', $this->allowedActions)); - } - - /** - * Handles unauthenticated access attempt. First the `unauthenticated()` method - * of the last authenticator in the chain will be called. The authenticator can - * handle sending response or redirection as appropriate and return `true` to - * indicate no further action is necessary. If authenticator returns null this - * method redirects user to login action. If it's an AJAX request and config - * `ajaxLogin` is specified that element is rendered else a 403 HTTP status code - * is returned. - * - * @param \Cake\Controller\Controller $controller A reference to the controller object. - * @return \Cake\Http\Response|null Null if current action is login action - * else response object returned by authenticate object or Controller::redirect(). - * @throws \Cake\Core\Exception\Exception - */ - protected function _unauthenticated(Controller $controller) - { - if (empty($this->_authenticateObjects)) { - $this->constructAuthenticate(); - } - $response = $this->response; - $auth = end($this->_authenticateObjects); - if ($auth === false) { - throw new Exception('At least one authenticate object must be available.'); - } - $result = $auth->unauthenticated($this->request, $response); - if ($result !== null) { - return $result; - } - - if (!$controller->request->is('ajax')) { - $this->flash($this->_config['authError']); - - return $controller->redirect($this->_loginActionRedirectUrl()); - } - - if (!empty($this->_config['ajaxLogin'])) { - $controller->viewBuilder()->setTemplatePath('Element'); - $response = $controller->render( - $this->_config['ajaxLogin'], - $this->RequestHandler->ajaxLayout - ); - - return $response->withStatus(403); - } - - return $response->withStatus(403); - } - - /** - * Returns the URL of the login action to redirect to. - * - * This includes the redirect query string if applicable. - * - * @return array|string - */ - protected function _loginActionRedirectUrl() - { - $urlToRedirectBackTo = $this->_getUrlToRedirectBackTo(); - - $loginAction = $this->_config['loginAction']; - if ($urlToRedirectBackTo === '/') { - return $loginAction; - } - - if (is_array($loginAction)) { - $loginAction['?'][static::QUERY_STRING_REDIRECT] = $urlToRedirectBackTo; - } else { - $char = strpos($loginAction, '?') === false ? '?' : '&'; - $loginAction .= $char . static::QUERY_STRING_REDIRECT . '=' . urlencode($urlToRedirectBackTo); - } - - return $loginAction; - } - - /** - * Normalizes config `loginAction` and checks if current request URL is same as login action. - * - * @param \Cake\Controller\Controller $controller A reference to the controller object. - * @return bool True if current action is login action else false. - */ - protected function _isLoginAction(Controller $controller) - { - $url = ''; - if (isset($controller->request->url)) { - $url = $controller->request->url; - } - $url = Router::normalize($url); - $loginAction = Router::normalize($this->_config['loginAction']); - - return $loginAction === $url; - } - - /** - * Handle unauthorized access attempt - * - * @param \Cake\Controller\Controller $controller A reference to the controller object - * @return \Cake\Http\Response - * @throws \Cake\Network\Exception\ForbiddenException - */ - protected function _unauthorized(Controller $controller) - { - if ($this->_config['unauthorizedRedirect'] === false) { - throw new ForbiddenException($this->_config['authError']); - } - - $this->flash($this->_config['authError']); - if ($this->_config['unauthorizedRedirect'] === true) { - $default = '/'; - if (!empty($this->_config['loginRedirect'])) { - $default = $this->_config['loginRedirect']; - } - if (is_array($default)) { - $default['_base'] = false; - } - $url = $controller->referer($default, true); - } else { - $url = $this->_config['unauthorizedRedirect']; - } - - return $controller->redirect($url); - } - - /** - * Sets defaults for configs. - * - * @return void - */ - protected function _setDefaults() - { - $defaults = [ - 'authenticate' => ['Form'], - 'flash' => [ - 'element' => 'error', - 'key' => 'flash', - 'params' => ['class' => 'error'] - ], - 'loginAction' => [ - 'controller' => 'Users', - 'action' => 'login', - 'plugin' => null - ], - 'logoutRedirect' => $this->_config['loginAction'], - 'authError' => __d('cake', 'You are not authorized to access that location.') - ]; - - $config = $this->getConfig(); - foreach ($config as $key => $value) { - if ($value !== null) { - unset($defaults[$key]); - } - } - $this->setConfig($defaults); - } - - /** - * Check if the provided user is authorized for the request. - * - * Uses the configured Authorization adapters to check whether or not a user is authorized. - * Each adapter will be checked in sequence, if any of them return true, then the user will - * be authorized for the request. - * - * @param array|\ArrayAccess|null $user The user to check the authorization of. - * If empty the user fetched from storage will be used. - * @param \Cake\Http\ServerRequest|null $request The request to authenticate for. - * If empty, the current request will be used. - * @return bool True if $user is authorized, otherwise false - */ - public function isAuthorized($user = null, ServerRequest $request = null) - { - if (empty($user) && !$this->user()) { - return false; - } - if (empty($user)) { - $user = $this->user(); - } - if (empty($request)) { - $request = $this->request; - } - if (empty($this->_authorizeObjects)) { - $this->constructAuthorize(); - } - foreach ($this->_authorizeObjects as $authorizer) { - if ($authorizer->authorize($user, $request) === true) { - $this->_authorizationProvider = $authorizer; - - return true; - } - } - - return false; - } - - /** - * Loads the authorization objects configured. - * - * @return array|null The loaded authorization objects, or null when authorize is empty. - * @throws \Cake\Core\Exception\Exception - */ - public function constructAuthorize() - { - if (empty($this->_config['authorize'])) { - return null; - } - $this->_authorizeObjects = []; - $authorize = Hash::normalize((array)$this->_config['authorize']); - $global = []; - if (isset($authorize[AuthComponent::ALL])) { - $global = $authorize[AuthComponent::ALL]; - unset($authorize[AuthComponent::ALL]); - } - foreach ($authorize as $alias => $config) { - if (!empty($config['className'])) { - $class = $config['className']; - unset($config['className']); - } else { - $class = $alias; - } - $className = App::className($class, 'Auth', 'Authorize'); - if (!class_exists($className)) { - throw new Exception(sprintf('Authorization adapter "%s" was not found.', $class)); - } - if (!method_exists($className, 'authorize')) { - throw new Exception('Authorization objects must implement an authorize() method.'); - } - $config = (array)$config + $global; - $this->_authorizeObjects[$alias] = new $className($this->_registry, $config); - } - - return $this->_authorizeObjects; - } - - /** - * Getter for authorize objects. Will return a particular authorize object. - * - * @param string $alias Alias for the authorize object - * @return \Cake\Auth\BaseAuthorize|null - */ - public function getAuthorize($alias) - { - if (empty($this->_authorizeObjects)) { - $this->constructAuthorize(); - } - - return isset($this->_authorizeObjects[$alias]) ? $this->_authorizeObjects[$alias] : null; - } - - /** - * Takes a list of actions in the current controller for which authentication is not required, or - * no parameters to allow all actions. - * - * You can use allow with either an array or a simple string. - * - * ``` - * $this->Auth->allow('view'); - * $this->Auth->allow(['edit', 'add']); - * ``` - * or to allow all actions - * ``` - * $this->Auth->allow(); - * ``` - * - * @param string|array|null $actions Controller action name or array of actions - * @return void - * @link https://book.cakephp.org/3.0/en/controllers/components/authentication.html#making-actions-public - */ - public function allow($actions = null) - { - if ($actions === null) { - $controller = $this->_registry->getController(); - $this->allowedActions = get_class_methods($controller); - - return; - } - $this->allowedActions = array_merge($this->allowedActions, (array)$actions); - } - - /** - * Removes items from the list of allowed/no authentication required actions. - * - * You can use deny with either an array or a simple string. - * - * ``` - * $this->Auth->deny('view'); - * $this->Auth->deny(['edit', 'add']); - * ``` - * or - * ``` - * $this->Auth->deny(); - * ``` - * to remove all items from the allowed list - * - * @param string|array|null $actions Controller action name or array of actions - * @return void - * @see \Cake\Controller\Component\AuthComponent::allow() - * @link https://book.cakephp.org/3.0/en/controllers/components/authentication.html#making-actions-require-authorization - */ - public function deny($actions = null) - { - if ($actions === null) { - $this->allowedActions = []; - - return; - } - foreach ((array)$actions as $action) { - $i = array_search($action, $this->allowedActions); - if (is_int($i)) { - unset($this->allowedActions[$i]); - } - } - $this->allowedActions = array_values($this->allowedActions); - } - - /** - * Set provided user info to storage as logged in user. - * - * The storage class is configured using `storage` config key or passing - * instance to AuthComponent::storage(). - * - * @param array|\ArrayAccess $user User data. - * @return void - * @link https://book.cakephp.org/3.0/en/controllers/components/authentication.html#identifying-users-and-logging-them-in - */ - public function setUser($user) - { - $this->storage()->write($user); - } - - /** - * Log a user out. - * - * Returns the logout action to redirect to. Triggers the `Auth.logout` event - * which the authenticate classes can listen for and perform custom logout logic. - * - * @return string Normalized config `logoutRedirect` - * @link https://book.cakephp.org/3.0/en/controllers/components/authentication.html#logging-users-out - */ - public function logout() - { - $this->_setDefaults(); - if (empty($this->_authenticateObjects)) { - $this->constructAuthenticate(); - } - $user = (array)$this->user(); - $this->dispatchEvent('Auth.logout', [$user]); - $this->storage()->delete(); - - return Router::normalize($this->_config['logoutRedirect']); - } - - /** - * Get the current user from storage. - * - * @param string|null $key Field to retrieve. Leave null to get entire User record. - * @return mixed|null Either User record or null if no user is logged in, or retrieved field if key is specified. - * @link https://book.cakephp.org/3.0/en/controllers/components/authentication.html#accessing-the-logged-in-user - */ - public function user($key = null) - { - $user = $this->storage()->read(); - if (!$user) { - return null; - } - - if ($key === null) { - return $user; - } - - return Hash::get($user, $key); - } - - /** - * Similar to AuthComponent::user() except if user is not found in - * configured storage, connected authentication objects will have their - * getUser() methods called. - * - * This lets stateless authentication methods function correctly. - * - * @return bool true If a user can be found, false if one cannot. - */ - protected function _getUser() - { - $user = $this->user(); - if ($user) { - return true; - } - - if (empty($this->_authenticateObjects)) { - $this->constructAuthenticate(); - } - foreach ($this->_authenticateObjects as $auth) { - $result = $auth->getUser($this->request); - if (!empty($result) && is_array($result)) { - $this->_authenticationProvider = $auth; - $event = $this->dispatchEvent('Auth.afterIdentify', [$result, $auth]); - if ($event->getResult() !== null) { - $result = $event->getResult(); - } - $this->storage()->write($result); - - return true; - } - } - - return false; - } - - /** - * Get the URL a user should be redirected to upon login. - * - * Pass a URL in to set the destination a user should be redirected to upon - * logging in. - * - * If no parameter is passed, gets the authentication redirect URL. The URL - * returned is as per following rules: - * - * - Returns the normalized redirect URL from storage if it is - * present and for the same domain the current app is running on. - * - If there is no URL returned from storage and there is a config - * `loginRedirect`, the `loginRedirect` value is returned. - * - If there is no session and no `loginRedirect`, / is returned. - * - * @param string|array|null $url Optional URL to write as the login redirect URL. - * @return string Redirect URL - */ - public function redirectUrl($url = null) - { - $redirectUrl = $this->request->getQuery(static::QUERY_STRING_REDIRECT); - if ($redirectUrl && (substr($redirectUrl, 0, 1) !== '/' || substr($redirectUrl, 0, 2) === '//')) { - $redirectUrl = null; - } - - if ($url !== null) { - $redirectUrl = $url; - } elseif ($redirectUrl) { - if (Router::normalize($redirectUrl) === Router::normalize($this->_config['loginAction'])) { - $redirectUrl = $this->_config['loginRedirect']; - } - } elseif ($this->_config['loginRedirect']) { - $redirectUrl = $this->_config['loginRedirect']; - } else { - $redirectUrl = '/'; - } - if (is_array($redirectUrl)) { - return Router::url($redirectUrl + ['_base' => false]); - } - - return $redirectUrl; - } - - /** - * Use the configured authentication adapters, and attempt to identify the user - * by credentials contained in $request. - * - * Triggers `Auth.afterIdentify` event which the authenticate classes can listen - * to. - * - * @return array|bool User record data, or false, if the user could not be identified. - */ - public function identify() - { - $this->_setDefaults(); - - if (empty($this->_authenticateObjects)) { - $this->constructAuthenticate(); - } - foreach ($this->_authenticateObjects as $auth) { - $result = $auth->authenticate($this->request, $this->response); - if (!empty($result)) { - $this->_authenticationProvider = $auth; - $event = $this->dispatchEvent('Auth.afterIdentify', [$result, $auth]); - if ($event->getResult() !== null) { - return $event->getResult(); - } - - return $result; - } - } - - return false; - } - - /** - * Loads the configured authentication objects. - * - * @return array|null The loaded authorization objects, or null on empty authenticate value. - * @throws \Cake\Core\Exception\Exception - */ - public function constructAuthenticate() - { - if (empty($this->_config['authenticate'])) { - return null; - } - $this->_authenticateObjects = []; - $authenticate = Hash::normalize((array)$this->_config['authenticate']); - $global = []; - if (isset($authenticate[AuthComponent::ALL])) { - $global = $authenticate[AuthComponent::ALL]; - unset($authenticate[AuthComponent::ALL]); - } - foreach ($authenticate as $alias => $config) { - if (!empty($config['className'])) { - $class = $config['className']; - unset($config['className']); - } else { - $class = $alias; - } - $className = App::className($class, 'Auth', 'Authenticate'); - if (!class_exists($className)) { - throw new Exception(sprintf('Authentication adapter "%s" was not found.', $class)); - } - if (!method_exists($className, 'authenticate')) { - throw new Exception('Authentication objects must implement an authenticate() method.'); - } - $config = array_merge($global, (array)$config); - $this->_authenticateObjects[$alias] = new $className($this->_registry, $config); - $this->getEventManager()->on($this->_authenticateObjects[$alias]); - } - - return $this->_authenticateObjects; - } - - /** - * Get/set user record storage object. - * - * @param \Cake\Auth\Storage\StorageInterface|null $storage Sets provided - * object as storage or if null returns configured storage object. - * @return \Cake\Auth\Storage\StorageInterface|\Cake\Core\InstanceConfigTrait|null - */ - public function storage(StorageInterface $storage = null) - { - if ($storage !== null) { - $this->_storage = $storage; - - return null; - } - - if ($this->_storage) { - return $this->_storage; - } - - $config = $this->_config['storage']; - if (is_string($config)) { - $class = $config; - $config = []; - } else { - $class = $config['className']; - unset($config['className']); - } - $className = App::className($class, 'Auth/Storage', 'Storage'); - if (!class_exists($className)) { - throw new Exception(sprintf('Auth storage adapter "%s" was not found.', $class)); - } - $this->_storage = new $className($this->request, $this->response, $config); - - return $this->_storage; - } - - /** - * Magic accessor for backward compatibility for property `$sessionKey`. - * - * @param string $name Property name - * @return mixed - */ - public function __get($name) - { - if ($name === 'sessionKey') { - return $this->storage()->getConfig('key'); - } - - return parent::__get($name); - } - - /** - * Magic setter for backward compatibility for property `$sessionKey`. - * - * @param string $name Property name. - * @param mixed $value Value to set. - * @return void - */ - public function __set($name, $value) - { - if ($name === 'sessionKey') { - $this->_storage = null; - - if ($value === false) { - $this->setConfig('storage', 'Memory'); - - return; - } - - $this->setConfig('storage', 'Session'); - $this->storage()->setConfig('key', $value); - - return; - } - - $this->{$name} = $value; - } - - /** - * Getter for authenticate objects. Will return a particular authenticate object. - * - * @param string $alias Alias for the authenticate object - * - * @return \Cake\Auth\BaseAuthenticate|null - */ - public function getAuthenticate($alias) - { - if (empty($this->_authenticateObjects)) { - $this->constructAuthenticate(); - } - - return isset($this->_authenticateObjects[$alias]) ? $this->_authenticateObjects[$alias] : null; - } - - /** - * Set a flash message. Uses the Flash component with values from `flash` config. - * - * @param string $message The message to set. - * @return void - */ - public function flash($message) - { - if ($message === false) { - return; - } - - $this->Flash->set($message, $this->_config['flash']); - } - - /** - * If login was called during this request and the user was successfully - * authenticated, this function will return the instance of the authentication - * object that was used for logging the user in. - * - * @return \Cake\Auth\BaseAuthenticate|null - */ - public function authenticationProvider() - { - return $this->_authenticationProvider; - } - - /** - * If there was any authorization processing for the current request, this function - * will return the instance of the Authorization object that granted access to the - * user to the current address. - * - * @return \Cake\Auth\BaseAuthorize|null - */ - public function authorizationProvider() - { - return $this->_authorizationProvider; - } - - /** - * Returns the URL to redirect back to or / if not possible. - * - * This method takes the referrer into account if the - * request is not of type GET. - * - * @return string - */ - protected function _getUrlToRedirectBackTo() - { - $urlToRedirectBackTo = $this->request->here(false); - if (!$this->request->is('get')) { - $urlToRedirectBackTo = $this->request->referer(true); - } - - return $urlToRedirectBackTo; - } -} diff --git a/src/Controller/Component/CheckHttpCacheComponent.php b/src/Controller/Component/CheckHttpCacheComponent.php new file mode 100644 index 00000000000..dc4f1edbfbc --- /dev/null +++ b/src/Controller/Component/CheckHttpCacheComponent.php @@ -0,0 +1,54 @@ + $event The Controller.beforeRender event. + * @return void + */ + public function beforeRender(EventInterface $event): void + { + $controller = $this->getController(); + $response = $controller->getResponse(); + $request = $controller->getRequest(); + if (!$response->isNotModified($request)) { + return; + } + + $controller->setResponse($response->withNotModified()); + $event->stopPropagation(); + } +} diff --git a/src/Controller/Component/CookieComponent.php b/src/Controller/Component/CookieComponent.php deleted file mode 100644 index 70248dfafee..00000000000 --- a/src/Controller/Component/CookieComponent.php +++ /dev/null @@ -1,359 +0,0 @@ - null, - 'domain' => '', - 'secure' => false, - 'key' => null, - 'httpOnly' => false, - 'encryption' => 'aes', - 'expires' => '+1 month', - ]; - - /** - * Config specific to a given top level key name. - * - * The values in this array are merged with the general config - * to generate the configuration for a given top level cookie name. - * - * @var array - */ - protected $_keyConfig = []; - - /** - * Values stored in the cookie. - * - * Accessed in the controller using $this->Cookie->read('Name.key'); - * - * @var array - */ - protected $_values = []; - - /** - * A map of keys that have been loaded. - * - * Since CookieComponent lazily reads cookie data, - * we need to track which cookies have been read to account for - * read, delete, read patterns. - * - * @var array - */ - protected $_loaded = []; - - /** - * A reference to the Controller's Cake\Http\Response object. - * Currently unused. - * - * @var \Cake\Http\Response|null - * @deprecated 3.4.0 Will be removed in 4.0.0 - */ - protected $_response; - - /** - * Initialize config data and properties. - * - * @param array $config The config data. - * @return void - */ - public function initialize(array $config) - { - if (!$this->_config['key']) { - $this->setConfig('key', Security::getSalt()); - } - - $controller = $this->_registry->getController(); - - if ($controller === null) { - $this->request = ServerRequest::createFromGlobals(); - } - - if (empty($this->_config['path'])) { - $this->setConfig('path', $this->request->webroot); - } - } - - /** - * Set the configuration for a specific top level key. - * - * ### Examples: - * - * Set a single config option for a key: - * - * ``` - * $this->Cookie->configKey('User', 'expires', '+3 months'); - * ``` - * - * Set multiple options: - * - * ``` - * $this->Cookie->configKey('User', [ - * 'expires', '+3 months', - * 'httpOnly' => true, - * ]); - * ``` - * - * @param string $keyname The top level keyname to configure. - * @param null|string|array $option Either the option name to set, or an array of options to set, - * or null to read config options for a given key. - * @param string|null $value Either the value to set, or empty when $option is an array. - * @return array|null - */ - public function configKey($keyname, $option = null, $value = null) - { - if ($option === null) { - $default = $this->_config; - $local = isset($this->_keyConfig[$keyname]) ? $this->_keyConfig[$keyname] : []; - - return $local + $default; - } - if (!is_array($option)) { - $option = [$option => $value]; - } - $this->_keyConfig[$keyname] = $option; - - return null; - } - - /** - * Events supported by this component. - * - * @return array - */ - public function implementedEvents() - { - return []; - } - - /** - * Write a value to the response cookies. - * - * You must use this method before any output is sent to the browser. - * Failure to do so will result in header already sent errors. - * - * @param string|array $key Key for the value - * @param mixed $value Value - * @return void - */ - public function write($key, $value = null) - { - if (!is_array($key)) { - $key = [$key => $value]; - } - - $keys = []; - foreach ($key as $name => $value) { - $this->_load($name); - - $this->_values = Hash::insert($this->_values, $name, $value); - $parts = explode('.', $name); - $keys[] = $parts[0]; - } - - foreach ($keys as $name) { - $this->_write($name, $this->_values[$name]); - } - } - - /** - * Read the value of key path from request cookies. - * - * This method will also allow you to read cookies that have been written in this - * request, but not yet sent to the client. - * - * @param string|null $key Key of the value to be obtained. - * @return string or null, value for specified key - */ - public function read($key = null) - { - $this->_load($key); - - return Hash::get($this->_values, $key); - } - - /** - * Load the cookie data from the request and response objects. - * - * Based on the configuration data, cookies will be decrypted. When cookies - * contain array data, that data will be expanded. - * - * @param string|array $key The key to load. - * @return void - */ - protected function _load($key) - { - $parts = explode('.', $key); - $first = array_shift($parts); - if (isset($this->_loaded[$first])) { - return; - } - if (!isset($this->request->cookies[$first])) { - return; - } - $cookie = $this->request->cookies[$first]; - $config = $this->configKey($first); - $this->_loaded[$first] = true; - $this->_values[$first] = $this->_decrypt($cookie, $config['encryption'], $config['key']); - } - - /** - * Returns true if given key is set in the cookie. - * - * @param string|null $key Key to check for - * @return bool True if the key exists - */ - public function check($key = null) - { - if (empty($key)) { - return false; - } - - return $this->read($key) !== null; - } - - /** - * Delete a cookie value - * - * You must use this method before any output is sent to the browser. - * Failure to do so will result in header already sent errors. - * - * Deleting a top level key will delete all keys nested within that key. - * For example deleting the `User` key, will also delete `User.email`. - * - * @param string $key Key of the value to be deleted - * @return void - */ - public function delete($key) - { - $this->_load($key); - - $this->_values = Hash::remove($this->_values, $key); - $parts = explode('.', $key); - $top = $parts[0]; - - if (isset($this->_values[$top])) { - $this->_write($top, $this->_values[$top]); - } else { - $this->_delete($top); - } - } - - /** - * Set cookie - * - * @param string $name Name for cookie - * @param string $value Value for cookie - * @return void - */ - protected function _write($name, $value) - { - $config = $this->configKey($name); - $expires = new Time($config['expires']); - - $response = $this->getController()->response; - $response->cookie([ - 'name' => $name, - 'value' => $this->_encrypt($value, $config['encryption'], $config['key']), - 'expire' => $expires->format('U'), - 'path' => $config['path'], - 'domain' => $config['domain'], - 'secure' => (bool)$config['secure'], - 'httpOnly' => (bool)$config['httpOnly'] - ]); - } - - /** - * Sets a cookie expire time to remove cookie value. - * - * This is only done once all values in a cookie key have been - * removed with delete. - * - * @param string $name Name of cookie - * @return void - */ - protected function _delete($name) - { - $config = $this->configKey($name); - $expires = new Time('now'); - - $response = $this->getController()->response; - $response->cookie([ - 'name' => $name, - 'value' => '', - 'expire' => $expires->format('U') - 42000, - 'path' => $config['path'], - 'domain' => $config['domain'], - 'secure' => $config['secure'], - 'httpOnly' => $config['httpOnly'] - ]); - } - - /** - * Returns the encryption key to be used. - * - * @return string - */ - protected function _getCookieEncryptionKey() - { - return $this->_config['key']; - } -} diff --git a/src/Controller/Component/CsrfComponent.php b/src/Controller/Component/CsrfComponent.php deleted file mode 100644 index 1d880b27221..00000000000 --- a/src/Controller/Component/CsrfComponent.php +++ /dev/null @@ -1,164 +0,0 @@ -Form->create(...)` is used in a view. - * - * @deprecated 3.5.0 Use Cake\Http\Middleware\CsrfProtectionMiddleware instead. - */ -class CsrfComponent extends Component -{ - - /** - * Default config for the CSRF handling. - * - * - cookieName = The name of the cookie to send. - * - expiry = How long the CSRF token should last. Defaults to browser session. - * - secure = Whether or not the cookie will be set with the Secure flag. Defaults to false. - * - httpOnly = Whether or not the cookie will be set with the HttpOnly flag. Defaults to false. - * - field = The form field to check. Changing this will also require configuring - * FormHelper. - * - * @var array - */ - protected $_defaultConfig = [ - 'cookieName' => 'csrfToken', - 'expiry' => 0, - 'secure' => false, - 'httpOnly' => false, - 'field' => '_csrfToken', - ]; - - /** - * Startup callback. - * - * Validates the CSRF token for POST data. If - * the request is a GET request, and the cookie value is absent a cookie will be set. - * - * Once a cookie is set it will be copied into request->getParam('_csrfToken') - * so that application and framework code can easily access the csrf token. - * - * RequestAction requests do not get checked, nor will - * they set a cookie should it be missing. - * - * @param \Cake\Event\Event $event Event instance. - * @return void - */ - public function startup(Event $event) - { - /** @var \Cake\Controller\Controller $controller */ - $controller = $event->getSubject(); - $request = $controller->request; - $response = $controller->response; - $cookieName = $this->_config['cookieName']; - - $cookieData = $request->getCookie($cookieName); - if ($cookieData) { - $request->params['_csrfToken'] = $cookieData; - } - - if ($request->is('requested')) { - return; - } - - if ($request->is('get') && $cookieData === null) { - $this->_setCookie($request, $response); - } - if ($request->is(['put', 'post', 'delete', 'patch']) || $request->getData()) { - $this->_validateToken($request); - unset($request->data[$this->_config['field']]); - } - } - - /** - * Events supported by this component. - * - * @return array - */ - public function implementedEvents() - { - return [ - 'Controller.startup' => 'startup', - ]; - } - - /** - * Set the cookie in the response. - * - * Also sets the request->params['_csrfToken'] so the newly minted - * token is available in the request data. - * - * @param \Cake\Http\ServerRequest $request The request object. - * @param \Cake\Http\Response $response The response object. - * @return void - */ - protected function _setCookie(ServerRequest $request, Response $response) - { - $expiry = new Time($this->_config['expiry']); - $value = hash('sha512', Security::randomBytes(16), false); - - $request->params['_csrfToken'] = $value; - $response->cookie([ - 'name' => $this->_config['cookieName'], - 'value' => $value, - 'expire' => $expiry->format('U'), - 'path' => $request->getAttribute('webroot'), - 'secure' => $this->_config['secure'], - 'httpOnly' => $this->_config['httpOnly'], - ]); - } - - /** - * Validate the request data against the cookie token. - * - * @param \Cake\Http\ServerRequest $request The request to validate against. - * @throws \Cake\Network\Exception\InvalidCsrfTokenException when the CSRF token is invalid or missing. - * @return void - */ - protected function _validateToken(ServerRequest $request) - { - $cookie = $request->getCookie($this->_config['cookieName']); - $post = $request->getData($this->_config['field']); - $header = $request->getHeaderLine('X-CSRF-Token'); - - if (!$cookie) { - throw new InvalidCsrfTokenException(__d('cake', 'Missing CSRF token cookie')); - } - - if ($post !== $cookie && $header !== $cookie) { - throw new InvalidCsrfTokenException(__d('cake', 'CSRF token mismatch.')); - } - } -} diff --git a/src/Controller/Component/FlashComponent.php b/src/Controller/Component/FlashComponent.php index b9099bf7308..28b142ede05 100644 --- a/src/Controller/Component/FlashComponent.php +++ b/src/Controller/Component/FlashComponent.php @@ -1,4 +1,6 @@ */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ 'key' => 'flash', 'element' => 'default', 'params' => [], 'clear' => false, - 'duplicate' => true + 'duplicate' => true, ]; - /** - * Constructor - * - * @param \Cake\Controller\ComponentRegistry $registry A ComponentRegistry for this component - * @param array $config Array of config. - */ - public function __construct(ComponentRegistry $registry, array $config = []) - { - parent::__construct($registry, $config); - $this->_session = $registry->getController()->request->getSession(); - } - /** * Used to set a session variable that can be used to output messages in the view. * If you make consecutive calls to this method, the messages will stack (if they are @@ -78,63 +62,90 @@ public function __construct(ComponentRegistry $registry, array $config = []) * - `clear` A bool stating if the current stack should be cleared to start a new one * - `escape` Set to false to allow templates to print out HTML content * - * @param string|\Exception $message Message to be flashed. If an instance - * of \Exception the exception message will be used and code will be set + * @param \Throwable|string $message Message to be flashed. If an instance + * of \Throwable the throwable message will be used and code will be set * in params. - * @param array $options An array of options + * @param array $options An array of options * @return void */ - public function set($message, array $options = []) + public function set(Throwable|string $message, array $options = []): void { - $options += $this->getConfig(); - - if ($message instanceof Exception) { - if (!isset($options['params']['code'])) { - $options['params']['code'] = $message->getCode(); - } - $message = $message->getMessage(); + if ($message instanceof Throwable) { + $this->flash()->setExceptionMessage($message, $options); + } else { + $this->flash()->set($message, $options); } + } - if (isset($options['escape']) && !isset($options['params']['escape'])) { - $options['params']['escape'] = $options['escape']; - } + /** + * Get flash message utility instance. + * + * @return \Cake\Http\FlashMessage + */ + protected function flash(): FlashMessage + { + return $this->getController()->getRequest()->getFlash(); + } - list($plugin, $element) = pluginSplit($options['element']); + /** + * Proxy method to FlashMessage instance. + * + * @param array|string $key The key to set, or a complete array of configs. + * @param mixed $value The value to set. + * @param bool $merge Whether to recursively merge or overwrite existing config, defaults to true. + * @return $this + * @throws \Cake\Core\Exception\CakeException When trying to set a key that is invalid. + */ + public function setConfig(array|string $key, mixed $value = null, bool $merge = true) + { + $this->flash()->setConfig($key, $value, $merge); - if ($plugin) { - $options['element'] = $plugin . '.Flash/' . $element; - } else { - $options['element'] = 'Flash/' . $element; - } + return $this; + } - $messages = []; - if (!$options['clear']) { - $messages = (array)$this->_session->read('Flash.' . $options['key']); - } + /** + * Proxy method to FlashMessage instance. + * + * @param string|null $key The key to get or null for the whole config. + * @param mixed $default The return value when the key does not exist. + * @return mixed Configuration data at the named key or null if the key does not exist. + */ + public function getConfig(?string $key = null, mixed $default = null): mixed + { + return $this->flash()->getConfig($key, $default); + } - if (!$options['duplicate']) { - foreach ($messages as $existingMessage) { - if ($existingMessage['message'] === $message) { - return; - } - } - } + /** + * Proxy method to FlashMessage instance. + * + * @param string $key The key to get. + * @return mixed Configuration data at the named key + * @throws \InvalidArgumentException + */ + public function getConfigOrFail(string $key): mixed + { + return $this->flash()->getConfigOrFail($key); + } - $messages[] = [ - 'message' => $message, - 'key' => $options['key'], - 'element' => $options['element'], - 'params' => $options['params'] - ]; + /** + * Proxy method to FlashMessage instance. + * + * @param array|string $key The key to set, or a complete array of configs. + * @param mixed $value The value to set. + * @return $this + */ + public function configShallow(array|string $key, mixed $value = null) + { + $this->flash()->configShallow($key, $value); - $this->_session->write('Flash.' . $options['key'], $messages); + return $this; } /** * Magic method for verbose flash methods based on element names. * * For example: $this->Flash->success('My message') would use the - * success.ctp element under `src/Template/Element/Flash` for rendering the + * `success.php` element under `templates/element/flash/` for rendering the * flash message. * * If you make consecutive calls to this method, the messages will stack (if they are @@ -144,15 +155,15 @@ public function set($message, array $options = []) * specific element from a plugin, you should set the `plugin` option in $args. * * For example: `$this->Flash->warning('My message', ['plugin' => 'PluginName'])` would - * use the warning.ctp element under `plugins/PluginName/src/Template/Element/Flash` for + * use the `warning.php` element under `plugins/PluginName/templates/element/flash/` for * rendering the flash message. * * @param string $name Element name to use. * @param array $args Parameters to pass when calling `FlashComponent::set()`. * @return void - * @throws \Cake\Network\Exception\InternalErrorException If missing the flash message. + * @throws \Cake\Http\Exception\InternalErrorException If missing the flash message. */ - public function __call($name, $args) + public function __call(string $name, array $args): void { $element = Inflector::underscore($name); @@ -162,6 +173,10 @@ public function __call($name, $args) $options = ['element' => $element]; + if (isset($args['options'])) { + $args[1] = $args['options']; + } + if (!empty($args[1])) { if (!empty($args[1]['plugin'])) { $options = ['element' => $args[1]['plugin'] . '.' . $element]; @@ -170,6 +185,6 @@ public function __call($name, $args) $options += (array)$args[1]; } - $this->set($args[0], $options); + $this->set($args[0] ?? $args['message'], $options); } } diff --git a/src/Controller/Component/FormProtectionComponent.php b/src/Controller/Component/FormProtectionComponent.php new file mode 100644 index 00000000000..80c43797e27 --- /dev/null +++ b/src/Controller/Component/FormProtectionComponent.php @@ -0,0 +1,208 @@ + + */ + protected array $_defaultConfig = [ + 'validate' => true, + 'unlockedFields' => [], + 'unlockedActions' => [], + 'validationFailureCallback' => null, + ]; + + /** + * Get Session id for FormProtector + * Must be the same as in FormHelper + * + * @return string + */ + protected function _getSessionId(): string + { + $session = $this->getController()->getRequest()->getSession(); + $session->start(); + + return $session->id(); + } + + /** + * Component startup. + * + * Token check happens here. + * + * @param \Cake\Event\EventInterface<\Cake\Controller\Controller> $event An Event instance + * @return \Cake\Http\Response|null + */ + public function startup(EventInterface $event): ?Response + { + $request = $this->getController()->getRequest(); + $data = $request->getParsedBody(); + $hasData = ($data || $request->is(['put', 'post', 'delete', 'patch'])); + + if ( + !in_array($request->getParam('action'), $this->_config['unlockedActions'], true) + && $hasData + && $this->_config['validate'] + ) { + $sessionId = $this->_getSessionId(); + $url = Router::url($request->getRequestTarget()); + + $formProtector = new FormProtector($this->_config); + $isValid = $formProtector->validate($data, $url, $sessionId); + + if (!$isValid) { + $event->setResult($this->validationFailure($formProtector)); + + return null; + } + } + + $token = [ + 'unlockedFields' => $this->_config['unlockedFields'], + ]; + $request = $request->withAttribute('formTokenData', [ + 'unlockedFields' => $token['unlockedFields'], + ]); + + if (is_array($data)) { + unset($data['_Token']); + $request = $request->withParsedBody($data); + } + + $this->getController()->setRequest($request); + + return null; + } + + /** + * Events supported by this component. + * + * @return array + */ + public function implementedEvents(): array + { + return [ + 'Controller.startup' => 'startup', + ]; + } + + /** + * Unlock actions from validation. + * + * @param string|array $actions Action or list of actions to unlock. + * @param bool $merge Whether to merge with existing unlocked actions or replace them. + * @return $this + */ + public function unlockActions(string|array $actions, bool $merge = true) + { + return $this->setConfig('unlockedActions', (array)$actions, $merge); + } + + /** + * Unlock fields from validation. + * + * Dot notation can be used to unlock nested fields. For example, `user.name` + * will unlock the `name` field in the `user` array. + * + * @param string|array $fields Field or list of fields to unlock. + * @param bool $merge Whether to merge with existing unlocked fields or replace them. + * @return $this + */ + public function unlockFields(string|array $fields, bool $merge = true) + { + return $this->setConfig('unlockedFields', (array)$fields, $merge); + } + + /** + * Throws a 400 - Bad request exception or calls custom callback. + * + * If `validationFailureCallback` config is specified, it will use this + * callback by executing the method passing the argument as exception. + * + * @param \Cake\Form\FormProtector $formProtector Form Protector instance. + * @return \Cake\Http\Response|null If specified, validationFailureCallback's response, or no return otherwise. + * @throws \Cake\Controller\Exception\FormProtectionException + */ + protected function validationFailure(FormProtector $formProtector): ?Response + { + if (Configure::read('debug')) { + $exception = new FormProtectionException($formProtector->getError()); + } else { + $exception = new FormProtectionException(static::DEFAULT_EXCEPTION_MESSAGE); + } + + if ($this->_config['validationFailureCallback']) { + return $this->executeCallback($this->_config['validationFailureCallback'], $exception); + } + + throw $exception; + } + + /** + * Execute callback. + * + * @param \Closure $callback Callback + * @param \Cake\Controller\Exception\FormProtectionException $exception Exception instance. + * @return \Cake\Http\Response|null + */ + protected function executeCallback(Closure $callback, FormProtectionException $exception): ?Response + { + return $callback($exception); + } +} diff --git a/src/Controller/Component/PaginatorComponent.php b/src/Controller/Component/PaginatorComponent.php deleted file mode 100644 index 6000e70732f..00000000000 --- a/src/Controller/Component/PaginatorComponent.php +++ /dev/null @@ -1,348 +0,0 @@ - 1, - 'limit' => 20, - 'maxLimit' => 100, - 'whitelist' => ['limit', 'sort', 'page', 'direction'] - ]; - - /** - * Datasource paginator instance. - * - * @var \Cake\Datasource\Paginator - */ - protected $_paginator; - - /** - * {@inheritDoc} - */ - public function __construct(ComponentRegistry $registry, array $config = []) - { - if (isset($config['paginator'])) { - if (!$config['paginator'] instanceof Paginator) { - throw new InvalidArgumentException('Paginator must be an instance of ' . Paginator::class); - } - $this->_paginator = $config['paginator']; - unset($config['paginator']); - } else { - $this->_paginator = new Paginator(); - } - - parent::__construct($registry, $config); - } - - /** - * Events supported by this component. - * - * @return array - */ - public function implementedEvents() - { - return []; - } - - /** - * Handles automatic pagination of model records. - * - * ### Configuring pagination - * - * When calling `paginate()` you can use the $settings parameter to pass in pagination settings. - * These settings are used to build the queries made and control other pagination settings. - * - * If your settings contain a key with the current table's alias. The data inside that key will be used. - * Otherwise the top level configuration will be used. - * - * ``` - * $settings = [ - * 'limit' => 20, - * 'maxLimit' => 100 - * ]; - * $results = $paginator->paginate($table, $settings); - * ``` - * - * The above settings will be used to paginate any Table. You can configure Table specific settings by - * keying the settings with the Table alias. - * - * ``` - * $settings = [ - * 'Articles' => [ - * 'limit' => 20, - * 'maxLimit' => 100 - * ], - * 'Comments' => [ ... ] - * ]; - * $results = $paginator->paginate($table, $settings); - * ``` - * - * This would allow you to have different pagination settings for `Articles` and `Comments` tables. - * - * ### Controlling sort fields - * - * By default CakePHP will automatically allow sorting on any column on the table object being - * paginated. Often times you will want to allow sorting on either associated columns or calculated - * fields. In these cases you will need to define a whitelist of all the columns you wish to allow - * sorting on. You can define the whitelist in the `$settings` parameter: - * - * ``` - * $settings = [ - * 'Articles' => [ - * 'finder' => 'custom', - * 'sortWhitelist' => ['title', 'author_id', 'comment_count'], - * ] - * ]; - * ``` - * - * Passing an empty array as whitelist disallows sorting altogether. - * - * ### Paginating with custom finders - * - * You can paginate with any find type defined on your table using the `finder` option. - * - * ``` - * $settings = [ - * 'Articles' => [ - * 'finder' => 'popular' - * ] - * ]; - * $results = $paginator->paginate($table, $settings); - * ``` - * - * Would paginate using the `find('popular')` method. - * - * You can also pass an already created instance of a query to this method: - * - * ``` - * $query = $this->Articles->find('popular')->matching('Tags', function ($q) { - * return $q->where(['name' => 'CakePHP']) - * }); - * $results = $paginator->paginate($query); - * ``` - * - * ### Scoping Request parameters - * - * By using request parameter scopes you can paginate multiple queries in the same controller action: - * - * ``` - * $articles = $paginator->paginate($articlesQuery, ['scope' => 'articles']); - * $tags = $paginator->paginate($tagsQuery, ['scope' => 'tags']); - * ``` - * - * Each of the above queries will use different query string parameter sets - * for pagination data. An example URL paginating both results would be: - * - * ``` - * /dashboard?articles[page]=1&tags[page]=2 - * ``` - * - * @param \Cake\Datasource\RepositoryInterface|\Cake\Datasource\QueryInterface $object The table or query to paginate. - * @param array $settings The settings/configuration used for pagination. - * @return \Cake\Datasource\ResultSetInterface Query results - * @throws \Cake\Network\Exception\NotFoundException - */ - public function paginate($object, array $settings = []) - { - $request = $this->_registry->getController()->request; - - try { - $results = $this->_paginator->paginate( - $object, - $request->getQueryParams(), - $settings - ); - - $this->_setPagingParams(); - } catch (PageOutOfBoundsException $e) { - $this->_setPagingParams(); - - throw new NotFoundException(); - } - - return $results; - } - - /** - * Merges the various options that Pagination uses. - * Pulls settings together from the following places: - * - * - General pagination settings - * - Model specific settings. - * - Request parameters - * - * The result of this method is the aggregate of all the option sets combined together. You can change - * config value `whitelist` to modify which options/values can be set using request parameters. - * - * @param string $alias Model alias being paginated, if the general settings has a key with this value - * that key's settings will be used for pagination instead of the general ones. - * @param array $settings The settings to merge with the request data. - * @return array Array of merged options. - */ - public function mergeOptions($alias, $settings) - { - $request = $this->_registry->getController()->request; - - return $this->_paginator->mergeOptions( - $request->getQueryParams(), - $this->_paginator->getDefaults($alias, $settings) - ); - } - - /** - * Set paginator instance. - * - * @param \Cake\Datasource\Paginator $paginator Paginator instance. - * @return self - */ - public function setPaginator(Paginator $paginator) - { - $this->_paginator = $paginator; - - return $this; - } - - /** - * Get paginator instance. - * - * @return \Cake\Datasource\Paginator - */ - public function getPaginator() - { - return $this->_paginator; - } - - /** - * Set paging params to request instance. - * - * @return void - */ - protected function _setPagingParams() - { - $request = $this->_registry->getController()->request; - - $request->addParams([ - 'paging' => $this->_paginator->getPagingParams() - + (array)$request->getParam('paging') - ]); - } - - /** - * Proxy getting/setting config options to Paginator. - * - * @deprecated 3.5.0 use setConfig()/getConfig() instead. - * @param string|array|null $key The key to get/set, or a complete array of configs. - * @param mixed|null $value The value to set. - * @param bool $merge Whether to recursively merge or overwrite existing config, defaults to true. - * @return mixed Config value being read, or the object itself on write operations. - */ - public function config($key = null, $value = null, $merge = true) - { - $return = $this->_paginator->config($key, $value, $merge); - if ($return instanceof Paginator) { - $return = $this; - } - - return $return; - } - - /** - * Proxy setting config options to Paginator. - * - * @param string|array $key The key to set, or a complete array of configs. - * @param mixed|null $value The value to set. - * @param bool $merge Whether to recursively merge or overwrite existing config, defaults to true. - * @return $this - */ - public function setConfig($key, $value = null, $merge = true) - { - $this->_paginator->setConfig($key, $value, $merge); - - return $this; - } - - /** - * Proxy getting config options to Paginator. - * - * @param string|null $key The key to get or null for the whole config. - * @param mixed $default The return value when the key does not exist. - * @return mixed Config value being read. - */ - public function getConfig($key = null, $default = null) - { - return $this->_paginator->getConfig($key, $default); - } - - /** - * Proxy setting config options to Paginator. - * - * @param string|array $key The key to set, or a complete array of configs. - * @param mixed|null $value The value to set. - * @return $this - */ - public function configShallow($key, $value = null) - { - $this->_paginator->configShallow($key, $value = null); - - return $this; - } - - /** - * Proxy method calls to Paginator. - * - * @param string $method Method name. - * @param array $args Method arguments. - * @return mixed - */ - public function __call($method, $args) - { - return call_user_func_array([$this->_paginator, $method], $args); - } -} diff --git a/src/Controller/Component/RequestHandlerComponent.php b/src/Controller/Component/RequestHandlerComponent.php deleted file mode 100644 index 857a853821e..00000000000 --- a/src/Controller/Component/RequestHandlerComponent.php +++ /dev/null @@ -1,750 +0,0 @@ - true, - 'viewClassMap' => [], - 'inputTypeMap' => [], - 'enableBeforeRedirect' => true - ]; - - /** - * Set the layout to be used when rendering the AuthComponent's ajaxLogin element. - * - * @var string - * @deprecated 3.3.11 This feature property is not supported and will - * be removed in 4.0.0 - */ - public $ajaxLayout; - - /** - * Constructor. Parses the accepted content types accepted by the client using HTTP_ACCEPT - * - * @param \Cake\Controller\ComponentRegistry $registry ComponentRegistry object. - * @param array $config Array of config. - */ - public function __construct(ComponentRegistry $registry, array $config = []) - { - $config += [ - 'viewClassMap' => [ - 'json' => 'Json', - 'xml' => 'Xml', - 'ajax' => 'Ajax' - ], - 'inputTypeMap' => [ - 'json' => ['json_decode', true], - 'xml' => [[$this, 'convertXml']], - ] - ]; - parent::__construct($registry, $config); - } - - /** - * Events supported by this component. - * - * @return array - */ - public function implementedEvents() - { - return [ - 'Controller.startup' => 'startup', - 'Controller.beforeRender' => 'beforeRender', - 'Controller.beforeRedirect' => 'beforeRedirect', - ]; - } - - /** - * @param array $config The config data. - * @return void - * @deprecated 3.4.0 Unused. To be removed in 4.0.0 - */ - public function initialize(array $config) - { - } - - /** - * Set the extension based on the accept headers. - * Compares the accepted types and configured extensions. - * If there is one common type, that is assigned as the ext/content type for the response. - * The type with the highest weight will be set. If the highest weight has more - * than one type matching the extensions, the order in which extensions are specified - * determines which type will be set. - * - * If html is one of the preferred types, no content type will be set, this - * is to avoid issues with browsers that prefer HTML and several other content types. - * - * @param \Cake\Http\ServerRequest $request The request instance. - * @param \Cake\Http\Response $response The response instance. - * @return void - */ - protected function _setExtension($request, $response) - { - $accept = $request->parseAccept(); - if (empty($accept) || current($accept)[0] === 'text/html') { - return; - } - - $accepts = $response->mapType($accept); - $preferredTypes = current($accepts); - if (array_intersect($preferredTypes, ['html', 'xhtml'])) { - return; - } - - $extensions = array_unique( - array_merge(Router::extensions(), array_keys($this->getConfig('viewClassMap'))) - ); - foreach ($accepts as $types) { - $ext = array_intersect($extensions, $types); - if ($ext) { - $this->ext = current($ext); - break; - } - } - } - - /** - * The startup method of the RequestHandler enables several automatic behaviors - * related to the detection of certain properties of the HTTP request, including: - * - * If the XML data is POSTed, the data is parsed into an XML object, which is assigned - * to the $data property of the controller, which can then be saved to a model object. - * - * @param \Cake\Event\Event $event The startup event that was fired. - * @return void - */ - public function startup(Event $event) - { - /** @var \Cake\Controller\Controller $controller */ - $controller = $event->getSubject(); - $request = $controller->request; - $response = $controller->response; - - if ($request->getParam('_ext')) { - $this->ext = $request->getParam('_ext'); - } - if (!$this->ext || in_array($this->ext, ['html', 'htm'])) { - $this->_setExtension($request, $response); - } - - $request->params['isAjax'] = $request->is('ajax'); - - if (!$this->ext && $request->is('ajax')) { - $this->ext = 'ajax'; - } - - if ($request->is(['get', 'head', 'options'])) { - return; - } - - foreach ($this->getConfig('inputTypeMap') as $type => $handler) { - if (!is_callable($handler[0])) { - throw new RuntimeException(sprintf("Invalid callable for '%s' type.", $type)); - } - if ($this->requestedWith($type)) { - $input = $request->input(...$handler); - $request->data = (array)$input; - } - } - } - - /** - * Helper method to parse xml input data, due to lack of anonymous functions - * this lives here. - * - * @param string $xml XML string. - * @return array Xml array data - */ - public function convertXml($xml) - { - try { - $xml = Xml::build($xml, ['readFile' => false]); - if (isset($xml->data)) { - return Xml::toArray($xml->data); - } - - return Xml::toArray($xml); - } catch (XmlException $e) { - return []; - } - } - - /** - * Handles (fakes) redirects for AJAX requests using requestAction() - * - * @param \Cake\Event\Event $event The Controller.beforeRedirect event. - * @param string|array $url A string or array containing the redirect location - * @param \Cake\Http\Response $response The response object. - * @return \Cake\Http\Response|null The response object if the redirect is caught. - * @deprecated 3.3.5 This functionality will be removed in 4.0.0. You can disable this function - * now by setting the `enableBeforeRedirect` config option to false. - */ - public function beforeRedirect(Event $event, $url, Response $response) - { - if (!$this->getConfig('enableBeforeRedirect')) { - return null; - } - $request = $this->request; - if (!$request->is('ajax')) { - return null; - } - if (empty($url)) { - return null; - } - if (is_array($url)) { - $url = Router::url($url + ['_base' => false]); - } - $query = []; - if (strpos($url, '?') !== false) { - list($url, $querystr) = explode('?', $url, 2); - parse_str($querystr, $query); - } - /** @var \Cake\Controller\Controller $controller */ - $controller = $event->getSubject(); - $response->body($controller->requestAction($url, [ - 'return', - 'bare' => false, - 'environment' => [ - 'REQUEST_METHOD' => 'GET' - ], - 'query' => $query, - 'cookies' => $request->getCookieParams() - ])); - - return $response->withStatus(200); - } - - /** - * Checks if the response can be considered different according to the request - * headers, and the caching response headers. If it was not modified, then the - * render process is skipped. And the client will get a blank response with a - * "304 Not Modified" header. - * - * - If Router::extensions() is enabled, the layout and template type are - * switched based on the parsed extension or `Accept` header. For example, - * if `controller/action.xml` is requested, the view path becomes - * `app/View/Controller/xml/action.ctp`. Also if `controller/action` is - * requested with `Accept: application/xml` in the headers the view - * path will become `app/View/Controller/xml/action.ctp`. Layout and template - * types will only switch to mime-types recognized by Cake\Http\Response. - * If you need to declare additional mime-types, you can do so using - * Cake\Http\Response::type() in your controller's beforeFilter() method. - * - If a helper with the same name as the extension exists, it is added to - * the controller. - * - If the extension is of a type that RequestHandler understands, it will - * set that Content-type in the response header. - * - * @param \Cake\Event\Event $event The Controller.beforeRender event. - * @return bool false if the render process should be aborted - */ - public function beforeRender(Event $event) - { - /** @var \Cake\Controller\Controller $controller */ - $controller = $event->getSubject(); - $response = $controller->response; - $request = $controller->request; - - $isRecognized = ( - !in_array($this->ext, ['html', 'htm']) && - $response->getMimeType($this->ext) - ); - - if ($this->ext && $isRecognized) { - $this->renderAs($controller, $this->ext); - } else { - $response->charset(Configure::read('App.encoding')); - } - - if ($this->_config['checkHttpCache'] && - $response->checkNotModified($request) - ) { - return false; - } - } - - /** - * Returns true if the current call accepts an XML response, false otherwise - * - * @return bool True if client accepts an XML response - */ - public function isXml() - { - return $this->prefers('xml'); - } - - /** - * Returns true if the current call accepts an RSS response, false otherwise - * - * @return bool True if client accepts an RSS response - */ - public function isRss() - { - return $this->prefers('rss'); - } - - /** - * Returns true if the current call accepts an Atom response, false otherwise - * - * @return bool True if client accepts an RSS response - */ - public function isAtom() - { - return $this->prefers('atom'); - } - - /** - * Returns true if user agent string matches a mobile web browser, or if the - * client accepts WAP content. - * - * @return bool True if user agent is a mobile web browser - */ - public function isMobile() - { - $request = $this->request; - - return $request->is('mobile') || $this->accepts('wap'); - } - - /** - * Returns true if the client accepts WAP content - * - * @return bool - */ - public function isWap() - { - return $this->prefers('wap'); - } - - /** - * Determines which content types the client accepts. Acceptance is based on - * the file extension parsed by the Router (if present), and by the HTTP_ACCEPT - * header. Unlike Cake\Http\ServerRequest::accepts() this method deals entirely with mapped content types. - * - * Usage: - * - * ``` - * $this->RequestHandler->accepts(['xml', 'html', 'json']); - * ``` - * - * Returns true if the client accepts any of the supplied types. - * - * ``` - * $this->RequestHandler->accepts('xml'); - * ``` - * - * Returns true if the client accepts xml. - * - * @param string|array|null $type Can be null (or no parameter), a string type name, or an - * array of types - * @return mixed If null or no parameter is passed, returns an array of content - * types the client accepts. If a string is passed, returns true - * if the client accepts it. If an array is passed, returns true - * if the client accepts one or more elements in the array. - */ - public function accepts($type = null) - { - $controller = $this->getController(); - $request = $controller->request; - $response = $controller->response; - $accepted = $request->accepts(); - - if (!$type) { - return $response->mapType($accepted); - } - if (is_array($type)) { - foreach ($type as $t) { - $t = $this->mapAlias($t); - if (in_array($t, $accepted)) { - return true; - } - } - - return false; - } - if (is_string($type)) { - return in_array($this->mapAlias($type), $accepted); - } - - return false; - } - - /** - * Determines the content type of the data the client has sent (i.e. in a POST request) - * - * @param string|array|null $type Can be null (or no parameter), a string type name, or an array of types - * @return mixed If a single type is supplied a boolean will be returned. If no type is provided - * The mapped value of CONTENT_TYPE will be returned. If an array is supplied the first type - * in the request content type will be returned. - */ - public function requestedWith($type = null) - { - $controller = $this->getController(); - $request = $controller->request; - $response = $controller->response; - - if (!$request->is('post') && - !$request->is('put') && - !$request->is('patch') && - !$request->is('delete') - ) { - return null; - } - if (is_array($type)) { - foreach ($type as $t) { - if ($this->requestedWith($t)) { - return $t; - } - } - - return false; - } - - list($contentType) = explode(';', $request->contentType()); - if ($type === null) { - return $response->mapType($contentType); - } - if (is_string($type)) { - return ($type === $response->mapType($contentType)); - } - } - - /** - * Determines which content-types the client prefers. If no parameters are given, - * the single content-type that the client most likely prefers is returned. If $type is - * an array, the first item in the array that the client accepts is returned. - * Preference is determined primarily by the file extension parsed by the Router - * if provided, and secondarily by the list of content-types provided in - * HTTP_ACCEPT. - * - * @param string|array|null $type An optional array of 'friendly' content-type names, i.e. - * 'html', 'xml', 'js', etc. - * @return mixed If $type is null or not provided, the first content-type in the - * list, based on preference, is returned. If a single type is provided - * a boolean will be returned if that type is preferred. - * If an array of types are provided then the first preferred type is returned. - * If no type is provided the first preferred type is returned. - */ - public function prefers($type = null) - { - $controller = $this->getController(); - $request = $controller->request; - $response = $controller->response; - $acceptRaw = $request->parseAccept(); - - if (empty($acceptRaw)) { - return $this->ext; - } - $accepts = $response->mapType(array_shift($acceptRaw)); - - if (!$type) { - if (empty($this->ext) && !empty($accepts)) { - return $accepts[0]; - } - - return $this->ext; - } - - $types = (array)$type; - - if (count($types) === 1) { - if ($this->ext) { - return in_array($this->ext, $types); - } - - return in_array($types[0], $accepts); - } - - $intersect = array_values(array_intersect($accepts, $types)); - if (!$intersect) { - return false; - } - - return $intersect[0]; - } - - /** - * Sets either the view class if one exists or the layout and template path of the view. - * The names of these are derived from the $type input parameter. - * - * ### Usage: - * - * Render the response as an 'ajax' response. - * - * ``` - * $this->RequestHandler->renderAs($this, 'ajax'); - * ``` - * - * Render the response as an xml file and force the result as a file download. - * - * ``` - * $this->RequestHandler->renderAs($this, 'xml', ['attachment' => 'myfile.xml']; - * ``` - * - * @param \Cake\Controller\Controller $controller A reference to a controller object - * @param string $type Type of response to send (e.g: 'ajax') - * @param array $options Array of options to use - * @return void - * @see \Cake\Controller\Component\RequestHandlerComponent::respondAs() - */ - public function renderAs(Controller $controller, $type, array $options = []) - { - $defaults = ['charset' => 'UTF-8']; - $viewClassMap = $this->getConfig('viewClassMap'); - - if (Configure::read('App.encoding') !== null) { - $defaults['charset'] = Configure::read('App.encoding'); - } - $options += $defaults; - - $builder = $controller->viewBuilder(); - if (array_key_exists($type, $viewClassMap)) { - $view = $viewClassMap[$type]; - } else { - $view = Inflector::classify($type); - } - - $viewClass = null; - if ($builder->getClassName() === null) { - $viewClass = App::className($view, 'View', 'View'); - } - - if ($viewClass) { - $controller->viewClass = $viewClass; - $builder->setClassName($viewClass); - } else { - if (!$this->_renderType) { - $builder->setTemplatePath($builder->getTemplatePath() . DIRECTORY_SEPARATOR . $type); - } else { - $builder->setTemplatePath(preg_replace( - "/([\/\\\\]{$this->_renderType})$/", - DIRECTORY_SEPARATOR . $type, - $builder->getTemplatePath() - )); - } - - $this->_renderType = $type; - $builder->setLayoutPath($type); - } - - $response = $controller->response; - if ($response->getMimeType($type)) { - $this->respondAs($type, $options); - } - } - - /** - * Sets the response header based on type map index name. This wraps several methods - * available on Cake\Http\Response. It also allows you to use Content-Type aliases. - * - * @param string|array $type Friendly type name, i.e. 'html' or 'xml', or a full content-type, - * like 'application/x-shockwave'. - * @param array $options If $type is a friendly type name that is associated with - * more than one type of content, $index is used to select which content-type to use. - * @return bool Returns false if the friendly type name given in $type does - * not exist in the type map, or if the Content-type header has - * already been set by this method. - */ - public function respondAs($type, array $options = []) - { - $defaults = ['index' => null, 'charset' => null, 'attachment' => false]; - $options += $defaults; - - $cType = $type; - $controller = $this->getController(); - $response = $controller->response; - $request = $controller->request; - - if (strpos($type, '/') === false) { - $cType = $response->getMimeType($type); - } - if (is_array($cType)) { - if (isset($cType[$options['index']])) { - $cType = $cType[$options['index']]; - } - - if ($this->prefers($cType)) { - $cType = $this->prefers($cType); - } else { - $cType = $cType[0]; - } - } - - if (!$type) { - return false; - } - if (!$request->getParam('requested')) { - $response->type($cType); - } - if (!empty($options['charset'])) { - $response->charset($options['charset']); - } - if (!empty($options['attachment'])) { - $response->download($options['attachment']); - } - - return true; - } - - /** - * Returns the current response type (Content-type header), or null if not alias exists - * - * @return mixed A string content type alias, or raw content type if no alias map exists, - * otherwise null - */ - public function responseType() - { - $response = $this->getController()->response; - - return $response->mapType($response->type()); - } - - /** - * Maps a content type alias back to its mime-type(s) - * - * @param string|array $alias String alias to convert back into a content type. Or an array of aliases to map. - * @return string|null|array Null on an undefined alias. String value of the mapped alias type. If an - * alias maps to more than one content type, the first one will be returned. If an array is provided - * for $alias, an array of mapped types will be returned. - */ - public function mapAlias($alias) - { - if (is_array($alias)) { - return array_map([$this, 'mapAlias'], $alias); - } - $response = $this->getController()->response; - $type = $response->getMimeType($alias); - if ($type) { - if (is_array($type)) { - return $type[0]; - } - - return $type; - } - - return null; - } - - /** - * Add a new mapped input type. Mapped input types are automatically - * converted by RequestHandlerComponent during the startup() callback. - * - * @param string $type The type alias being converted, ie. json - * @param array $handler The handler array for the type. The first index should - * be the handling callback, all other arguments should be additional parameters - * for the handler. - * @return void - * @throws \Cake\Core\Exception\Exception - * @deprecated 3.1.0 Use setConfig('addInputType', ...) instead. - */ - public function addInputType($type, $handler) - { - trigger_error( - 'RequestHandlerComponent::addInputType() is deprecated. Use setConfig("inputTypeMap", ...) instead.', - E_USER_DEPRECATED - ); - if (!is_array($handler) || !isset($handler[0]) || !is_callable($handler[0])) { - throw new Exception('You must give a handler callback.'); - } - $this->setConfig('inputTypeMap.' . $type, $handler); - } - - /** - * Getter/setter for viewClassMap - * - * @param array|string|null $type The type string or array with format `['type' => 'viewClass']` to map one or more - * @param array|null $viewClass The viewClass to be used for the type without `View` appended - * @return array|string Returns viewClass when only string $type is set, else array with viewClassMap - * @deprecated 3.1.0 Use setConfig('viewClassMap', ...) instead. - */ - public function viewClassMap($type = null, $viewClass = null) - { - trigger_error( - 'RequestHandlerComponent::viewClassMap() is deprecated. Use setConfig("viewClassMap", ...) instead.', - E_USER_DEPRECATED - ); - if (!$viewClass && is_string($type)) { - return $this->getConfig('viewClassMap.' . $type); - } - if (is_string($type)) { - $this->setConfig('viewClassMap.' . $type, $viewClass); - } elseif (is_array($type)) { - $this->setConfig('viewClassMap', $type, true); - } - - return $this->getConfig('viewClassMap'); - } -} diff --git a/src/Controller/Component/SecurityComponent.php b/src/Controller/Component/SecurityComponent.php deleted file mode 100644 index b62180c3bdc..00000000000 --- a/src/Controller/Component/SecurityComponent.php +++ /dev/null @@ -1,656 +0,0 @@ - null, - 'requireSecure' => [], - 'requireAuth' => [], - 'allowedControllers' => [], - 'allowedActions' => [], - 'unlockedFields' => [], - 'unlockedActions' => [], - 'validatePost' => true - ]; - - /** - * Holds the current action of the controller - * - * @var string - */ - protected $_action; - - /** - * The Session object - * - * @var \Cake\Network\Session - */ - public $session; - - /** - * Component startup. All security checking happens here. - * - * @param \Cake\Event\Event $event An Event instance - * @return mixed - */ - public function startup(Event $event) - { - /** @var \Cake\Controller\Controller $controller */ - $controller = $event->getSubject(); - $this->session = $controller->request->getSession(); - $this->_action = $controller->request->getParam('action'); - $hasData = ($controller->request->getData() || $controller->request->is(['put', 'post', 'delete', 'patch'])); - try { - $this->_secureRequired($controller); - $this->_authRequired($controller); - - $isNotRequestAction = !$controller->request->getParam('requested'); - - if ($this->_action === $this->_config['blackHoleCallback']) { - throw new AuthSecurityException(sprintf('Action %s is defined as the blackhole callback.', $this->_action)); - } - - if (!in_array($this->_action, (array)$this->_config['unlockedActions']) && - $hasData && - $isNotRequestAction && - $this->_config['validatePost']) { - $this->_validatePost($controller); - } - } catch (SecurityException $se) { - $this->blackHole($controller, $se->getType(), $se); - } - - $this->generateToken($controller->request); - if ($hasData && is_array($controller->request->getData())) { - unset($controller->request->data['_Token']); - } - } - - /** - * Events supported by this component. - * - * @return array - */ - public function implementedEvents() - { - return [ - 'Controller.startup' => 'startup', - ]; - } - - /** - * Sets the actions that require a request that is SSL-secured, or empty for all actions - * - * @param string|array|null $actions Actions list - * @return void - */ - public function requireSecure($actions = null) - { - $this->_requireMethod('Secure', (array)$actions); - } - - /** - * Sets the actions that require whitelisted form submissions. - * - * Adding actions with this method will enforce the restrictions - * set in SecurityComponent::$allowedControllers and - * SecurityComponent::$allowedActions. - * - * @param string|array $actions Actions list - * @return void - * @deprecated 3.2.2 This feature is confusing and not useful. - */ - public function requireAuth($actions) - { - $this->_requireMethod('Auth', (array)$actions); - } - - /** - * Black-hole an invalid request with a 400 error or custom callback. If SecurityComponent::$blackHoleCallback - * is specified, it will use this callback by executing the method indicated in $error - * - * @param \Cake\Controller\Controller $controller Instantiating controller - * @param string $error Error method - * @param \Cake\Controller\Exception\SecurityException|null $exception Additional debug info describing the cause - * @return mixed If specified, controller blackHoleCallback's response, or no return otherwise - * @see \Cake\Controller\Component\SecurityComponent::$blackHoleCallback - * @link https://book.cakephp.org/3.0/en/controllers/components/security.html#handling-blackhole-callbacks - * @throws \Cake\Network\Exception\BadRequestException - */ - public function blackHole(Controller $controller, $error = '', SecurityException $exception = null) - { - if (!$this->_config['blackHoleCallback']) { - $this->_throwException($exception); - } - - return $this->_callback($controller, $this->_config['blackHoleCallback'], [$error, $exception]); - } - - /** - * Check debug status and throw an Exception based on the existing one - * - * @param \Cake\Controller\Exception\SecurityException|null $exception Additional debug info describing the cause - * @throws \Cake\Network\Exception\BadRequestException - * @return void - */ - protected function _throwException($exception = null) - { - if ($exception !== null) { - if (!Configure::read('debug') && $exception instanceof SecurityException) { - $exception->setReason($exception->getMessage()); - $exception->setMessage(self::DEFAULT_EXCEPTION_MESSAGE); - } - throw $exception; - } - throw new BadRequestException(self::DEFAULT_EXCEPTION_MESSAGE); - } - - /** - * Sets the actions that require a $method HTTP request, or empty for all actions - * - * @param string $method The HTTP method to assign controller actions to - * @param array $actions Controller actions to set the required HTTP method to. - * @return void - */ - protected function _requireMethod($method, $actions = []) - { - if (isset($actions[0]) && is_array($actions[0])) { - $actions = $actions[0]; - } - $this->setConfig('require' . $method, empty($actions) ? ['*'] : $actions); - } - - /** - * Check if access requires secure connection - * - * @param \Cake\Controller\Controller $controller Instantiating controller - * @return bool true if secure connection required - */ - protected function _secureRequired(Controller $controller) - { - if (is_array($this->_config['requireSecure']) && - !empty($this->_config['requireSecure']) - ) { - $requireSecure = $this->_config['requireSecure']; - - if (in_array($this->_action, $requireSecure) || $requireSecure === ['*']) { - if (!$this->request->is('ssl')) { - throw new SecurityException( - 'Request is not SSL and the action is required to be secure' - ); - } - } - } - - return true; - } - - /** - * Check if authentication is required - * - * @param \Cake\Controller\Controller $controller Instantiating controller - * @return bool true if authentication required - * @deprecated 3.2.2 This feature is confusing and not useful. - */ - protected function _authRequired(Controller $controller) - { - $request = $controller->request; - if (is_array($this->_config['requireAuth']) && - !empty($this->_config['requireAuth']) && - $request->getData() - ) { - $requireAuth = $this->_config['requireAuth']; - - if (in_array($request->getParam('action'), $requireAuth) || $requireAuth == ['*']) { - if ($request->getData('_Token') === null) { - throw new AuthSecurityException('\'_Token\' was not found in request data.'); - } - - if ($this->session->check('_Token')) { - $tData = $this->session->read('_Token'); - - if (!empty($tData['allowedControllers']) && - !in_array($request->getParam('controller'), $tData['allowedControllers'])) { - throw new AuthSecurityException( - sprintf( - 'Controller \'%s\' was not found in allowed controllers: \'%s\'.', - $request->getParam('controller'), - implode(', ', (array)$tData['allowedControllers']) - ) - ); - } - if (!empty($tData['allowedActions']) && - !in_array($request->getParam('action'), $tData['allowedActions']) - ) { - throw new AuthSecurityException( - sprintf( - 'Action \'%s::%s\' was not found in allowed actions: \'%s\'.', - $request->getParam('controller'), - $request->getParam('action'), - implode(', ', (array)$tData['allowedActions']) - ) - ); - } - } else { - throw new AuthSecurityException('\'_Token\' was not found in session.'); - } - } - } - - return true; - } - - /** - * Validate submitted form - * - * @param \Cake\Controller\Controller $controller Instantiating controller - * @throws \Cake\Controller\Exception\AuthSecurityException - * @return bool true if submitted form is valid - */ - protected function _validatePost(Controller $controller) - { - $token = $this->_validToken($controller); - $hashParts = $this->_hashParts($controller); - $check = Security::hash(implode('', $hashParts), 'sha1'); - - if ($token === $check) { - return true; - } - - $msg = self::DEFAULT_EXCEPTION_MESSAGE; - if (Configure::read('debug')) { - $msg = $this->_debugPostTokenNotMatching($controller, $hashParts); - } - - throw new AuthSecurityException($msg); - } - - /** - * Check if token is valid - * - * @param \Cake\Controller\Controller $controller Instantiating controller - * @throws \Cake\Controller\Exception\SecurityException - * @return string fields token - */ - protected function _validToken(Controller $controller) - { - $check = $controller->request->getData(); - - $message = '\'%s\' was not found in request data.'; - if (!isset($check['_Token'])) { - throw new AuthSecurityException(sprintf($message, '_Token')); - } - if (!isset($check['_Token']['fields'])) { - throw new AuthSecurityException(sprintf($message, '_Token.fields')); - } - if (!isset($check['_Token']['unlocked'])) { - throw new AuthSecurityException(sprintf($message, '_Token.unlocked')); - } - if (Configure::read('debug') && !isset($check['_Token']['debug'])) { - throw new SecurityException(sprintf($message, '_Token.debug')); - } - if (!Configure::read('debug') && isset($check['_Token']['debug'])) { - throw new SecurityException('Unexpected \'_Token.debug\' found in request data'); - } - - $token = urldecode($check['_Token']['fields']); - if (strpos($token, ':')) { - list($token, ) = explode(':', $token, 2); - } - - return $token; - } - - /** - * Return hash parts for the Token generation - * - * @param \Cake\Controller\Controller $controller Instantiating controller - * @return array - */ - protected function _hashParts(Controller $controller) - { - $fieldList = $this->_fieldsList($controller->request->getData()); - $unlocked = $this->_sortedUnlocked($controller->request->getData()); - - return [ - $controller->request->here(), - serialize($fieldList), - $unlocked, - Security::getSalt() - ]; - } - - /** - * Return the fields list for the hash calculation - * - * @param array $check Data array - * @return array - */ - protected function _fieldsList(array $check) - { - $locked = ''; - $token = urldecode($check['_Token']['fields']); - $unlocked = $this->_unlocked($check); - - if (strpos($token, ':')) { - list($token, $locked) = explode(':', $token, 2); - } - unset($check['_Token'], $check['_csrfToken']); - - $locked = explode('|', $locked); - $unlocked = explode('|', $unlocked); - - $fields = Hash::flatten($check); - $fieldList = array_keys($fields); - $multi = $lockedFields = []; - $isUnlocked = false; - - foreach ($fieldList as $i => $key) { - if (preg_match('/(\.\d+){1,10}$/', $key)) { - $multi[$i] = preg_replace('/(\.\d+){1,10}$/', '', $key); - unset($fieldList[$i]); - } else { - $fieldList[$i] = (string)$key; - } - } - if (!empty($multi)) { - $fieldList += array_unique($multi); - } - - $unlockedFields = array_unique( - array_merge((array)$this->getConfig('disabledFields'), (array)$this->_config['unlockedFields'], $unlocked) - ); - - foreach ($fieldList as $i => $key) { - $isLocked = (is_array($locked) && in_array($key, $locked)); - - if (!empty($unlockedFields)) { - foreach ($unlockedFields as $off) { - $off = explode('.', $off); - $field = array_values(array_intersect(explode('.', $key), $off)); - $isUnlocked = ($field === $off); - if ($isUnlocked) { - break; - } - } - } - - if ($isUnlocked || $isLocked) { - unset($fieldList[$i]); - if ($isLocked) { - $lockedFields[$key] = $fields[$key]; - } - } - } - sort($fieldList, SORT_STRING); - ksort($lockedFields, SORT_STRING); - $fieldList += $lockedFields; - - return $fieldList; - } - - /** - * Get the unlocked string - * - * @param array $data Data array - * @return string - */ - protected function _unlocked(array $data) - { - return urldecode($data['_Token']['unlocked']); - } - - /** - * Get the sorted unlocked string - * - * @param array $data Data array - * @return string - */ - protected function _sortedUnlocked($data) - { - $unlocked = $this->_unlocked($data); - $unlocked = explode('|', $unlocked); - sort($unlocked, SORT_STRING); - - return implode('|', $unlocked); - } - - /** - * Create a message for humans to understand why Security token is not matching - * - * @param \Cake\Controller\Controller $controller Instantiating controller - * @param array $hashParts Elements used to generate the Token hash - * @return string Message explaining why the tokens are not matching - */ - protected function _debugPostTokenNotMatching(Controller $controller, $hashParts) - { - $messages = []; - $expectedParts = json_decode(urldecode($controller->request->getData('_Token.debug')), true); - if (!is_array($expectedParts) || count($expectedParts) !== 3) { - return 'Invalid security debug token.'; - } - $expectedUrl = Hash::get($expectedParts, 0); - $url = Hash::get($hashParts, 0); - if ($expectedUrl !== $url) { - $messages[] = sprintf('URL mismatch in POST data (expected \'%s\' but found \'%s\')', $expectedUrl, $url); - } - $expectedFields = Hash::get($expectedParts, 1); - $dataFields = Hash::get($hashParts, 1); - if ($dataFields) { - $dataFields = unserialize($dataFields); - } - $fieldsMessages = $this->_debugCheckFields( - $dataFields, - $expectedFields, - 'Unexpected field \'%s\' in POST data', - 'Tampered field \'%s\' in POST data (expected value \'%s\' but found \'%s\')', - 'Missing field \'%s\' in POST data' - ); - $expectedUnlockedFields = Hash::get($expectedParts, 2); - $dataUnlockedFields = Hash::get($hashParts, 2) ?: null; - if ($dataUnlockedFields) { - $dataUnlockedFields = explode('|', $dataUnlockedFields); - } - $unlockFieldsMessages = $this->_debugCheckFields( - (array)$dataUnlockedFields, - $expectedUnlockedFields, - 'Unexpected unlocked field \'%s\' in POST data', - null, - 'Missing unlocked field: \'%s\'' - ); - - $messages = array_merge($messages, $fieldsMessages, $unlockFieldsMessages); - - return implode(', ', $messages); - } - - /** - * Iterates data array to check against expected - * - * @param array $dataFields Fields array, containing the POST data fields - * @param array $expectedFields Fields array, containing the expected fields we should have in POST - * @param string $intKeyMessage Message string if unexpected found in data fields indexed by int (not protected) - * @param string $stringKeyMessage Message string if tampered found in data fields indexed by string (protected) - * @param string $missingMessage Message string if missing field - * @return array Messages - */ - protected function _debugCheckFields($dataFields, $expectedFields = [], $intKeyMessage = '', $stringKeyMessage = '', $missingMessage = '') - { - $messages = $this->_matchExistingFields($dataFields, $expectedFields, $intKeyMessage, $stringKeyMessage); - $expectedFieldsMessage = $this->_debugExpectedFields($expectedFields, $missingMessage); - if ($expectedFieldsMessage !== null) { - $messages[] = $expectedFieldsMessage; - } - - return $messages; - } - - /** - * Manually add form tampering prevention token information into the provided - * request object. - * - * @param \Cake\Http\ServerRequest $request The request object to add into. - * @return bool - */ - public function generateToken(ServerRequest $request) - { - if ($request->is('requested')) { - if ($this->session->check('_Token')) { - $request->params['_Token'] = $this->session->read('_Token'); - } - - return false; - } - $token = [ - 'allowedControllers' => $this->_config['allowedControllers'], - 'allowedActions' => $this->_config['allowedActions'], - 'unlockedFields' => $this->_config['unlockedFields'], - ]; - - $this->session->write('_Token', $token); - $request->params['_Token'] = [ - 'unlockedFields' => $token['unlockedFields'] - ]; - - return true; - } - - /** - * Calls a controller callback method - * - * @param \Cake\Controller\Controller $controller Instantiating controller - * @param string $method Method to execute - * @param array $params Parameters to send to method - * @return mixed Controller callback method's response - * @throws \Cake\Network\Exception\BadRequestException When a the blackholeCallback is not callable. - */ - protected function _callback(Controller $controller, $method, $params = []) - { - if (!is_callable([$controller, $method])) { - throw new BadRequestException('The request has been black-holed'); - } - - return call_user_func_array([&$controller, $method], empty($params) ? null : $params); - } - - /** - * Generate array of messages for the existing fields in POST data, matching dataFields in $expectedFields - * will be unset - * - * @param array $dataFields Fields array, containing the POST data fields - * @param array $expectedFields Fields array, containing the expected fields we should have in POST - * @param string $intKeyMessage Message string if unexpected found in data fields indexed by int (not protected) - * @param string $stringKeyMessage Message string if tampered found in data fields indexed by string (protected) - * @return array Error messages - */ - protected function _matchExistingFields($dataFields, &$expectedFields, $intKeyMessage, $stringKeyMessage) - { - $messages = []; - foreach ((array)$dataFields as $key => $value) { - if (is_int($key)) { - $foundKey = array_search($value, (array)$expectedFields); - if ($foundKey === false) { - $messages[] = sprintf($intKeyMessage, $value); - } else { - unset($expectedFields[$foundKey]); - } - } elseif (is_string($key)) { - if (isset($expectedFields[$key]) && $value !== $expectedFields[$key]) { - $messages[] = sprintf($stringKeyMessage, $key, $expectedFields[$key], $value); - } - unset($expectedFields[$key]); - } - } - - return $messages; - } - - /** - * Generate debug message for the expected fields - * - * @param array $expectedFields Expected fields - * @param string $missingMessage Message template - * @return string|null Error message about expected fields - */ - protected function _debugExpectedFields($expectedFields = [], $missingMessage = '') - { - if (count($expectedFields) === 0) { - return null; - } - - $expectedFieldNames = []; - foreach ((array)$expectedFields as $key => $expectedField) { - if (is_int($key)) { - $expectedFieldNames[] = $expectedField; - } else { - $expectedFieldNames[] = $key; - } - } - - return sprintf($missingMessage, implode(', ', $expectedFieldNames)); - } -} diff --git a/src/Controller/ComponentRegistry.php b/src/Controller/ComponentRegistry.php index f395c8c220d..238dddf651b 100644 --- a/src/Controller/ComponentRegistry.php +++ b/src/Controller/ComponentRegistry.php @@ -1,4 +1,6 @@ */ class ComponentRegistry extends ObjectRegistry implements EventDispatcherInterface { - use EventDispatcherTrait; + use ArgumentResolverTrait; + + use ArgumentReflectorTrait; + /** - * The controller that this collection was initialized with. + * The controller that this collection is associated with. * - * @var \Cake\Controller\Controller + * @var \Cake\Controller\Controller|null */ - protected $_Controller; + protected ?Controller $_Controller = null; + + /** + * @var \Cake\Core\ContainerInterface|null + */ + protected ?ContainerInterface $container = null; /** * Constructor. * * @param \Cake\Controller\Controller|null $controller Controller instance. + * @param \Cake\Core\ContainerInterface|null $container Container instance. */ - public function __construct(Controller $controller = null) + public function __construct(?Controller $controller = null, ?ContainerInterface $container = null) { - if ($controller) { + if ($controller !== null) { $this->setController($controller); } + $this->container = $container; } /** - * Get the controller associated with the collection. + * Set the controller associated with the collection. * - * @return \Cake\Controller\Controller Controller instance + * @param \Cake\Controller\Controller $controller Controller instance. + * @return $this */ - public function getController() + public function setController(Controller $controller) { - return $this->_Controller; + $this->_Controller = $controller; + $this->setEventManager($controller->getEventManager()); + + return $this; } /** - * Set the controller associated with the collection. + * Get the controller associated with the collection. * - * @param \Cake\Controller\Controller $controller Controller instance. - * @return void + * @return \Cake\Controller\Controller Controller instance. */ - public function setController(Controller $controller) + public function getController(): Controller { - $this->_Controller = $controller; - $this->setEventManager($controller->getEventManager()); + if ($this->_Controller === null) { + throw new RuntimeException('Controller must be set first.'); + } + + return $this->_Controller; } /** * Resolve a component classname. * - * Part of the template method for Cake\Core\ObjectRegistry::load() + * Part of the template method for {@link \Cake\Core\ObjectRegistry::load()}. * * @param string $class Partial classname to resolve. - * @return string|false Either the correct classname or false. + * @return class-string<\Cake\Controller\Component>|null Either the correct class name or null. */ - protected function _resolveClassName($class) + protected function _resolveClassName(string $class): ?string { + /** @var class-string<\Cake\Controller\Component>|null */ return App::className($class, 'Controller/Component', 'Component'); } /** * Throws an exception when a component is missing. * - * Part of the template method for Cake\Core\ObjectRegistry::load() - * and Cake\Core\ObjectRegistry::unload() + * Part of the template method for {@link \Cake\Core\ObjectRegistry::load()} + * and {@link \Cake\Core\ObjectRegistry::unload()} * * @param string $class The classname that is missing. - * @param string $plugin The plugin the component is missing in. + * @param string|null $plugin The plugin the component is missing in. * @return void * @throws \Cake\Controller\Exception\MissingComponentException */ - protected function _throwMissingClassError($class, $plugin) + protected function _throwMissingClassError(string $class, ?string $plugin): void { throw new MissingComponentException([ 'class' => $class . 'Component', - 'plugin' => $plugin + 'plugin' => $plugin, ]); } /** * Create the component instance. * - * Part of the template method for Cake\Core\ObjectRegistry::load() + * Part of the template method for {@link \Cake\Core\ObjectRegistry::load()} * Enabled components will be registered with the event manager. * - * @param string $class The classname to create. + * ## Container Resolution + * + * When a container is available, this method attempts to resolve components from it. + * Components registered in the container will be resolved using dependency injection. + * If not registered, a new definition will be created with auto-wired constructor arguments. + * + * ## Edge Cases + * + * - **Shared instances**: Components registered as shared instances in the container + * will have their config merged via setConfig(). This means multiple controller + * instances may share the same component instance, which could lead to unexpected + * state sharing between requests. + * - **Manual registration**: Components manually registered in the container with + * specific constructor arguments will use those arguments. The `$config` parameter + * will be merged into the component after instantiation using setConfig(). + * + * @param \Cake\Controller\Component|class-string<\Cake\Controller\Component> $class The classname to create. * @param string $alias The alias of the component. - * @param array $config An array of config to use for the component. + * @param array $config An array of config to use for the component. * @return \Cake\Controller\Component The constructed component class. */ - protected function _create($class, $alias, $config) + protected function _create(object|string $class, string $alias, array $config): Component { - $instance = new $class($this, $config); - $enable = isset($config['enabled']) ? $config['enabled'] : true; - if ($enable) { + if (is_object($class)) { + return $class; + } + if ($this->container?->has($class)) { + // Check if definition already exists - if so, user has manually configured it + $hasDefinition = false; + try { + $this->container->extend($class); + $hasDefinition = true; + } catch (NotFoundExceptionInterface) { + // No definition exists yet + } + + if (!$hasDefinition) { + // No user-defined configuration - add auto-wired arguments + $constructor = (new ReflectionClass($class))->getConstructor(); + if ($constructor !== null) { + $args = $this->reflectArguments($constructor, ['config' => $config]); + $this->container->add($class)->addArguments($args); + } + } + + /** @var \Cake\Controller\Component $instance */ + $instance = $this->container->get($class); + + // For manually configured components, merge runtime config + if ($hasDefinition && $config) { + $instance->setConfig($config); + } + } else { + $instance = new $class($this, $config); + } + + if ($config['enabled'] ?? true) { $this->getEventManager()->on($instance); } return $instance; } + + /** + * Get container instance. + * + * @return \Cake\Core\ContainerInterface + */ + protected function getContainer(): ContainerInterface + { + if ($this->container === null) { + throw new CakeException('Container not set.'); + } + + return $this->container; + } + + /** + * Reflect on constructor arguments and build argument list for container. + * + * This method inspects a constructor's parameters and builds a list of + * arguments that can be passed to the container's add() or extend() methods. + * + * @param \ReflectionFunctionAbstract $method The constructor to reflect on + * @param array $args Named arguments to pass as literals (e.g., ['config' => []]) + * @return array<\League\Container\Argument\LiteralArgument|\League\Container\Argument\ResolvableArgument> + */ + protected function reflectArguments(ReflectionFunctionAbstract $method, array $args = []): array + { + $arguments = []; + $params = $method->getParameters(); + + foreach ($params as $param) { + $name = $param->getName(); + + // If we have a literal value for this parameter, use it + if (array_key_exists($name, $args)) { + $arguments[] = new LiteralArgument($args[$name]); + continue; + } + + // Check if parameter has a type hint + $type = $param->getType(); + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + // Type-hinted parameter - resolve from container + $arguments[] = new ResolvableArgument($type->getName()); + continue; + } + + // Check for default value + if ($param->isDefaultValueAvailable()) { + $arguments[] = new LiteralArgument($param->getDefaultValue()); + continue; + } + + // No type hint, no default, no provided value - this will fail at runtime + $declaringClass = $method instanceof ReflectionMethod + ? $method->getDeclaringClass()->getName() + : 'unknown'; + + throw new CakeException( + sprintf( + 'Cannot auto-wire parameter $%s in %s - no type hint or default value', + $name, + $declaringClass, + ), + ); + } + + return $this->resolveArguments($arguments); + } + + /** + * Get the mode of the container. + * + * This method is used to determine how the container should resolve + * dependencies and arguments. + * + * @return int The mode of the container. + * @internal + */ + protected function getMode(): int + { + return ReflectionContainer::AUTO_WIRING; + } } diff --git a/src/Controller/Controller.php b/src/Controller/Controller.php index b3cb310d88f..bf48b0322e7 100644 --- a/src/Controller/Controller.php +++ b/src/Controller/Controller.php @@ -1,4 +1,6 @@ request`. The request object + * You can access request parameters, using `$this->getRequest()`. The request object * contains all the POST, GET and FILES that were part of the request. * * After performing the required action, controllers are responsible for * creating a response. This usually takes the form of a generated `View`, or - * possibly a redirection to another URL. In either case `$this->response` + * possibly a redirection to another URL. In either case `$this->getResponse()` * allows you to manipulate all aspects of the response. * - * Controllers are created by `Dispatcher` based on request parameters and + * Controllers are created based on request parameters and * routing. By default controllers and actions use conventional names. * For example `/posts/index` maps to `PostsController::index()`. You can re-map - * URLs using Router::connect() or RouterBuilder::connect(). + * URLs using Router::connect() or RouteBuilder::connect(). * * ### Life cycle callbacks * @@ -64,35 +79,26 @@ * By implementing a method you can receive the related events. The available * callbacks are: * - * - `beforeFilter(Event $event)` + * - `beforeFilter(EventInterface $event)` * Called before each action. This is a good place to do general logic that * applies to all actions. - * - `beforeRender(Event $event)` + * - `beforeRender(EventInterface $event)` * Called before the view is rendered. - * - `beforeRedirect(Event $event, $url, Response $response)` + * - `beforeRedirect(EventInterface $event, $url, Response $response)` * Called before a redirect is done. - * - `afterFilter(Event $event)` + * - `afterFilter(EventInterface $event)` * Called after each action is complete and after the view is rendered. * - * @property \Cake\Controller\Component\AuthComponent $Auth - * @property \Cake\Controller\Component\CookieComponent $Cookie - * @property \Cake\Controller\Component\CsrfComponent $Csrf * @property \Cake\Controller\Component\FlashComponent $Flash - * @property \Cake\Controller\Component\PaginatorComponent $Paginator - * @property \Cake\Controller\Component\RequestHandlerComponent $RequestHandler - * @property \Cake\Controller\Component\SecurityComponent $Security - * @method bool isAuthorized($user) - * @link https://book.cakephp.org/3.0/en/controllers.html + * @property \Cake\Controller\Component\FormProtectionComponent $FormProtection + * @property \Cake\Controller\Component\CheckHttpCacheComponent $CheckHttpCache + * @link https://book.cakephp.org/5/en/controllers.html */ class Controller implements EventListenerInterface, EventDispatcherInterface { - use EventDispatcherTrait; use LocatorAwareTrait; use LogTrait; - use MergeVariablesTrait; - use ModelAwareTrait; - use RequestActionTrait; use ViewVarsTrait; /** @@ -102,57 +108,44 @@ class Controller implements EventListenerInterface, EventDispatcherInterface * * @var string */ - public $name; - - /** - * An array containing the names of helpers this controller uses. The array elements should - * not contain the "Helper" part of the class name. - * - * Example: - * ``` - * public $helpers = ['Form', 'Html', 'Time']; - * ``` - * - * @var array - * @link https://book.cakephp.org/3.0/en/controllers.html#configuring-helpers-to-load - */ - public $helpers = []; + protected string $name; /** * An instance of a \Cake\Http\ServerRequest object that contains information about the current request. * This object contains all the information about a request and several methods for reading * additional information about the request. * - * @var \Cake\Http\ServerRequest|null - * @link https://book.cakephp.org/3.0/en/controllers/request-response.html#request + * @var \Cake\Http\ServerRequest + * @link https://book.cakephp.org/5/en/controllers/request-response.html#request */ - public $request; + protected ServerRequest $request; /** * An instance of a Response object that contains information about the impending response * - * @var \Cake\Http\Response|null - * @link https://book.cakephp.org/3.0/en/controllers/request-response.html#response + * @var \Cake\Http\Response + * @link https://book.cakephp.org/5/en/controllers/request-response.html#response */ - public $response; + protected Response $response; /** - * The class name to use for creating the response object. + * Pagination settings. * - * @var string - */ - protected $_responseClass = 'Cake\Http\Response'; - - /** - * Settings for pagination. + * When calling paginate() these settings will be merged with the configuration + * you provide. Possible keys: * - * Used to pre-configure pagination preferences for the various - * tables your controller will be paginating. + * - `maxLimit` - The maximum limit users can choose to view. Defaults to 100 + * - `limit` - The initial number of items per page. Defaults to 20. + * - `page` - The starting page, defaults to 1. + * - `allowedParameters` - A list of parameters users are allowed to set using request + * parameters. Modifying this list will allow users to have more influence + * over pagination, be careful with what you permit. + * - `className` - The paginator class to use. Defaults to `Cake\Datasource\Paging\NumericPaginator::class`. * - * @var array - * @see \Cake\Controller\Component\PaginatorComponent + * @var array + * @see \Cake\Datasource\Paging\NumericPaginator */ - public $paginate = []; + protected array $paginate = []; /** * Set to true to automatically render the view @@ -160,62 +153,35 @@ class Controller implements EventListenerInterface, EventDispatcherInterface * * @var bool */ - public $autoRender = true; + protected bool $autoRender = true; /** * Instance of ComponentRegistry used to create Components * - * @var \Cake\Controller\ComponentRegistry + * @var \Cake\Controller\ComponentRegistry|null */ - protected $_components; + protected ?ComponentRegistry $_components = null; /** - * Array containing the names of components this controller uses. Component names - * should not contain the "Component" portion of the class name. - * - * Example: - * ``` - * public $components = ['RequestHandler', 'Acl']; - * ``` - * - * @var array - * @link https://book.cakephp.org/3.0/en/controllers/components.html - */ - public $components = []; - - /** - * Instance of the View created during rendering. Won't be set until after - * Controller::render() is called. - * - * @var \Cake\View\View - * @deprecated 3.1.0 Use viewBuilder() instead. - */ - public $View; - - /** - * These Controller properties will be passed from the Controller to the View as options. + * Automatically set to the name of a plugin. * - * @var array - * @see \Cake\View\View + * @var string|null */ - protected $_validViewOptions = [ - 'passedArgs' - ]; + protected ?string $plugin = null; /** - * Automatically set to the name of a plugin. + * Middlewares list. * - * @var string|null + * @var array */ - public $plugin; + protected array $middlewares = []; /** - * Holds all passed params. + * View classes for content negotiation. * - * @var array - * @deprecated 3.1.0 Use `$this->request->getParam('pass')` instead. + * @var array */ - public $passedArgs = []; + protected array $viewClasses = []; /** * Constructor. @@ -223,47 +189,50 @@ class Controller implements EventListenerInterface, EventDispatcherInterface * Sets a number of properties based on conventions if they are empty. To override the * conventions CakePHP uses you can define properties in your class declaration. * - * @param \Cake\Http\ServerRequest|null $request Request object for this controller. Can be null for testing, + * @param \Cake\Http\ServerRequest $request Request object for this controller. * but expect that features that use the request parameters will not work. - * @param \Cake\Http\Response|null $response Response object for this controller. * @param string|null $name Override the name useful in testing when using mocks. - * @param \Cake\Event\EventManager|null $eventManager The event manager. Defaults to a new instance. - * @param \Cake\Controller\ComponentRegistry|null $components The component registry. Defaults to a new instance. - */ - public function __construct(ServerRequest $request = null, Response $response = null, $name = null, $eventManager = null, $components = null) - { + * @param \Cake\Event\EventManagerInterface|null $eventManager The event manager. Defaults to a new instance. + * @param \Cake\Controller\ComponentRegistry|null $components ComponentRegistry to use. Defaults to a new instance. + */ + public function __construct( + ServerRequest $request, + ?string $name = null, + ?EventManagerInterface $eventManager = null, + ?ComponentRegistry $components = null, + ) { if ($name !== null) { $this->name = $name; + } elseif (!isset($this->name)) { + $controller = $request->getParam('controller'); + if ($controller) { + $this->name = $controller; + } } - if ($this->name === null && $request && $request->getParam('controller')) { - $this->name = $request->getParam('controller'); - } - - if ($this->name === null) { - list(, $name) = namespaceSplit(get_class($this)); + if (!isset($this->name)) { + [, $name] = namespaceSplit(static::class); $this->name = substr($name, 0, -10); } - $this->setRequest($request !== null ? $request : new ServerRequest()); - $this->response = $response !== null ? $response : new Response(); + $this->setRequest($request); + $this->response = new Response(); if ($eventManager !== null) { $this->setEventManager($eventManager); } - - $this->modelFactory('Table', [$this->getTableLocator(), 'get']); - $modelClass = ($this->plugin ? $this->plugin . '.' : '') . $this->name; - $this->_setModelClass($modelClass); - if ($components !== null) { - $this->components($components); + $this->_components = $components; + $components->setController($this); + } + if ($this->defaultTable === null) { + $plugin = $this->request->getParam('plugin'); + $tableAlias = ($plugin ? $plugin . '.' : '') . $this->name; + $this->defaultTable = $tableAlias; } $this->initialize(); - $this->_mergeControllerVars(); - $this->_loadComponents(); $this->getEventManager()->on($this); } @@ -275,118 +244,201 @@ public function __construct(ServerRequest $request = null, Response $response = * * @return void */ - public function initialize() + public function initialize(): void { } /** * Get the component registry for this controller. * - * If called with the first parameter, it will be set as the controller $this->_components property - * - * @param \Cake\Controller\ComponentRegistry|null $components Component registry. - * * @return \Cake\Controller\ComponentRegistry */ - public function components($components = null) + public function components(): ComponentRegistry { - if ($components === null && $this->_components === null) { - $this->_components = new ComponentRegistry($this); - } - if ($components !== null) { - $components->setController($this); - $this->_components = $components; - } - - return $this->_components; + return $this->_components ??= new ComponentRegistry($this); } /** * Add a component to the controller's registry. * - * This method will also set the component to a property. + * After loading a component it will be accessible as a property through Controller::__get(). * For example: * * ``` - * $this->loadComponent('Acl.Acl'); + * $this->loadComponent('Authentication.Authentication'); * ``` * - * Will result in a `Toolbar` property being set. + * Will result in a `$this->Authentication` being a reference to that component. * * @param string $name The name of the component to load. - * @param array $config The config for the component. + * @param array $config The config for the component. * @return \Cake\Controller\Component + * @throws \Exception + * @link https://book.cakephp.org/5/en/controllers.html#configuring-components-to-load */ - public function loadComponent($name, array $config = []) + public function loadComponent(string $name, array $config = []): Component { - list(, $prop) = pluginSplit($name); + [, $alias] = pluginSplit($name); + + if ($this->defaultTable) { + if (str_contains($this->defaultTable, '\\')) { + $tableAlias = App::shortName($this->defaultTable, 'Model/Table', 'Table'); + } else { + [, $tableAlias] = pluginSplit($this->defaultTable, true); + } + + if ($alias === $tableAlias) { + triggerWarning(sprintf( + 'Component alias `%s` clashes with the default table name `%s`. ' . + 'The table name will take precedence when accessing `$this->%s`. ' . + 'Consider using a different component alias or set `Controller::$defaultTable` to ' . + "an empty string if the controller doesn't use a table.", + $alias, + $this->defaultTable, + $alias, + )); + } + } - return $this->{$prop} = $this->components()->load($name, $config); + return $this->components()->load($name, $config); } /** - * Magic accessor for model autoloading. + * Magic accessor for the default table. * * @param string $name Property name - * @return bool|object The model instance or false + * @return \Cake\Controller\Component|\Cake\ORM\Table|null */ - public function __get($name) + public function __get(string $name): mixed { - $deprecated = [ - 'layout' => 'getLayout', - 'view' => 'getTemplate', - 'theme' => 'getTheme', - 'autoLayout' => 'isAutoLayoutEnabled', - 'viewPath' => 'getTemplatePath', - 'layoutPath' => 'getLayoutPath', - ]; - if (isset($deprecated[$name])) { - $method = $deprecated[$name]; - trigger_error( - sprintf('Controller::$%s is deprecated. Use $this->viewBuilder()->%s() instead.', $name, $method), - E_USER_DEPRECATED - ); + if ($this->defaultTable) { + if (str_contains($this->defaultTable, '\\')) { + $class = App::shortName($this->defaultTable, 'Model/Table', 'Table'); + } else { + [, $class] = pluginSplit($this->defaultTable, true); + } - return $this->viewBuilder()->{$method}(); + if ($class === $name) { + return $this->fetchTable(); + } } - list($plugin, $class) = pluginSplit($this->modelClass, true); - if ($class !== $name) { - return false; + if ($this->components()->has($name)) { + return $this->components()->get($name); } - return $this->loadModel($plugin . $class); + $trace = debug_backtrace(); + $parts = explode('\\', static::class); + trigger_error( + sprintf( + 'Undefined property `%s::$%s` in `%s` on line %s', + array_pop($parts), + $name, + $trace[0]['file'] ?? 'unknown', + $trace[0]['line'] ?? 'unknown', + ), + E_USER_NOTICE, + ); + + return null; } /** - * Magic setter for removed properties. + * Returns the controller name. * - * @param string $name Property name. - * @param mixed $value Value to set. - * @return void + * @return string + * @since 3.6.0 */ - public function __set($name, $value) + public function getName(): string { - $deprecated = [ - 'layout' => 'setLayout', - 'view' => 'setTemplate', - 'theme' => 'setTheme', - 'autoLayout' => 'enableAutoLayout', - 'viewPath' => 'setTemplatePath', - 'layoutPath' => 'setLayoutPath', - ]; - if (isset($deprecated[$name])) { - $method = $deprecated[$name]; - trigger_error( - sprintf('Controller::$%s is deprecated. Use $this->viewBuilder()->%s() instead.', $name, $method), - E_USER_DEPRECATED - ); - $this->viewBuilder()->{$method}($value); + return $this->name; + } - return; - } + /** + * Sets the controller name. + * + * @param string $name Controller name. + * @return $this + * @since 3.6.0 + */ + public function setName(string $name) + { + $this->name = $name; + + return $this; + } + + /** + * Returns the plugin name. + * + * @return string|null + * @since 3.6.0 + */ + public function getPlugin(): ?string + { + return $this->plugin; + } + + /** + * Sets the plugin name. + * + * @param string|null $name Plugin name. + * @return $this + * @since 3.6.0 + */ + public function setPlugin(?string $name) + { + $this->plugin = $name; + + return $this; + } + + /** + * Returns true if an action should be rendered automatically. + * + * @return bool + * @since 3.6.0 + */ + public function isAutoRenderEnabled(): bool + { + return $this->autoRender; + } + + /** + * Enable automatic action rendering. + * + * @return $this + * @since 3.6.0 + */ + public function enableAutoRender() + { + $this->autoRender = true; + + return $this; + } - $this->{$name} = $value; + /** + * Disable automatic action rendering. + * + * @return $this + * @since 3.6.0 + */ + public function disableAutoRender() + { + $this->autoRender = false; + + return $this; + } + + /** + * Gets the request instance. + * + * @return \Cake\Http\ServerRequest + * @since 3.6.0 + */ + public function getRequest(): ServerRequest + { + return $this->request; } /** @@ -395,96 +447,161 @@ public function __set($name, $value) * which must also be updated here. The properties that get set are: * * - $this->request - To the $request parameter - * - $this->plugin - To the $request->params['plugin'] - * - $this->passedArgs - Same as $request->params['pass] - * - View::$plugin - $this->plugin * * @param \Cake\Http\ServerRequest $request Request instance. - * @return void + * @return $this */ public function setRequest(ServerRequest $request) { $this->request = $request; - $this->plugin = $request->getParam('plugin') ?: null; + $this->plugin = $request->getParam('plugin'); - if ($request->getParam('pass')) { - $this->passedArgs = $request->getParam('pass'); - } + return $this; + } + + /** + * Gets the response instance. + * + * @return \Cake\Http\Response + * @since 3.6.0 + */ + public function getResponse(): Response + { + return $this->response; + } + + /** + * Sets the response instance. + * + * @param \Cake\Http\Response $response Response instance. + * @return $this + * @since 3.6.0 + */ + public function setResponse(Response $response) + { + $this->response = $response; + + return $this; } /** - * Dispatches the controller action. Checks that the action - * exists and isn't private. + * Get the closure for action to be invoked by ControllerFactory. * - * @return mixed The resulting response. - * @throws \LogicException When request is not set. - * @throws \Cake\Controller\Exception\MissingActionException When actions are not defined or inaccessible. + * @return \Closure + * @throws \Cake\Controller\Exception\MissingActionException */ - public function invokeAction() + public function getAction(): Closure { $request = $this->request; - if (!isset($request)) { - throw new LogicException('No Request object configured. Cannot invoke action'); - } - if (!$this->isAction($request->getParam('action'))) { + /** @var string $action */ + $action = $request->getParam('action'); + $controller = $this->name . 'Controller'; + + if (!$this->isAction($action)) { throw new MissingActionException([ - 'controller' => $this->name . 'Controller', - 'action' => $request->getParam('action'), - 'prefix' => $request->getParam('prefix') ?: '', - 'plugin' => $request->getParam('plugin'), + 'controller' => $controller, + 'action' => $action, + 'prefix' => $request->getParam('prefix') ?? null, + 'plugin' => $this->plugin ?? null, ]); } - /* @var callable $callable */ - $callable = [$this, $request->getParam('action')]; - return $callable(...array_values($request->getParam('pass'))); + return $this->$action(...); } /** - * Merge components, helpers vars from - * parent classes. + * Dispatches the controller action. * + * @param \Closure $action The action closure. + * @param array $args The arguments to be passed when invoking action. * @return void */ - protected function _mergeControllerVars() + public function invokeAction(Closure $action, array $args): void { - $this->_mergeVars( - ['components', 'helpers'], - ['associative' => ['components', 'helpers']] - ); + $result = $action(...$args); + if ($result !== null) { + assert( + $result instanceof Response, + sprintf( + 'Controller actions can only return Response instance or null. ' + . 'Got %s instead.', + get_debug_type($result), + ), + ); + } elseif ($this->isAutoRenderEnabled()) { + $result = $this->render(); + } + if ($result) { + $this->response = $result; + } } /** - * Returns a list of all events that will fire in the controller during its lifecycle. - * You can override this function to add your own listener callbacks + * Register middleware for the controller. * - * @return array + * @param \Psr\Http\Server\MiddlewareInterface|\Closure|string $middleware Middleware. + * @param array{only?: array|string, except?: array|string} $options Valid options: + * - `only`: (array|string) Only run the middleware for specified actions. + * - `except`: (array|string) Run the middleware for all actions except the specified ones. + * @return void + * @since 4.3.0 */ - public function implementedEvents() + public function middleware(MiddlewareInterface|Closure|string $middleware, array $options = []): void { - return [ - 'Controller.initialize' => 'beforeFilter', - 'Controller.beforeRender' => 'beforeRender', - 'Controller.beforeRedirect' => 'beforeRedirect', - 'Controller.shutdown' => 'afterFilter', + $this->middlewares[] = [ + 'middleware' => $middleware, + 'options' => $options, ]; } /** - * Loads the defined components using the Component factory. + * Get middleware to be applied for this controller. * - * @return void + * @return array + * @since 4.3.0 */ - protected function _loadComponents() + public function getMiddleware(): array { - if (empty($this->components)) { - return; - } - $registry = $this->components(); - $components = $registry->normalizeArray($this->components); - foreach ($components as $properties) { - $this->loadComponent($properties['class'], $properties['config']); + $matching = []; + $action = $this->request->getParam('action'); + + foreach ($this->middlewares as $middleware) { + $options = $middleware['options']; + if (!empty($options['only'])) { + if (in_array($action, (array)$options['only'], true)) { + $matching[] = $middleware['middleware']; + } + + continue; + } + + if ( + !empty($options['except']) && + in_array($action, (array)$options['except'], true) + ) { + continue; + } + + $matching[] = $middleware['middleware']; } + + return $matching; + } + + /** + * Returns a list of all events that will fire in the controller during its lifecycle. + * You can override this function to add your own listener callbacks + * + * @return array + */ + public function implementedEvents(): array + { + return [ + 'Controller.initialize' => 'beforeFilter', + 'Controller.beforeRender' => 'beforeRender', + 'Controller.beforeRedirect' => 'beforeRedirect', + 'Controller.shutdown' => 'afterFilter', + ]; } /** @@ -495,17 +612,18 @@ protected function _loadComponents() * - Calls the controller `beforeFilter`. * - triggers Component `startup` methods. * - * @return \Cake\Http\Response|null + * @return \Psr\Http\Message\ResponseInterface|null */ - public function startupProcess() + public function startupProcess(): ?ResponseInterface { - $event = $this->dispatchEvent('Controller.initialize'); - if ($event->getResult() instanceof Response) { - return $event->getResult(); + $result = $this->dispatchEvent('Controller.initialize')->getResult(); + if ($result instanceof ResponseInterface) { + return $result; } - $event = $this->dispatchEvent('Controller.startup'); - if ($event->getResult() instanceof Response) { - return $event->getResult(); + + $result = $this->dispatchEvent('Controller.startup')->getResult(); + if ($result instanceof ResponseInterface) { + return $result; } return null; @@ -518,13 +636,13 @@ public function startupProcess() * - triggers the component `shutdown` callback. * - calls the Controller's `afterFilter` method. * - * @return \Cake\Http\Response|null + * @return \Psr\Http\Message\ResponseInterface|null */ - public function shutdownProcess() + public function shutdownProcess(): ?ResponseInterface { - $event = $this->dispatchEvent('Controller.shutdown'); - if ($event->getResult() instanceof Response) { - return $event->getResult(); + $result = $this->dispatchEvent('Controller.shutdown')->getResult(); + if ($result instanceof ResponseInterface) { + return $result; } return null; @@ -533,28 +651,32 @@ public function shutdownProcess() /** * Redirects to given $url, after turning off $this->autoRender. * - * @param string|array $url A string or array-based URL pointing to another location within the app, - * or an absolute URL - * @param int $status HTTP status code (eg: 301) + * @param \Psr\Http\Message\UriInterface|array|string $url A string, array-based URL or UriInterface instance. + * @param int $status HTTP status code. Defaults to `302`. * @return \Cake\Http\Response|null - * @link https://book.cakephp.org/3.0/en/controllers.html#Controller::redirect + * @link https://book.cakephp.org/5/en/controllers.html#redirecting-to-other-pages */ - public function redirect($url, $status = 302) + public function redirect(UriInterface|array|string $url, int $status = 302): ?Response { $this->autoRender = false; - $response = $this->response; - if ($status) { - $response = $response->withStatus($status); + if ($status < 300 || $status > 399) { + throw new InvalidArgumentException( + sprintf('Invalid status code `%s`. It should be within the range ' . + '`300` - `399` for redirect responses.', $status), + ); } - $event = $this->dispatchEvent('Controller.beforeRedirect', [$url, $response]); - if ($event->getResult() instanceof Response) { - return $this->response = $event->getResult(); + $this->response = $this->response->withStatus($status); + $event = $this->dispatchEvent('Controller.beforeRedirect', [$url, $this->response]); + $result = $event->getResult(); + if ($result instanceof Response) { + return $this->response = $result; } if ($event->isStopped()) { return null; } + $response = $this->response; if (!$response->getHeaderLine('Location')) { $response = $response->withLocation(Router::url($url, true)); @@ -563,49 +685,30 @@ public function redirect($url, $status = 302) return $this->response = $response; } - /** - * Internally redirects one action to another. Does not perform another HTTP request unlike Controller::redirect() - * - * Examples: - * - * ``` - * setAction('another_action'); - * setAction('action_with_parameters', $parameter1); - * ``` - * - * @param string $action The new action to be 'redirected' to. - * Any other parameters passed to this method will be passed as parameters to the new action. - * @param array ...$args Arguments passed to the action - * @return mixed Returns the return value of the called action - */ - public function setAction($action, ...$args) - { - $this->request = $this->request->withParam('action', $action); - - return $this->$action(...$args); - } - /** * Instantiates the correct view class, hands it its data, and uses it to render the view output. * - * @param string|null $view View to use for rendering + * @param string|null $template Template to use for rendering * @param string|null $layout Layout to use * @return \Cake\Http\Response A response object containing the rendered view. - * @link https://book.cakephp.org/3.0/en/controllers.html#rendering-a-view + * @link https://book.cakephp.org/5/en/controllers.html#rendering-a-view */ - public function render($view = null, $layout = null) + public function render(?string $template = null, ?string $layout = null): Response { $builder = $this->viewBuilder(); if (!$builder->getTemplatePath()) { - $builder->setTemplatePath($this->_viewPath()); + $builder->setTemplatePath($this->_templatePath()); } - if ($this->request->getParam('bare')) { - $builder->enableAutoLayout(false); + $this->autoRender = false; + + if ($template !== null) { + $builder->setTemplate($template); } - $builder->getClassName($this->viewClass); - $this->autoRender = false; + if ($layout !== null) { + $builder->setLayout($layout); + } $event = $this->dispatchEvent('Controller.beforeRender'); if ($event->getResult() instanceof Response) { @@ -615,107 +718,208 @@ public function render($view = null, $layout = null) return $this->response; } - if ($builder->getTemplate() === null && $this->request->getParam('action')) { + if ($builder->getTemplate() === null) { $builder->setTemplate($this->request->getParam('action')); } + $viewClass = $this->chooseViewClass(); + $view = $this->createView($viewClass); - $this->View = $this->createView(); - $contents = $this->View->render($view, $layout); - $this->response = $this->View->response->withStringBody($contents); + $contents = $view->render(); + $response = $view->getResponse()->withStringBody($contents); - return $this->response; + return $this->setResponse($response)->response; + } + + /** + * Get the View classes this controller can perform content negotiation with. + * + * Each view class must implement the `getContentType()` hook method + * to participate in negotiation. + * + * @see \Cake\Http\ContentTypeNegotiation + * @return array + */ + public function viewClasses(): array + { + return $this->viewClasses; + } + + /** + * Add View classes this controller can perform content negotiation with. + * + * Each view class must implement the `getContentType()` hook method + * to participate in negotiation. + * + * @param array $viewClasses View classes list. + * @return $this + * @see \Cake\Http\ContentTypeNegotiation + * @since 4.5.0 + */ + public function addViewClasses(array $viewClasses) + { + $this->viewClasses = array_merge($this->viewClasses, $viewClasses); + + return $this; + } + + /** + * Use the view classes defined on this controller to view + * selection based on content-type negotiation. + * + * @return string|null The chosen view class or null for no decision. + */ + protected function chooseViewClass(): ?string + { + $possibleViewClasses = $this->viewClasses(); + if (!$possibleViewClasses) { + return null; + } + // Controller or component has already made a view class decision. + // That decision should overwrite the framework behavior. + if ($this->viewBuilder()->getClassName() !== null) { + return null; + } + + $typeMap = []; + foreach ($possibleViewClasses as $class) { + /** @var string $viewContentType */ + $viewContentType = $class::contentType(); + if ($viewContentType && !isset($typeMap[$viewContentType])) { + $typeMap[$viewContentType] = $class; + } + } + $request = $this->getRequest(); + + // Prefer the _ext route parameter if it is defined. + $ext = $request->getParam('_ext'); + if ($ext) { + $extTypes = MimeType::getMimeTypes($ext) ?? []; + foreach ($extTypes as $extType) { + if (isset($typeMap[$extType])) { + return $typeMap[$extType]; + } + } + + throw new NotFoundException(sprintf('View class for `%s` extension not found', $ext)); + } + + // Use accept header based negotiation. + $contentType = new ContentTypeNegotiation(); + $preferredType = $contentType->preferredType($request, array_keys($typeMap)); + if ($preferredType) { + // If the matched type is not in the client's top-priority Accept group + // but HTML is, the client actually prefers an HTML response. Return null + // so the default HTML view is used instead of a lower-priority match + // (e.g. application/xml at q=0.9 when text/html at q=1.0 was skipped). + $parsed = $contentType->parseAccept($request); + $topGroup = reset($parsed) ?: []; + if ( + !in_array($preferredType, $topGroup, true) && + array_intersect($topGroup, ['text/html', 'application/xhtml+xml']) + ) { + return null; + } + + return $typeMap[$preferredType]; + } + + // Use the match-all view if available or null for no decision. + return $typeMap[View::TYPE_MATCH_ALL] ?? null; } /** - * Get the viewPath based on controller name and request prefix. + * Get the templatePath based on controller name and request prefix. * * @return string */ - protected function _viewPath() + protected function _templatePath(): string { - $viewPath = $this->name; + $templatePath = $this->name; if ($this->request->getParam('prefix')) { $prefixes = array_map( 'Cake\Utility\Inflector::camelize', - explode('/', $this->request->getParam('prefix')) + explode('/', $this->request->getParam('prefix')), ); - $viewPath = implode(DIRECTORY_SEPARATOR, $prefixes) . DIRECTORY_SEPARATOR . $viewPath; + $templatePath = implode(DIRECTORY_SEPARATOR, $prefixes) . DIRECTORY_SEPARATOR . $templatePath; } - return $viewPath; + return $templatePath; } /** * Returns the referring URL for this request. * - * @param string|array|null $default Default URL to use if HTTP_REFERER cannot be read from headers - * @param bool $local If true, restrict referring URLs to local server + * @param array|string|null $default Default URL to use if HTTP_REFERER cannot be read from headers + * @param bool $local If false, do not restrict referring URLs to local server. + * Careful with trusting external sources. * @return string Referring URL */ - public function referer($default = null, $local = false) + public function referer(array|string|null $default = '/', bool $local = true): string { - if (!$this->request) { - return Router::url($default, !$local); - } - $referer = $this->request->referer($local); - if ($referer === '/' && $default && $default !== $referer) { - $url = Router::url($default, !$local); - $base = $this->request->getAttribute('base'); - if ($local && $base && strpos($url, $base) === 0) { - $url = substr($url, strlen($base)); - if ($url[0] !== '/') { - $url = '/' . $url; - } + if ($referer !== null) { + return $referer; + } - return $url; + $url = Router::url($default, !$local); + $base = $this->request->getAttribute('base'); + if ($local && $base && str_starts_with($url, $base)) { + $url = substr($url, strlen($base)); + if (!str_starts_with($url, '/')) { + return '/' . $url; } return $url; } - return $referer; + return $url; } /** * Handles pagination of records in Table objects. * - * Will load the referenced Table object, and have the PaginatorComponent + * Will load the referenced Table object, and have the paginator * paginate the query using the request date and settings defined in `$this->paginate`. * * This method will also make the PaginatorHelper available in the view. * - * @param \Cake\ORM\Table|string|\Cake\ORM\Query|null $object Table to paginate + * @param \Cake\Datasource\RepositoryInterface|\Cake\Datasource\QueryInterface|string|null $object Table to paginate * (e.g: Table instance, 'TableName' or a Query object) - * @param array $settings The settings/configuration used for pagination. - * @return \Cake\ORM\ResultSet|\Cake\Datasource\ResultSetInterface Query results - * @link https://book.cakephp.org/3.0/en/controllers.html#paginating-a-model - * @throws \RuntimeException When no compatible table object can be found. + * @param array $settings The settings/configuration used for pagination. See {@link \Cake\Controller\Controller::$paginate}. + * @return \Cake\Datasource\Paging\PaginatedInterface + * @link https://book.cakephp.org/5/en/controllers.html#paginating-a-model + * @throws \Cake\Http\Exception\NotFoundException When a page out of bounds is requested. */ - public function paginate($object = null, array $settings = []) - { - if (is_object($object)) { - $table = $object; + public function paginate( + RepositoryInterface|QueryInterface|string|null $object = null, + array $settings = [], + ): PaginatedInterface { + if (!is_object($object)) { + $object = $this->fetchTable($object); } - if (is_string($object) || $object === null) { - $try = [$object, $this->modelClass]; - foreach ($try as $tableName) { - if (empty($tableName)) { - continue; - } - $table = $this->loadModel($tableName); - break; - } - } + $settings += $this->paginate; + + /** @var class-string<\Cake\Datasource\Paging\PaginatorInterface> $paginator */ + $paginator = App::className( + $settings['className'] ?? NumericPaginator::class, + 'Datasource/Paging', + 'Paginator', + ); + $paginator = new $paginator(); + unset($settings['className']); - $this->loadComponent('Paginator'); - if (empty($table)) { - throw new RuntimeException('Unable to locate an object compatible with paginate.'); + try { + $results = $paginator->paginate( + $object, + $this->request->getQueryParams(), + $settings, + ); + } catch (PageOutOfBoundsException $exception) { + throw new NotFoundException(null, null, $exception); } - $settings += $this->paginate; - return $this->Paginator->paginate($table, $settings); + return $results; } /** @@ -726,47 +930,47 @@ public function paginate($object = null, array $settings = []) * and allows all public methods on all subclasses of this class. * * @param string $action The action to check. - * @return bool Whether or not the method is accessible from a URL. + * @return bool Whether the method is accessible from a URL. */ - public function isAction($action) + public function isAction(string $action): bool { - $baseClass = new ReflectionClass('Cake\Controller\Controller'); - if ($baseClass->hasMethod($action)) { + if (method_exists(self::class, $action)) { return false; } + try { $method = new ReflectionMethod($this, $action); - } catch (ReflectionException $e) { + } catch (ReflectionException) { return false; } - return $method->isPublic(); + return $method->isPublic() && $method->getName() === $action; } /** * Called before the controller action. You can use this method to configure and customize components * or perform logic that needs to happen before each controller action. * - * @param \Cake\Event\Event $event An Event instance - * @return \Cake\Http\Response|null - * @link https://book.cakephp.org/3.0/en/controllers.html#request-life-cycle-callbacks + * @param \Cake\Event\EventInterface<\Cake\Controller\Controller> $event An Event instance + * @return void + * @link https://book.cakephp.org/5/en/controllers.html#request-life-cycle-callbacks + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingNativeTypeHint */ - public function beforeFilter(Event $event) + public function beforeFilter(EventInterface $event) { - return null; } /** * Called after the controller action is run, but before the view is rendered. You can use this method * to perform logic or set view variables that are required on every request. * - * @param \Cake\Event\Event $event An Event instance - * @return \Cake\Http\Response|null - * @link https://book.cakephp.org/3.0/en/controllers.html#request-life-cycle-callbacks + * @param \Cake\Event\EventInterface<\Cake\Controller\Controller> $event An Event instance + * @return void + * @link https://book.cakephp.org/5/en/controllers.html#request-life-cycle-callbacks + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingNativeTypeHint */ - public function beforeRender(Event $event) + public function beforeRender(EventInterface $event) { - return null; } /** @@ -778,27 +982,27 @@ public function beforeRender(Event $event) * You can set the event result to response instance or modify the redirect location * using controller's response instance. * - * @param \Cake\Event\Event $event An Event instance - * @param string|array $url A string or array-based URL pointing to another location within the app, + * @param \Cake\Event\EventInterface<\Cake\Controller\Controller> $event An Event instance + * @param \Psr\Http\Message\UriInterface|array|string $url A string or array-based URL pointing to another location within the app, * or an absolute URL * @param \Cake\Http\Response $response The response object. - * @return \Cake\Http\Response|null - * @link https://book.cakephp.org/3.0/en/controllers.html#request-life-cycle-callbacks + * @return void + * @link https://book.cakephp.org/5/en/controllers.html#request-life-cycle-callbacks + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingNativeTypeHint */ - public function beforeRedirect(Event $event, $url, Response $response) + public function beforeRedirect(EventInterface $event, UriInterface|array|string $url, Response $response) { - return null; } /** * Called after the controller action is run and rendered. * - * @param \Cake\Event\Event $event An Event instance - * @return \Cake\Http\Response|null - * @link https://book.cakephp.org/3.0/en/controllers.html#request-life-cycle-callbacks + * @param \Cake\Event\EventInterface<\Cake\Controller\Controller> $event An Event instance + * @return void + * @link https://book.cakephp.org/5/en/controllers.html#request-life-cycle-callbacks + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingNativeTypeHint */ - public function afterFilter(Event $event) + public function afterFilter(EventInterface $event) { - return null; } } diff --git a/src/Controller/ControllerFactory.php b/src/Controller/ControllerFactory.php new file mode 100644 index 00000000000..9b4079287ce --- /dev/null +++ b/src/Controller/ControllerFactory.php @@ -0,0 +1,377 @@ + + */ +class ControllerFactory implements ControllerFactoryInterface, RequestHandlerInterface +{ + /** + * @var \Cake\Core\ContainerInterface + */ + protected ContainerInterface $container; + + /** + * @var \Cake\Controller\Controller + */ + protected Controller $controller; + + /** + * Constructor + * + * @param \Cake\Core\ContainerInterface $container The container to build controllers with. + */ + public function __construct(ContainerInterface $container) + { + $this->container = $container; + } + + /** + * Create a controller for a given request. + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request to build a controller for. + * @return \Cake\Controller\Controller + * @throws \Cake\Http\Exception\MissingControllerException + */ + public function create(ServerRequestInterface $request): Controller + { + assert($request instanceof ServerRequest); + $className = $this->getControllerClass($request); + if ($className === null) { + throw $this->missingController($request); + } + + $reflection = new ReflectionClass($className); + if ($reflection->isAbstract()) { + throw $this->missingController($request); + } + $this->container->addShared( + ComponentRegistry::class, + new ComponentRegistry(container: $this->container), + ); + + // Get the controller from the container if defined. + // The request is in the container by default. + if ($this->container->has($className)) { + $controller = $this->container->get($className); + } else { + $components = $this->container->get(ComponentRegistry::class); + $constructor = $reflection->getConstructor(); + assert($constructor !== null); + $hasComponents = false; + foreach ($constructor->getParameters() as $parameter) { + $paramType = $parameter->getType(); + // TODO: In a future minor release it would be good to start requiring the components parameter + if ( + $parameter->getName() === 'components' && + $paramType instanceof ReflectionNamedType && + $paramType->getName() === ComponentRegistry::class + ) { + $hasComponents = true; + break; + } + } + if ($hasComponents) { + $controller = $reflection->newInstance(request: $request, components: $components); + } else { + $controller = $reflection->newInstance($request); + } + } + + return $controller; + } + + /** + * Invoke a controller's action and wrapping methods. + * + * @param \Cake\Controller\Controller $controller The controller to invoke. + * @return \Psr\Http\Message\ResponseInterface The response + * @throws \Cake\Controller\Exception\MissingActionException If controller action is not found. + * @throws \UnexpectedValueException If return value of action method is not null or ResponseInterface instance. + */ + public function invoke(mixed $controller): ResponseInterface + { + $this->controller = $controller; + + $middlewares = $controller->getMiddleware(); + + if ($middlewares) { + $middlewareQueue = new MiddlewareQueue($middlewares, $this->container); + $runner = new Runner(); + + return $runner->run($middlewareQueue, $controller->getRequest(), $this); + } + + return $this->handle($controller->getRequest()); + } + + /** + * Invoke the action. + * + * @param \Psr\Http\Message\ServerRequestInterface $request Request instance. + * @return \Psr\Http\Message\ResponseInterface + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + assert($request instanceof ServerRequest); + $controller = $this->controller; + $controller->setRequest($request); + + $result = $controller->startupProcess(); + if ($result !== null) { + return $result; + } + + $action = $controller->getAction(); + $args = $this->getActionArgs( + $action, + array_values((array)$controller->getRequest()->getParam('pass')), + ); + $controller->invokeAction($action, $args); + + $result = $controller->shutdownProcess(); + if ($result !== null) { + return $result; + } + + return $controller->getResponse(); + } + + /** + * Get the arguments for the controller action invocation. + * + * @param \Closure $action Controller action. + * @param array $passedParams Params passed by the router. + * @return array + */ + protected function getActionArgs(Closure $action, array $passedParams): array + { + $resolved = []; + $function = new ReflectionFunction($action); + $request = $this->controller->getRequest(); + foreach ($function->getParameters() as $parameter) { + $attributeValue = $this->resolveParameterAttribute($parameter, $request); + if ($attributeValue !== null) { + $resolved[] = $attributeValue; + continue; + } + + $type = $parameter->getType(); + + // Check for dependency injection for classes + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $typeName = $type->getName(); + if ($this->container->has($typeName)) { + $resolved[] = $this->container->get($typeName); + continue; + } + + // Use passedParams as a source of typed dependencies. + // The accepted types for passedParams was never defined and userland code relies on that. + if ($passedParams && $passedParams[0] instanceof $typeName) { + $resolved[] = array_shift($passedParams); + continue; + } + + // Add default value if provided + // Do not allow positional arguments for classes + if ($parameter->isDefaultValueAvailable()) { + $resolved[] = $parameter->getDefaultValue(); + continue; + } + + throw new InvalidParameterException([ + 'template' => 'missing_dependency', + 'parameter' => $parameter->getName(), + 'type' => $typeName, + 'controller' => $this->controller->getName(), + 'action' => $this->controller->getRequest()->getParam('action'), + 'prefix' => $this->controller->getRequest()->getParam('prefix'), + 'plugin' => $this->controller->getRequest()->getParam('plugin'), + ]); + } + + // Use any passed params as positional arguments + if ($passedParams) { + $argument = array_shift($passedParams); + if (is_string($argument) && $type instanceof ReflectionNamedType) { + $typedArgument = $this->coerceStringToType($argument, $type); + + if ($typedArgument === null) { + throw new InvalidParameterException([ + 'template' => 'failed_coercion', + 'passed' => $argument, + 'type' => $type->getName(), + 'parameter' => $parameter->getName(), + 'controller' => $this->controller->getName(), + 'action' => $this->controller->getRequest()->getParam('action'), + 'prefix' => $this->controller->getRequest()->getParam('prefix'), + 'plugin' => $this->controller->getRequest()->getParam('plugin'), + ]); + } + $argument = $typedArgument; + } + + $resolved[] = $argument; + continue; + } + + // Add default value if provided + if ($parameter->isDefaultValueAvailable()) { + $resolved[] = $parameter->getDefaultValue(); + continue; + } + + // Variadic parameter can have 0 arguments + if ($parameter->isVariadic()) { + continue; + } + + throw new InvalidParameterException([ + 'template' => 'missing_parameter', + 'parameter' => $parameter->getName(), + 'controller' => $this->controller->getName(), + 'action' => $this->controller->getRequest()->getParam('action'), + 'prefix' => $this->controller->getRequest()->getParam('prefix'), + 'plugin' => $this->controller->getRequest()->getParam('plugin'), + ]); + } + + return array_merge($resolved, $passedParams); + } + + /** + * Resolve parameter value from attributes implementing ParameterAttributeInterface. + * + * @param \ReflectionParameter $parameter The parameter to resolve + * @param \Cake\Http\ServerRequest $request The server request + * @return mixed The resolved value or null if no matching attribute found + */ + protected function resolveParameterAttribute(ReflectionParameter $parameter, ServerRequest $request): mixed + { + foreach ($parameter->getAttributes() as $attribute) { + $instance = $attribute->newInstance(); + if ($instance instanceof ParameterAttributeInterface) { + return $instance->resolve($parameter, $request); + } + } + + return null; + } + + /** + * Coerces string argument to primitive type. + * + * @param string $argument Argument to coerce + * @param \ReflectionNamedType $type Parameter type + * @return array|string|float|int|bool|null + */ + protected function coerceStringToType(string $argument, ReflectionNamedType $type): array|string|float|int|bool|null + { + return match ($type->getName()) { + 'string' => $argument, + 'float' => toFloat($argument), + 'int' => toInt($argument), + 'bool' => toBool($argument), + 'array' => $argument === '' ? [] : explode(',', $argument), + default => null, + }; + } + + /** + * Determine the controller class name based on current request and controller param + * + * @param \Cake\Http\ServerRequest $request The request to build a controller for. + * @return class-string<\Cake\Controller\Controller>|null + */ + public function getControllerClass(ServerRequest $request): ?string + { + $pluginPath = ''; + $namespace = 'Controller'; + $controller = $request->getParam('controller', ''); + if ($request->getParam('plugin')) { + $pluginPath = $request->getParam('plugin') . '.'; + } + if ($request->getParam('prefix')) { + $prefix = $request->getParam('prefix'); + $namespace .= '/' . $prefix; + } + $firstChar = substr($controller, 0, 1); + + // Disallow plugin short forms, / and \\ from + // controller names as they allow direct references to + // be created. + if ( + str_contains($controller, '\\') || + str_contains($controller, '/') || + str_contains($controller, '.') || + $firstChar === strtolower($firstChar) + ) { + throw $this->missingController($request); + } + + /** @var class-string<\Cake\Controller\Controller>|null */ + return App::className($pluginPath . $controller, $namespace, 'Controller'); + } + + /** + * Throws an exception when a controller is missing. + * + * @param \Cake\Http\ServerRequest $request The request. + * @return \Cake\Http\Exception\MissingControllerException + */ + protected function missingController(ServerRequest $request): MissingControllerException + { + return new MissingControllerException([ + 'controller' => $request->getParam('controller'), + 'plugin' => $request->getParam('plugin'), + 'prefix' => $request->getParam('prefix'), + '_ext' => $request->getParam('_ext'), + ]); + } +} + +// phpcs:disable +class_alias( + 'Cake\Controller\ControllerFactory', + 'Cake\Http\ControllerFactory' +); +// phpcs:enable diff --git a/src/Controller/ErrorController.php b/src/Controller/ErrorController.php index 51b52957a3c..f153826c849 100644 --- a/src/Controller/ErrorController.php +++ b/src/Controller/ErrorController.php @@ -1,4 +1,6 @@ */ - public function initialize() + public function viewClasses(): array { - $this->loadComponent('RequestHandler'); + return [JsonView::class]; } /** * beforeRender callback. * - * @param \Cake\Event\Event $event Event. - * @return void + * @param \Cake\Event\EventInterface<\Cake\Controller\Controller> $event Event. + * @return \Cake\Http\Response|null|void */ - public function beforeRender(Event $event) + public function beforeRender(EventInterface $event) { - $this->viewBuilder()->setTemplatePath('Error'); + $builder = $this->viewBuilder(); + $templatePath = 'Error'; + + if ( + $this->request->getParam('prefix') && + in_array($builder->getTemplate(), ['error400', 'error500'], true) + ) { + $parts = explode(DIRECTORY_SEPARATOR, (string)$builder->getTemplatePath(), -1); + $templatePath = implode(DIRECTORY_SEPARATOR, $parts) . DIRECTORY_SEPARATOR . 'Error'; + } + + $builder->setTemplatePath($templatePath); } } diff --git a/src/Controller/Exception/AuthSecurityException.php b/src/Controller/Exception/AuthSecurityException.php index f902d5443ee..85b069d66bd 100644 --- a/src/Controller/Exception/AuthSecurityException.php +++ b/src/Controller/Exception/AuthSecurityException.php @@ -1,4 +1,6 @@ + */ + protected array $templates = [ + 'failed_coercion' => 'Unable to coerce `%s` to `%s` for `%s` in action `%s::%s()`.', + 'missing_dependency' => 'Failed to inject dependency from service container for parameter `%s` ' . + 'with type `%s` in action `%s::%s()`.', + 'missing_parameter' => 'Missing passed parameter for `%s` in action `%s::%s()`.', + 'unsupported_type' => 'Type declaration for `%s` in action `%s::%s()` is unsupported.', + ]; + + /** + * Switches message template based on `template` key in message array. + * + * @param array|string $message Either the string of the error message, or an array of attributes + * that are made available in the view, and sprintf()'d into Exception::$_messageTemplate + * @param int|null $code The error code + * @param \Throwable|null $previous the previous exception. + */ + public function __construct(array|string $message = '', ?int $code = null, ?Throwable $previous = null) + { + if (is_array($message)) { + $this->_messageTemplate = $this->templates[$message['template']] ?? ''; + unset($message['template']); + } + parent::__construct($message, $code, $previous); + } +} diff --git a/src/Controller/Exception/MissingActionException.php b/src/Controller/Exception/MissingActionException.php index c6653539b60..7e1d6895cf3 100644 --- a/src/Controller/Exception/MissingActionException.php +++ b/src/Controller/Exception/MissingActionException.php @@ -1,4 +1,6 @@ _type; } @@ -48,7 +72,7 @@ public function getType() * @param string $message Exception message * @return void */ - public function setMessage($message) + public function setMessage(string $message): void { $this->message = $message; } @@ -57,19 +81,21 @@ public function setMessage($message) * Set Reason * * @param string|null $reason Reason details - * @return void + * @return $this */ - public function setReason($reason = null) + public function setReason(?string $reason = null) { $this->_reason = $reason; + + return $this; } /** * Get Reason * - * @return string + * @return string|null */ - public function getReason() + public function getReason(): ?string { return $this->_reason; } diff --git a/src/Core/App.php b/src/Core/App.php index b18d09c9c27..09bb27be988 100644 --- a/src/Core/App.php +++ b/src/Core/App.php @@ -1,4 +1,6 @@ + * @link https://book.cakephp.org/5/en/core-libraries/app.html#finding-paths-to-namespaces + */ + public static function path(string $type, ?string $plugin = null): array + { + if ($plugin === null) { + return (array)Configure::read('App.paths.' . $type); + } + + return match ($type) { + 'templates' => [Plugin::templatePath($plugin)], + 'locales' => [Plugin::path($plugin) . 'resources' . DIRECTORY_SEPARATOR . 'locales' . DIRECTORY_SEPARATOR], + default => throw new CakeException(sprintf( + 'Invalid type `%s`. Only path types `templates` and `locales` are supported for plugins.', + $type, + )) + }; + } + + /** + * Gets the path to a class type in the application or a plugin. + * + * Example: * * ``` - * App::path('Model/Datasource', 'MyPlugin'); + * App::classPath('Model/Table'); * ``` * - * Will return the path for datasources under the 'MyPlugin' plugin. + * Will return the path for tables - e.g. `src/Model/Table/`. * - * @param string $type type of path - * @param string|null $plugin name of plugin - * @return array - * @link https://book.cakephp.org/3.0/en/core-libraries/app.html#finding-paths-to-namespaces + * ``` + * App::classPath('Model/Table', 'My/Plugin'); + * ``` + * + * Will return the plugin based path for those. + * + * @param string $type Package type. + * @param string|null $plugin Plugin name. + * @return array */ - public static function path($type, $plugin = null) + public static function classPath(string $type, ?string $plugin = null): array { - if ($type === 'Plugin') { - return (array)Configure::read('App.paths.plugins'); - } - if (empty($plugin) && $type === 'Locale') { - return (array)Configure::read('App.paths.locales'); - } - if (empty($plugin) && $type === 'Template') { - return (array)Configure::read('App.paths.templates'); - } - if (!empty($plugin)) { - return [Plugin::classPath($plugin) . $type . DIRECTORY_SEPARATOR]; + if ($plugin !== null) { + return [ + Plugin::classPath($plugin) . $type . DIRECTORY_SEPARATOR, + ]; } return [APP . $type . DIRECTORY_SEPARATOR]; @@ -205,10 +247,14 @@ public static function path($type, $plugin = null) * Will return the full path to the cache engines package. * * @param string $type Package type. - * @return array Full path to package + * @return array Full path to package */ - public static function core($type) + public static function core(string $type): array { + if ($type === 'templates') { + return [CORE_PATH . 'templates' . DIRECTORY_SEPARATOR]; + } + return [CAKE . str_replace('/', DIRECTORY_SEPARATOR, $type) . DIRECTORY_SEPARATOR]; } } diff --git a/src/Core/Attribute/Configure.php b/src/Core/Attribute/Configure.php new file mode 100644 index 00000000000..4bd35b9a205 --- /dev/null +++ b/src/Core/Attribute/Configure.php @@ -0,0 +1,61 @@ +name); + } +} diff --git a/src/Core/BasePlugin.php b/src/Core/BasePlugin.php new file mode 100644 index 00000000000..83ac51bcee7 --- /dev/null +++ b/src/Core/BasePlugin.php @@ -0,0 +1,374 @@ + $options Options + */ + public function __construct(array $options = []) + { + foreach (static::VALID_HOOKS as $key) { + if (isset($options[$key])) { + $this->{"{$key}Enabled"} = (bool)$options[$key]; + } + } + foreach (['name', 'path', 'classPath', 'configPath', 'templatePath'] as $path) { + if (isset($options[$path])) { + $this->{$path} = $options[$path]; + } + } + $this->initialize(); + } + + /** + * Initialization hook called from constructor. + * + * @return void + */ + public function initialize(): void + { + } + + /** + * @inheritDoc + */ + public function getName(): string + { + if ($this->name !== null) { + return $this->name; + } + $parts = explode('\\', static::class); + array_pop($parts); + + return $this->name = implode('/', $parts); + } + + /** + * @inheritDoc + */ + public function getPath(): string + { + if ($this->path !== null) { + return $this->path; + } + $reflection = new ReflectionClass($this); + $path = dirname((string)$reflection->getFileName()); + + // Trim off src + if (str_ends_with($path, 'src')) { + $path = substr($path, 0, -3); + } + + return $this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + } + + /** + * @inheritDoc + */ + public function getConfigPath(): string + { + if ($this->configPath !== null) { + return $this->configPath; + } + $path = $this->getPath(); + + return $path . 'config' . DIRECTORY_SEPARATOR; + } + + /** + * @inheritDoc + */ + public function getClassPath(): string + { + if ($this->classPath !== null) { + return $this->classPath; + } + $path = $this->getPath(); + + return $path . 'src' . DIRECTORY_SEPARATOR; + } + + /** + * @inheritDoc + */ + public function getTemplatePath(): string + { + if ($this->templatePath !== null) { + return $this->templatePath; + } + $path = $this->getPath(); + + return $this->templatePath = $path . 'templates' . DIRECTORY_SEPARATOR; + } + + /** + * @inheritDoc + */ + public function enable(string $hook) + { + $this->checkHook($hook); + $this->{"{$hook}Enabled"} = true; + + return $this; + } + + /** + * @inheritDoc + */ + public function disable(string $hook) + { + $this->checkHook($hook); + $this->{"{$hook}Enabled"} = false; + + return $this; + } + + /** + * @inheritDoc + */ + public function isEnabled(string $hook): bool + { + $this->checkHook($hook); + + return $this->{"{$hook}Enabled"} === true; + } + + /** + * Check if a hook name is valid + * + * @param string $hook The hook name to check + * @throws \InvalidArgumentException on invalid hooks + * @return void + */ + protected function checkHook(string $hook): void + { + if (!in_array($hook, static::VALID_HOOKS, true)) { + throw new InvalidArgumentException(sprintf( + '`%s` is not a valid hook name. Must be one of `%s.`', + $hook, + implode(', ', static::VALID_HOOKS), + )); + } + } + + /** + * @inheritDoc + */ + public function routes(RouteBuilder $routes): void + { + $path = $this->getConfigPath() . 'routes.php'; + if (is_file($path)) { + $return = require $path; + if ($return instanceof Closure) { + $return($routes); + } + } + } + + /** + * @inheritDoc + */ + public function bootstrap(PluginApplicationInterface $app): void + { + $bootstrap = $this->getConfigPath() . 'bootstrap.php'; + if (is_file($bootstrap)) { + require $bootstrap; + } + + $this->registerEvents($app); + } + + /** + * @inheritDoc + */ + public function console(CommandCollection $commands): CommandCollection + { + return $commands->addMany($commands->discoverPlugin($this->getName())); + } + + /** + * @inheritDoc + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + return $middlewareQueue; + } + + /** + * Register container services for this plugin. + * + * @param \Cake\Core\ContainerInterface $container The container to add services to. + * @return void + */ + public function services(ContainerInterface $container): void + { + } + + /** + * Define global event listeners for the plugin. + * + * Listener classes are resolved through the host application's container and + * can declare constructor dependencies. + * + * @return list> + */ + public function eventListeners(): array + { + return []; + } + + /** + * Register declarative and imperative application events. + * + * @param \Cake\Core\PluginApplicationInterface $app The host application + * @return void + */ + protected function registerEvents(PluginApplicationInterface $app): void + { + if (!$this->isEnabled('events')) { + return; + } + + $eventManager = $app->getEventManager(); + $listeners = $this->eventListeners(); + if ($listeners !== []) { + if (!$app instanceof ContainerApplicationInterface) { + throw new InvalidArgumentException(sprintf( + 'Plugin `%s` defines event listeners but the application does not implement %s', + $this->getName(), + ContainerApplicationInterface::class, + )); + } + + $this->registerEventListeners( + $listeners, + $eventManager, + $app->getContainer(), + ); + } + + $this->events($eventManager); + } + + /** + * Register application events. + * + * @param \Cake\Event\EventManagerInterface $eventManager The global event manager to register listeners on + * @return \Cake\Event\EventManagerInterface + */ + public function events(EventManagerInterface $eventManager): EventManagerInterface + { + return $eventManager; + } +} diff --git a/src/Core/CakeContainerBridge.php b/src/Core/CakeContainerBridge.php new file mode 100644 index 00000000000..ec2e9acae7a --- /dev/null +++ b/src/Core/CakeContainerBridge.php @@ -0,0 +1,132 @@ +container->add($id, $concrete); + + return new CakeDefinitionBridge($definition, $this); + } + + /** + * @inheritDoc + */ + public function addServiceProvider(ServiceProviderInterface $provider): static + { + if (!$provider instanceof CakeServiceProviderInterface) { + throw new InvalidArgumentException(sprintf( + 'Service provider must implement `%s` when using the CakePHP container', + CakeServiceProviderInterface::class, + )); + } + $this->container->addServiceProvider($provider); + + return $this; + } + + /** + * @inheritDoc + */ + public function addShared(string $id, mixed $concrete = null, bool $overwrite = false): DefinitionInterface + { + $definition = $this->container->addShared($id, $concrete); + + return new CakeDefinitionBridge($definition, $this); + } + + /** + * @inheritDoc + */ + public function extend(string $id): DefinitionInterface + { + $definition = $this->container->extend($id); + + return new CakeDefinitionBridge($definition, $this); + } + + /** + * @inheritDoc + */ + public function getNew(string $id): mixed + { + return $this->container->getNew($id); + } + + /** + * @inheritDoc + */ + public function inflector(string $type, ?callable $callback = null): InflectorInterface + { + $inflector = $this->container->inflector($type, $callback); + + return new CakeInflectorBridge($inflector); + } + + /** + * @inheritDoc + */ + public function get(string $id): mixed + { + return $this->container->get($id); + } + + /** + * @inheritDoc + */ + public function has(string $id): bool + { + return $this->container->has($id); + } + + /** + * @inheritDoc + */ + public function delegate(PsrContainerInterface $container): PsrContainerInterface + { + $this->container->delegate($container); + + return $this; + } +} diff --git a/src/Core/CakeDefinitionBridge.php b/src/Core/CakeDefinitionBridge.php new file mode 100644 index 00000000000..24c4aa210b0 --- /dev/null +++ b/src/Core/CakeDefinitionBridge.php @@ -0,0 +1,195 @@ +definition->addArgument($arg); + + return $this; + } + + /** + * @inheritDoc + */ + public function addArguments(array $args): DefinitionInterface + { + $this->definition->addArguments($args); + + return $this; + } + + /** + * @inheritDoc + */ + public function addMethodCall(string $method, array $args = []): DefinitionInterface + { + $this->definition->addMethodCall($method, $args); + + return $this; + } + + /** + * @inheritDoc + */ + public function addMethodCalls(array $methods = []): DefinitionInterface + { + $this->definition->addMethodCalls($methods); + + return $this; + } + + /** + * @inheritDoc + */ + public function addTag(string $tag): DefinitionInterface + { + $this->definition->addTag($tag); + + return $this; + } + + /** + * @inheritDoc + */ + public function getAlias(): string + { + return $this->definition->getAlias(); + } + + /** + * @inheritDoc + */ + public function getConcrete(): mixed + { + return $this->definition->getConcrete(); + } + + /** + * @inheritDoc + */ + public function getTags(): array + { + return $this->definition->getTags(); + } + + /** + * @inheritDoc + */ + public function hasTag(string $tag): bool + { + return $this->definition->hasTag($tag); + } + + /** + * @inheritDoc + */ + public function isShared(): bool + { + return $this->definition->isShared(); + } + + /** + * @inheritDoc + */ + public function resolve(): mixed + { + return $this->definition->resolve(); + } + + /** + * @inheritDoc + */ + public function resolveNew(): mixed + { + return $this->definition->resolveNew(); + } + + /** + * @inheritDoc + */ + public function setAlias(string $id): DefinitionInterface + { + // CakePHP's Definition doesn't have setAlias, but we can return self + return $this; + } + + /** + * @inheritDoc + */ + public function setConcrete(mixed $concrete): DefinitionInterface + { + $this->definition->setConcrete($concrete); + + return $this; + } + + /** + * @inheritDoc + */ + public function setShared(bool $shared): DefinitionInterface + { + $this->definition->setShared($shared); + + return $this; + } + + /** + * @inheritDoc + */ + public function getContainer(): DefinitionContainerInterface + { + return $this->container; + } + + /** + * @inheritDoc + */ + public function setContainer(DefinitionContainerInterface $container): ContainerAwareInterface + { + $this->container = $container; + + return $this; + } +} diff --git a/src/Core/CakeInflectorBridge.php b/src/Core/CakeInflectorBridge.php new file mode 100644 index 00000000000..3b3889a45f7 --- /dev/null +++ b/src/Core/CakeInflectorBridge.php @@ -0,0 +1,94 @@ +inflector->getType(); + } + + /** + * @inheritDoc + */ + public function invokeMethod(string $name, array $args): InflectorInterface + { + $this->inflector->invokeMethod($name, $args); + + return $this; + } + + /** + * @inheritDoc + */ + public function invokeMethods(array $methods): InflectorInterface + { + $this->inflector->invokeMethods($methods); + + return $this; + } + + /** + * @inheritDoc + */ + public function setProperty(string $property, mixed $value): InflectorInterface + { + $this->inflector->setProperty($property, $value); + + return $this; + } + + /** + * @inheritDoc + */ + public function setProperties(array $properties): InflectorInterface + { + $this->inflector->setProperties($properties); + + return $this; + } + + /** + * @inheritDoc + */ + public function inflect(object $object): void + { + $this->inflector->inflect($object); + } +} diff --git a/src/Core/ClassLoader.php b/src/Core/ClassLoader.php deleted file mode 100644 index 2f5bdefc758..00000000000 --- a/src/Core/ClassLoader.php +++ /dev/null @@ -1,136 +0,0 @@ -_prefixes[$prefix])) { - $this->_prefixes[$prefix] = []; - } - - if ($prepend) { - array_unshift($this->_prefixes[$prefix], $baseDir); - } else { - $this->_prefixes[$prefix][] = $baseDir; - } - } - - /** - * Loads the class file for a given class name. - * - * @param string $class The fully-qualified class name. - * @return string|false The mapped file name on success, or boolean false on - * failure. - */ - public function loadClass($class) - { - $prefix = $class; - - while (($pos = strrpos($prefix, '\\')) !== false) { - $prefix = substr($class, 0, $pos + 1); - $relativeClass = substr($class, $pos + 1); - - $mappedFile = $this->_loadMappedFile($prefix, $relativeClass); - if ($mappedFile) { - return $mappedFile; - } - - $prefix = rtrim($prefix, '\\'); - } - - return false; - } - - /** - * Load the mapped file for a namespace prefix and relative class. - * - * @param string $prefix The namespace prefix. - * @param string $relativeClass The relative class name. - * @return mixed Boolean false if no mapped file can be loaded, or the - * name of the mapped file that was loaded. - */ - protected function _loadMappedFile($prefix, $relativeClass) - { - if (!isset($this->_prefixes[$prefix])) { - return false; - } - - foreach ($this->_prefixes[$prefix] as $baseDir) { - $file = $baseDir . str_replace('\\', DIRECTORY_SEPARATOR, $relativeClass) . '.php'; - - if ($this->_requireFile($file)) { - return $file; - } - } - - return false; - } - - /** - * If a file exists, require it from the file system. - * - * @param string $file The file to require. - * @return bool True if the file exists, false if not. - */ - protected function _requireFile($file) - { - if (file_exists($file)) { - require $file; - - return true; - } - - return false; - } -} diff --git a/src/Core/Configure.php b/src/Core/Configure.php index 6384051b16b..cbb4bd07012 100644 --- a/src/Core/Configure.php +++ b/src/Core/Configure.php @@ -1,4 +1,6 @@ */ - protected static $_values = [ - 'debug' => false + protected static array $_values = [ + 'debug' => false, ]; /** * Configured engine classes, used to load config files from resources * * @see \Cake\Core\Configure::load() - * @var \Cake\Core\Configure\ConfigEngineInterface[] + * @var array<\Cake\Core\Configure\ConfigEngineInterface> */ - protected static $_engines = []; + protected static array $_engines = []; /** - * Flag to track whether or not ini_set exists. + * Flag to track whether ini_set exists. * * @var bool|null */ - protected static $_hasIniSet; + protected static ?bool $_hasIniSet = null; /** * Used to store a dynamic variable in Configure. @@ -75,32 +75,29 @@ class Configure * ]); * ``` * - * @param string|array $config The key to write, can be a dot notation value. + * @param array|string $config The key to write, can be a dot notation value. * Alternatively can be an array containing key(s) and value(s). - * @param mixed $value Value to set for var - * @return bool True if write was successful - * @link https://book.cakephp.org/3.0/en/development/configuration.html#writing-configuration-data + * @param mixed $value Value to set for the given key. + * @return void + * @link https://book.cakephp.org/5/en/development/configuration.html#writing-configuration-data */ - public static function write($config, $value = null) + public static function write(array|string $config, mixed $value = null): void { if (!is_array($config)) { $config = [$config => $value]; } - foreach ($config as $name => $value) { - static::$_values = Hash::insert(static::$_values, $name, $value); + foreach ($config as $name => $valueToInsert) { + static::$_values = Hash::insert(static::$_values, $name, $valueToInsert); } if (isset($config['debug'])) { - if (static::$_hasIniSet === null) { - static::$_hasIniSet = function_exists('ini_set'); - } + static::$_hasIniSet ??= function_exists('ini_set'); + if (static::$_hasIniSet) { ini_set('display_errors', $config['debug'] ? '1' : '0'); } } - - return true; } /** @@ -116,9 +113,9 @@ public static function write($config, $value = null) * @param string|null $var Variable to obtain. Use '.' to access array elements. * @param mixed $default The return value when the configure does not exist * @return mixed Value stored in configure, or null. - * @link https://book.cakephp.org/3.0/en/development/configuration.html#reading-configuration-data + * @link https://book.cakephp.org/5/en/development/configuration.html#reading-configuration-data */ - public static function read($var = null, $default = null) + public static function read(?string $var = null, mixed $default = null): mixed { if ($var === null) { return static::$_values; @@ -133,9 +130,9 @@ public static function read($var = null, $default = null) * @param string $var Variable name to check for * @return bool True if variable is there */ - public static function check($var) + public static function check(string $var): bool { - if (empty($var)) { + if (!$var) { return false; } @@ -158,13 +155,13 @@ public static function check($var) * * @param string $var Variable to obtain. Use '.' to access array elements. * @return mixed Value stored in configure. - * @throws \RuntimeException if the requested configuration is not set. - * @link https://book.cakephp.org/3.0/en/development/configuration.html#reading-configuration-data + * @throws \Cake\Core\Exception\CakeException if the requested configuration is not set. + * @link https://book.cakephp.org/5/en/development/configuration.html#reading-configuration-data */ - public static function readOrFail($var) + public static function readOrFail(string $var): mixed { - if (static::check($var) === false) { - throw new RuntimeException(sprintf('Expected configuration key "%s" not found.', $var)); + if (!static::check($var)) { + throw new CakeException(sprintf('Expected configuration key `%s` not found.', $var)); } return static::read($var); @@ -181,13 +178,35 @@ public static function readOrFail($var) * * @param string $var the var to be deleted * @return void - * @link https://book.cakephp.org/3.0/en/development/configuration.html#deleting-configuration-data + * @link https://book.cakephp.org/5/en/development/configuration.html#deleting-configuration-data */ - public static function delete($var) + public static function delete(string $var): void { static::$_values = Hash::remove(static::$_values, $var); } + /** + * Used to consume information stored in Configure. It's not + * possible to store `null` values in Configure. + * + * Acts as a wrapper around Configure::consume() and Configure::check(). + * The configure key/value pair consumed via this method is expected to exist. + * In case it does not an exception will be thrown. + * + * @param string $var Variable to consume. Use '.' to access array elements. + * @return mixed Value stored in configure. + * @throws \Cake\Core\Exception\CakeException if the requested configuration is not set. + * @since 3.6.0 + */ + public static function consumeOrFail(string $var): mixed + { + if (!static::check($var)) { + throw new CakeException(sprintf('Expected configuration key `%s` not found.', $var)); + } + + return static::consume($var); + } + /** * Used to read and delete a variable from Configure. * @@ -195,11 +214,11 @@ public static function delete($var) * out of configure into the various other classes in CakePHP. * * @param string $var The key to read and remove. - * @return array|string|null + * @return mixed The value stored in Configure, or null if the key doesn't exist. */ - public static function consume($var) + public static function consume(string $var): mixed { - if (strpos($var, '.') === false) { + if (!str_contains($var, '.')) { if (!isset(static::$_values[$var])) { return null; } @@ -230,24 +249,34 @@ public static function consume($var) * @param \Cake\Core\Configure\ConfigEngineInterface $engine The engine to append. * @return void */ - public static function config($name, ConfigEngineInterface $engine) + public static function config(string $name, ConfigEngineInterface $engine): void { static::$_engines[$name] = $engine; } + /** + * Returns true if the Engine objects is configured. + * + * @param string $name Engine name. + * @return bool + */ + public static function isConfigured(string $name): bool + { + return isset(static::$_engines[$name]); + } + /** * Gets the names of the configured Engine objects. * - * @param string|null $name Engine name. - * @return array|bool Array of the configured Engine objects, bool for specific name. + * @return array */ - public static function configured($name = null) + public static function configured(): array { - if ($name !== null) { - return isset(static::$_engines[$name]); - } + $engines = array_keys(static::$_engines); - return array_keys(static::$_engines); + return array_map(function (int|string $key) { + return (string)$key; + }, $engines); } /** @@ -257,7 +286,7 @@ public static function configured($name = null) * @param string $name Name of the engine to drop. * @return bool Success */ - public static function drop($name) + public static function drop(string $name): bool { if (!isset(static::$_engines[$name])) { return false; @@ -290,22 +319,32 @@ public static function drop($name) * @param string $key name of configuration resource to load. * @param string $config Name of the configured engine to use to read the resource identified by $key. * @param bool $merge if config files should be merged instead of simply overridden - * @return bool False if file not found, true if load successful. - * @link https://book.cakephp.org/3.0/en/development/configuration.html#reading-and-writing-configuration-files + * @return bool True if load successful. + * @throws \Cake\Core\Exception\CakeException if the $config engine is not found + * @link https://book.cakephp.org/5/en/development/configuration.html#reading-and-writing-configuration-files */ - public static function load($key, $config = 'default', $merge = true) + public static function load(string $key, string $config = 'default', bool $merge = true): bool { $engine = static::_getEngine($config); if (!$engine) { - return false; + throw new CakeException( + sprintf( + 'Config %s engine not found when attempting to load %s.', + $config, + $key, + ), + ); } + $values = $engine->read($key); if ($merge) { $values = Hash::merge(static::$_values, $values); } - return static::write($values); + static::write($values); + + return true; } /** @@ -332,23 +371,23 @@ public static function load($key, $config = 'default', $merge = true) * @param string $key The identifier to create in the config adapter. * This could be a filename or a cache key depending on the adapter being used. * @param string $config The name of the configured adapter to dump data with. - * @param array $keys The name of the top-level keys you want to dump. + * @param array $keys The name of the top-level keys you want to dump. * This allows you save only some data stored in Configure. * @return bool Success - * @throws \Cake\Core\Exception\Exception if the adapter does not implement a `dump` method. + * @throws \Cake\Core\Exception\CakeException if the adapter does not implement a `dump` method. */ - public static function dump($key, $config = 'default', $keys = []) + public static function dump(string $key, string $config = 'default', array $keys = []): bool { $engine = static::_getEngine($config); if (!$engine) { - throw new Exception(sprintf('There is no "%s" config engine.', $config)); + throw new CakeException(sprintf('There is no `%s` config engine.', $config)); } $values = static::$_values; - if (!empty($keys) && is_array($keys)) { + if ($keys) { $values = array_intersect_key($values, array_flip($keys)); } - return (bool)$engine->dump($key, $values); + return $engine->dump($key, $values); } /** @@ -356,13 +395,13 @@ public static function dump($key, $config = 'default', $keys = []) * Will create new PhpConfig for default if not configured yet. * * @param string $config The name of the configured adapter - * @return \Cake\Core\Configure\ConfigEngineInterface|false Engine instance or false + * @return \Cake\Core\Configure\ConfigEngineInterface|null Engine instance or null */ - protected static function _getEngine($config) + protected static function _getEngine(string $config): ?ConfigEngineInterface { if (!isset(static::$_engines[$config])) { if ($config !== 'default') { - return false; + return null; } static::config($config, new PhpConfig()); } @@ -380,14 +419,22 @@ protected static function _getEngine($config) * * @return string Current version of CakePHP */ - public static function version() + public static function version(): string { - if (!isset(static::$_values['Cake']['version'])) { - $config = require CORE_PATH . 'config/config.php'; + $version = static::read('Cake.version'); + if ($version !== null) { + return $version; + } + + $path = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'config/config.php'; + if (is_file($path)) { + $config = require $path; static::write($config); + + return static::read('Cake.version'); } - return static::$_values['Cake']['version']; + return 'unknown'; } /** @@ -400,10 +447,12 @@ public static function version() * @param array|null $data Either an array of data to store, or leave empty to store all values. * @return bool Success */ - public static function store($name, $cacheConfig = 'default', $data = null) + public static function store(string $name, string $cacheConfig = 'default', ?array $data = null): bool { - if ($data === null) { - $data = static::$_values; + $data ??= static::$_values; + + if (!class_exists(Cache::class)) { + throw new CakeException('You must install cakephp/cache to use Configure::store()'); } return Cache::write($name, $data, $cacheConfig); @@ -417,11 +466,16 @@ public static function store($name, $cacheConfig = 'default', $data = null) * @param string $cacheConfig Name of the Cache configuration to read from. * @return bool Success. */ - public static function restore($name, $cacheConfig = 'default') + public static function restore(string $name, string $cacheConfig = 'default'): bool { + if (!class_exists(Cache::class)) { + throw new CakeException('You must install cakephp/cache to use Configure::restore()'); + } $values = Cache::read($name, $cacheConfig); if ($values) { - return static::write($values); + static::write($values); + + return true; } return false; @@ -430,12 +484,10 @@ public static function restore($name, $cacheConfig = 'default') /** * Clear all values stored in Configure. * - * @return bool success. + * @return void */ - public static function clear() + public static function clear(): void { static::$_values = []; - - return true; } } diff --git a/src/Core/Configure/ConfigEngineInterface.php b/src/Core/Configure/ConfigEngineInterface.php index 188c0b9ad29..a8f59ade32d 100644 --- a/src/Core/Configure/ConfigEngineInterface.php +++ b/src/Core/Configure/ConfigEngineInterface.php @@ -1,4 +1,6 @@ _path = $path; + $this->_path = $path ?? CONFIG; $this->_section = $section; } @@ -93,14 +92,18 @@ public function __construct($path = null, $section = null) * @param string $key The identifier to read from. If the key has a . it will be treated * as a plugin prefix. The chosen file must be on the engine's path. * @return array Parsed configuration values. - * @throws \Cake\Core\Exception\Exception when files don't exist. + * @throws \Cake\Core\Exception\CakeException when files don't exist. * Or when files contain '..' as this could lead to abusive reads. */ - public function read($key) + public function read(string $key): array { $file = $this->_getFilePath($key, true); $contents = parse_ini_file($file, true); + if ($contents === false) { + throw new CakeException(sprintf('Cannot parse INI file `%s`', $file)); + } + if ($this->_section && isset($contents[$this->_section])) { $values = $this->_parseNestedValues($contents[$this->_section]); } else { @@ -124,7 +127,7 @@ public function read($key) * @param array $values Values to be exploded. * @return array Array of values exploded */ - protected function _parseNestedValues($values) + protected function _parseNestedValues(array $values): array { foreach ($values as $key => $value) { if ($value === '1') { @@ -134,7 +137,7 @@ protected function _parseNestedValues($values) $value = false; } unset($values[$key]); - if (strpos($key, '.') !== false) { + if (str_contains((string)$key, '.')) { $values = Hash::insert($values, $key, $value); } else { $values[$key] = $value; @@ -152,19 +155,19 @@ protected function _parseNestedValues($values) * @param array $data The data to convert to ini file. * @return bool Success. */ - public function dump($key, array $data) + public function dump(string $key, array $data): bool { $result = []; foreach ($data as $k => $value) { $isSection = false; - if ($k[0] !== '[') { - $result[] = "[$k]"; + if (!str_starts_with($k, '[')) { + $result[] = "[{$k}]"; $isSection = true; } if (is_array($value)) { $kValues = Hash::flatten($value, '.'); foreach ($kValues as $k2 => $v) { - $result[] = "$k2 = " . $this->_value($v); + $result[] = "{$k2} = " . $this->_value($v); } } if ($isSection) { @@ -184,18 +187,13 @@ public function dump($key, array $data) * @param mixed $value Value to export. * @return string String value for ini file. */ - protected function _value($value) + protected function _value(mixed $value): string { - if ($value === null) { - return 'null'; - } - if ($value === true) { - return 'true'; - } - if ($value === false) { - return 'false'; - } - - return (string)$value; + return match ($value) { + null => 'null', + true => 'true', + false => 'false', + default => (string)$value + }; } } diff --git a/src/Core/Configure/Engine/JsonConfig.php b/src/Core/Configure/Engine/JsonConfig.php index b454347933c..95388e53f5f 100644 --- a/src/Core/Configure/Engine/JsonConfig.php +++ b/src/Core/Configure/Engine/JsonConfig.php @@ -1,4 +1,6 @@ _path = $path; + $this->_path = $path ?? CONFIG; } /** @@ -70,26 +68,30 @@ public function __construct($path = null) * @param string $key The identifier to read from. If the key has a . it will be treated * as a plugin prefix. * @return array Parsed configuration values. - * @throws \Cake\Core\Exception\Exception When files don't exist or when + * @throws \Cake\Core\Exception\CakeException When files don't exist or when * files contain '..' (as this could lead to abusive reads) or when there * is an error parsing the JSON string. */ - public function read($key) + public function read(string $key): array { $file = $this->_getFilePath($key, true); - $values = json_decode(file_get_contents($file), true); + $jsonContent = file_get_contents($file); + if ($jsonContent === false) { + throw new CakeException(sprintf('Cannot read file content of `%s`', $file)); + } + $values = json_decode($jsonContent, true); if (json_last_error() !== JSON_ERROR_NONE) { - throw new Exception(sprintf( - 'Error parsing JSON string fetched from config file "%s.json": %s', + throw new CakeException(sprintf( + 'Error parsing JSON string fetched from config file `%s.json`: %s', $key, - json_last_error_msg() + json_last_error_msg(), )); } if (!is_array($values)) { - throw new Exception(sprintf( - 'Decoding JSON config file "%s.json" did not return an array', - $key + throw new CakeException(sprintf( + 'Decoding JSON config file `%s.json` did not return an array', + $key, )); } @@ -105,10 +107,10 @@ public function read($key) * @param array $data Data to dump. * @return bool Success */ - public function dump($key, array $data) + public function dump(string $key, array $data): bool { $filename = $this->_getFilePath($key); - return file_put_contents($filename, json_encode($data, JSON_PRETTY_PRINT)) > 0; + return file_put_contents($filename, json_encode($data, JSON_PRETTY_PRINT)) !== false; } } diff --git a/src/Core/Configure/Engine/PhpConfig.php b/src/Core/Configure/Engine/PhpConfig.php index 9f4fc2d5e05..74273602b27 100644 --- a/src/Core/Configure/Engine/PhpConfig.php +++ b/src/Core/Configure/Engine/PhpConfig.php @@ -1,4 +1,6 @@ 0, + * 'debug' => false, * 'Security' => [ * 'salt' => 'its-secret' * ], @@ -40,11 +42,10 @@ * ]; * ``` * - * @see Cake\Core\Configure::load() for how to load custom configuration files. + * @see \Cake\Core\Configure::load() for how to load custom configuration files. */ class PhpConfig implements ConfigEngineInterface { - use FileConfigTrait; /** @@ -52,19 +53,16 @@ class PhpConfig implements ConfigEngineInterface * * @var string */ - protected $_extension = '.php'; + protected string $_extension = '.php'; /** * Constructor for PHP Config file reading. * * @param string|null $path The path to read config files from. Defaults to CONFIG. */ - public function __construct($path = null) + public function __construct(?string $path = null) { - if ($path === null) { - $path = CONFIG; - } - $this->_path = $path; + $this->_path = $path ?? CONFIG; } /** @@ -73,15 +71,13 @@ public function __construct($path = null) * Files with `.` in the name will be treated as values in plugins. Instead of * reading from the initialized path, plugin keys will be located using Plugin::path(). * - * Setting a `$config` variable is deprecated. Use `return` instead. - * * @param string $key The identifier to read from. If the key has a . it will be treated * as a plugin prefix. * @return array Parsed configuration values. - * @throws \Cake\Core\Exception\Exception when files don't exist or they don't contain `$config`. + * @throws \Cake\Core\Exception\CakeException when files don't exist or they don't contain `$config`. * Or when files contain '..' as this could lead to abusive reads. */ - public function read($key) + public function read(string $key): array { $file = $this->_getFilePath($key, true); @@ -90,11 +86,7 @@ public function read($key) return $return; } - if (!isset($config)) { - throw new Exception(sprintf('Config file "%s" did not return an array', $key . '.php')); - } - - return $config; + throw new CakeException(sprintf('Config file `%s` did not return an array', $key . '.php.')); } /** @@ -106,7 +98,7 @@ public function read($key) * @param array $data Data to dump. * @return bool Success */ - public function dump($key, array $data) + public function dump(string $key, array $data): bool { $contents = ' new CakeContainerBridge(new CakeContainer()), + default => new Container(), + }; + } +} diff --git a/src/Core/ContainerInterface.php b/src/Core/ContainerInterface.php new file mode 100644 index 00000000000..e942306e826 --- /dev/null +++ b/src/Core/ContainerInterface.php @@ -0,0 +1,38 @@ +_attributes = $message; + $message = vsprintf($this->_messageTemplate, $message); + } + parent::__construct($message, $code ?? $this->_defaultCode, $previous); + } + + /** + * Get the passed in attributes + * + * @return array + * @psalm-taint-escape html Exception attributes are developer-defined metadata (e.g. controller + * name, validation rule names) used only in debug output — log files and CLI console — never + * rendered unescaped into HTML responses. + */ + public function getAttributes(): array + { + return $this->_attributes; + } +} diff --git a/src/Core/Exception/Exception.php b/src/Core/Exception/Exception.php deleted file mode 100644 index 6baa16ddd55..00000000000 --- a/src/Core/Exception/Exception.php +++ /dev/null @@ -1,107 +0,0 @@ -_defaultCode; - } - - if (is_array($message)) { - $this->_attributes = $message; - $message = vsprintf($this->_messageTemplate, $message); - } - parent::__construct($message, $code, $previous); - } - - /** - * Get the passed in attributes - * - * @return array - */ - public function getAttributes() - { - return $this->_attributes; - } - - /** - * Get/set the response header to be used - * - * See also Cake\Http\Response::header() - * - * @param string|array|null $header An array of header strings or a single header string - * - an associative array of "header name" => "header value" - * - an array of string headers is also accepted - * @param string|null $value The header value. - * @return array - */ - public function responseHeader($header = null, $value = null) - { - if ($header === null) { - return $this->_responseHeaders; - } - if (is_array($header)) { - return $this->_responseHeaders = $header; - } - $this->_responseHeaders = [$header => $value]; - } -} diff --git a/src/Core/Exception/HttpErrorCodeInterface.php b/src/Core/Exception/HttpErrorCodeInterface.php new file mode 100644 index 00000000000..91333136965 --- /dev/null +++ b/src/Core/Exception/HttpErrorCodeInterface.php @@ -0,0 +1,21 @@ + */ - protected $_config = []; + protected array $_config = []; /** * Whether the config property has already been configured with defaults * * @var bool */ - protected $_configInitialized = false; + protected bool $_configInitialized = false; /** * Sets the config. @@ -62,19 +64,15 @@ trait InstanceConfigTrait * $this->setConfig(['one' => 'value', 'another' => 'value']); * ``` * - * @param string|array $key The key to set, or a complete array of configs. + * @param array|string $key The key to set, or a complete array of configs. * @param mixed|null $value The value to set. * @param bool $merge Whether to recursively merge or overwrite existing config, defaults to true. * @return $this - * @throws \Cake\Core\Exception\Exception When trying to set a key that is invalid. + * @throws \Cake\Core\Exception\CakeException When trying to set a key that is invalid. */ - public function setConfig($key, $value = null, $merge = true) + public function setConfig(array|string $key, mixed $value = null, bool $merge = true) { - if (!$this->_configInitialized) { - $this->_config = $this->_defaultConfig; - $this->_configInitialized = true; - } - + $this->initCfg(); $this->_configWrite($key, $value, $merge); return $this; @@ -111,75 +109,32 @@ public function setConfig($key, $value = null, $merge = true) * * @param string|null $key The key to get or null for the whole config. * @param mixed $default The return value when the key does not exist. - * @return mixed Config value being read. + * @return ($key is null ? array : mixed) Configuration data at the named key or null if the key does not exist. */ - public function getConfig($key = null, $default = null) + public function getConfig(?string $key = null, mixed $default = null): mixed { - if (!$this->_configInitialized) { - $this->_config = $this->_defaultConfig; - $this->_configInitialized = true; - } - - $return = $this->_configRead($key); + $this->initCfg(); - return $return === null ? $default : $return; + return $this->_configRead($key) ?? $default; } /** - * Gets/Sets the config. - * - * ### Usage - * - * Reading the whole config: - * - * ``` - * $this->config(); - * ``` - * - * Reading a specific value: - * - * ``` - * $this->config('key'); - * ``` - * - * Reading a nested value: - * - * ``` - * $this->config('some.nested.key'); - * ``` - * - * Setting a specific value: - * - * ``` - * $this->config('key', $value); - * ``` - * - * Setting a nested value: + * Returns the config for this specific key. * - * ``` - * $this->config('some.nested.key', $value); - * ``` - * - * Updating multiple config settings at the same time: + * The config value for this key must exist, it can never be null. * - * ``` - * $this->config(['one' => 'value', 'another' => 'value']); - * ``` - * - * @deprecated 3.4.0 use setConfig()/getConfig() instead. - * @param string|array|null $key The key to get/set, or a complete array of configs. - * @param mixed|null $value The value to set. - * @param bool $merge Whether to recursively merge or overwrite existing config, defaults to true. - * @return mixed Config value being read, or the object itself on write operations. - * @throws \Cake\Core\Exception\Exception When trying to set a key that is invalid. + * @param string $key The key to get. + * @return mixed Configuration data at the named key + * @throws \InvalidArgumentException */ - public function config($key = null, $value = null, $merge = true) + public function getConfigOrFail(string $key): mixed { - if (is_array($key) || func_num_args() >= 2) { - return $this->setConfig($key, $value, $merge); + $config = $this->getConfig($key); + if ($config === null) { + throw new InvalidArgumentException(sprintf('Expected configuration `%s` not found.', $key)); } - return $this->getConfig($key); + return $config; } /** @@ -204,36 +159,59 @@ public function config($key = null, $value = null, $merge = true) * $this->configShallow(['one' => 'value', 'another' => 'value']); * ``` * - * @param string|array $key The key to set, or a complete array of configs. + * @param array|string $key The key to set, or a complete array of configs. * @param mixed|null $value The value to set. * @return $this */ - public function configShallow($key, $value = null) + public function configShallow(array|string $key, mixed $value = null) + { + $this->initCfg(); + $this->_configWrite($key, $value, 'shallow'); + + return $this; + } + + /** + * Deletes a config key. + * + * @param string $key Key to delete. It can be a dot separated string to delete nested keys. + * @return $this + */ + public function deleteConfig(string $key) + { + $this->initCfg(); + $this->_configDelete($key); + + return $this; + } + + /** + * Initializes the config with the default config. + * + * @return void + */ + private function initCfg(): void { if (!$this->_configInitialized) { $this->_config = $this->_defaultConfig; $this->_configInitialized = true; } - - $this->_configWrite($key, $value, 'shallow'); - - return $this; } /** * Reads a config key. * * @param string|null $key Key to read. - * @return mixed + * @return ($key is null ? array : mixed) */ - protected function _configRead($key) + protected function _configRead(?string $key): mixed { if ($key === null) { return $this->_config; } - if (strpos($key, '.') === false) { - return isset($this->_config[$key]) ? $this->_config[$key] : null; + if (!str_contains($key, '.')) { + return $this->_config[$key] ?? null; } $return = $this->_config; @@ -253,14 +231,14 @@ protected function _configRead($key) /** * Writes a config key. * - * @param string|array $key Key to write to. + * @param array|string $key Key to write to. * @param mixed $value Value to write. - * @param bool|string $merge True to merge recursively, 'shallow' for simple merge, + * @param string|bool $merge True to merge recursively, 'shallow' for simple merge, * false to overwrite, defaults to false. * @return void - * @throws \Cake\Core\Exception\Exception if attempting to clobber existing config + * @throws \Cake\Core\Exception\CakeException if attempting to clobber existing config */ - protected function _configWrite($key, $value, $merge = false) + protected function _configWrite(array|string $key, mixed $value, string|bool $merge = false): void { if (is_string($key) && $value === null) { $this->_configDelete($key); @@ -287,25 +265,23 @@ protected function _configWrite($key, $value, $merge = false) return; } - if (strpos($key, '.') === false) { + if (!str_contains($key, '.')) { $this->_config[$key] = $value; return; } - $update =& $this->_config; + $update = &$this->_config; $stack = explode('.', $key); foreach ($stack as $k) { if (!is_array($update)) { - throw new Exception(sprintf('Cannot set %s value', $key)); + throw new CakeException(sprintf('Cannot set `%s` value.', $key)); } - if (!isset($update[$k])) { - $update[$k] = []; - } + $update[$k] ??= []; - $update =& $update[$k]; + $update = &$update[$k]; } $update = $value; @@ -316,23 +292,23 @@ protected function _configWrite($key, $value, $merge = false) * * @param string $key Key to delete. * @return void - * @throws \Cake\Core\Exception\Exception if attempting to clobber existing config + * @throws \Cake\Core\Exception\CakeException if attempting to clobber existing config */ - protected function _configDelete($key) + protected function _configDelete(string $key): void { - if (strpos($key, '.') === false) { + if (!str_contains($key, '.')) { unset($this->_config[$key]); return; } - $update =& $this->_config; + $update = &$this->_config; $stack = explode('.', $key); $length = count($stack); foreach ($stack as $i => $k) { if (!is_array($update)) { - throw new Exception(sprintf('Cannot unset %s value', $key)); + throw new CakeException(sprintf('Cannot unset `%s` value.', $key)); } if (!isset($update[$k])) { @@ -344,7 +320,7 @@ protected function _configDelete($key) break; } - $update =& $update[$k]; + $update = &$update[$k]; } } } diff --git a/src/Core/LICENSE.txt b/src/Core/LICENSE.txt index 0c4b7932c31..b938c9e8ed3 100644 --- a/src/Core/LICENSE.txt +++ b/src/Core/LICENSE.txt @@ -1,7 +1,7 @@ The MIT License (MIT) CakePHP(tm) : The Rapid Development PHP Framework (https://cakephp.org) -Copyright (c) 2005-2016, Cake Software Foundation, Inc. (https://cakefoundation.org) +Copyright (c) 2005-2020, Cake Software Foundation, Inc. (https://cakefoundation.org) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/Core/ObjectRegistry.php b/src/Core/ObjectRegistry.php index 82568c76cc0..7908b33e382 100644 --- a/src/Core/ObjectRegistry.php +++ b/src/Core/ObjectRegistry.php @@ -1,4 +1,6 @@ */ abstract class ObjectRegistry implements Countable, IteratorAggregate { - /** * Map of loaded objects. * - * @var object[] + * @var array */ - protected $_loaded = []; + protected array $_loaded = []; /** * Loads/constructs an object instance. @@ -54,47 +57,59 @@ abstract class ObjectRegistry implements Countable, IteratorAggregate * If a subclass provides event support, you can use `$config['enabled'] = false` * to exclude constructed objects from being registered for events. * - * Using Cake\Controller\Controller::$components as an example. You can alias + * Using {@link \Cake\Controller\Component::$components} as an example. You can alias * an object by setting the 'className' key, i.e., * * ``` - * public $components = [ + * protected $components = [ * 'Email' => [ - * 'className' => '\App\Controller\Component\AliasedEmailComponent' + * 'className' => 'App\Controller\Component\AliasedEmailComponent' * ]; * ]; * ``` * * All calls to the `Email` component would use `AliasedEmail` instead. * - * @param string $objectName The name/class of the object to load. - * @param array $config Additional settings to use when loading the object. - * @return mixed + * @param string $name The name/class of the object to load. + * @param array $config Additional settings to use when loading the object. + * @return TObject + * @throws \Exception If the class cannot be found. */ - public function load($objectName, $config = []) + public function load(string $name, array $config = []): object { - if (is_array($config) && isset($config['className'])) { - $name = $objectName; - $objectName = $config['className']; + if (isset($config['className'])) { + if ($name === $config['className']) { + [, $objName] = pluginSplit($name); + } else { + $objName = $name; + } + $name = $config['className']; } else { - list(, $name) = pluginSplit($objectName); + [$plugin, $objName] = pluginSplit($name); + if ($plugin) { + $config['className'] = $name; + } } - $loaded = isset($this->_loaded[$name]); - if ($loaded && !empty($config)) { - $this->_checkDuplicate($name, $config); + $loaded = isset($this->_loaded[$objName]); + if ($loaded && $config !== []) { + $this->_checkDuplicate($objName, $config); } if ($loaded) { - return $this->_loaded[$name]; + return $this->_loaded[$objName]; } - $className = $this->_resolveClassName($objectName); - if (!$className || (is_string($className) && !class_exists($className))) { - list($plugin, $objectName) = pluginSplit($objectName); - $this->_throwMissingClassError($objectName, $plugin); + $className = $name; + if (is_string($name)) { + $className = $this->_resolveClassName($name); + if ($className === null) { + [$plugin, $name] = pluginSplit($name); + $this->_throwMissingClassError($name, $plugin); + } } - $instance = $this->_create($className, $name, $config); - $this->_loaded[$name] = $instance; + + $instance = $this->_create($className, $objName, $config); + $this->_loaded[$objName] = $instance; return $instance; } @@ -111,41 +126,42 @@ public function load($objectName, $config = []) * logic dependent on the configuration. * * @param string $name The name of the alias in the registry. - * @param array $config The config data for the new instance. + * @param array $config The config data for the new instance. * @return void - * @throws \RuntimeException When a duplicate is found. + * @throws \Cake\Core\Exception\CakeException When a duplicate is found. */ - protected function _checkDuplicate($name, $config) + protected function _checkDuplicate(string $name, array $config): void { - /** @var \Cake\Core\InstanceConfigTrait $existing */ $existing = $this->_loaded[$name]; - $msg = sprintf('The "%s" alias has already been loaded', $name); - $hasConfig = method_exists($existing, 'config'); + $msg = sprintf('The `%s` alias has already been loaded.', $name); + $hasConfig = method_exists($existing, 'getConfig'); if (!$hasConfig) { - throw new RuntimeException($msg); + throw new CakeException($msg); } - if (empty($config)) { + if (!$config) { return; } $existingConfig = $existing->getConfig(); unset($config['enabled'], $existingConfig['enabled']); - $fail = false; + $failure = null; foreach ($config as $key => $value) { if (!array_key_exists($key, $existingConfig)) { - $fail = true; + $failure = " The `{$key}` was not defined in the previous configuration data."; break; } if (isset($existingConfig[$key]) && $existingConfig[$key] !== $value) { - $fail = true; + $failure = sprintf( + ' The `%s` key has a value of `%s` but previously had a value of `%s`', + $key, + json_encode($value, JSON_THROW_ON_ERROR), + json_encode($existingConfig[$key], JSON_THROW_ON_ERROR), + ); break; } } - if ($fail) { - $msg .= ' with the following config: '; - $msg .= var_export($existingConfig, true); - $msg .= ' which differs from ' . var_export($config, true); - throw new RuntimeException($msg); + if ($failure) { + throw new CakeException($msg . $failure); } } @@ -153,19 +169,19 @@ protected function _checkDuplicate($name, $config) * Should resolve the classname for a given object type. * * @param string $class The class to resolve. - * @return string|bool The resolved name or false for failure. + * @return class-string|null The resolved name or null for failure. */ - abstract protected function _resolveClassName($class); + abstract protected function _resolveClassName(string $class): ?string; /** * Throw an exception when the requested object name is missing. * * @param string $class The class that is missing. - * @param string $plugin The plugin $class is missing from. + * @param string|null $plugin The plugin $class is missing from. * @return void * @throws \Exception */ - abstract protected function _throwMissingClassError($class, $plugin); + abstract protected function _throwMissingClassError(string $class, ?string $plugin): void; /** * Create an instance of a given classname. @@ -173,30 +189,30 @@ abstract protected function _throwMissingClassError($class, $plugin); * This method should construct and do any other initialization logic * required. * - * @param string $class The class to build. + * @param TObject|class-string $class The class to build. * @param string $alias The alias of the object. - * @param array $config The Configuration settings for construction - * @return mixed + * @param array $config The Configuration settings for construction + * @return TObject */ - abstract protected function _create($class, $alias, $config); + abstract protected function _create(object|string $class, string $alias, array $config): object; /** * Get the list of loaded objects. * - * @return array List of object names. + * @return array List of object names. */ - public function loaded() + public function loaded(): array { return array_keys($this->_loaded); } /** - * Check whether or not a given object is loaded. + * Check whether a given object is loaded. * * @param string $name The object name to check for. - * @return bool True is object is loaded else false. + * @return bool True if object is loaded else false. */ - public function has($name) + public function has(string $name): bool { return isset($this->_loaded[$name]); } @@ -205,26 +221,27 @@ public function has($name) * Get loaded object instance. * * @param string $name Name of object. - * @return object|null Object instance if loaded else null. + * @return TObject Object instance. + * @throws \Cake\Core\Exception\CakeException If not loaded or found. */ - public function get($name) + public function get(string $name): object { - if (isset($this->_loaded[$name])) { - return $this->_loaded[$name]; + if (!isset($this->_loaded[$name])) { + throw new CakeException(sprintf('Unknown object `%s`.', $name)); } - return null; + return $this->_loaded[$name]; } /** * Provide public read access to the loaded objects * * @param string $name Name of property to read - * @return mixed + * @return TObject|null */ - public function __get($name) + public function __get(string $name): ?object { - return $this->get($name); + return $this->_loaded[$name] ?? null; } /** @@ -233,19 +250,19 @@ public function __get($name) * @param string $name Name of object being checked. * @return bool */ - public function __isset($name) + public function __isset(string $name): bool { - return isset($this->_loaded[$name]); + return $this->has($name); } /** * Sets an object. * * @param string $name Name of a property to set. - * @param mixed $object Object to set. + * @param TObject $object Object to set. * @return void */ - public function __set($name, $object) + public function __set(string $name, object $object): void { $this->set($name, $object); } @@ -256,29 +273,39 @@ public function __set($name, $object) * @param string $name Name of a property to unset. * @return void */ - public function __unset($name) + public function __unset(string $name): void { $this->unload($name); } /** - * Normalizes an object array, creates an array that makes lazy loading - * easier + * Normalizes an object configuration array into associative form for making + * lazy loading easier. * - * @param array $objects Array of child objects to normalize. - * @return array Array of normalized objects. + * @param array> $objects Array of child objects to normalize. + * @return array> Array of normalized objects. */ - public function normalizeArray($objects) + public function normalizeArray(array $objects): array { $normal = []; - foreach ($objects as $i => $objectName) { - $config = []; - if (!is_int($i)) { - $config = (array)$objectName; - $objectName = $i; + foreach ($objects as $objectName => $config) { + if (is_int($objectName)) { + $objectName = $config; + $config = []; + } + if (!is_string($objectName)) { + continue; } - list(, $name) = pluginSplit($objectName); - $normal[$name] = ['class' => $objectName, 'config' => $config]; + if (!is_array($config)) { + $config = []; + } + + [$plugin, $name] = pluginSplit($objectName); + if ($plugin) { + $config['className'] = $objectName; + } + + $normal[$name] = $config; } return $normal; @@ -294,7 +321,7 @@ public function normalizeArray($objects) public function reset() { foreach (array_keys($this->_loaded) as $name) { - $this->unload($name); + $this->unload((string)$name); } return $this; @@ -306,20 +333,18 @@ public function reset() * If this collection implements events, the passed object will * be attached into the event manager * - * @param string $objectName The name of the object to set in the registry. - * @param object $object instance to store in the registry + * @param string $name The name of the object to set in the registry. + * @param TObject $object instance to store in the registry * @return $this */ - public function set($objectName, $object) + public function set(string $name, object $object) { - list(, $name) = pluginSplit($objectName); - // Just call unload if the object was loaded before - if (array_key_exists($objectName, $this->_loaded)) { - $this->unload($objectName); + if (array_key_exists($name, $this->_loaded)) { + $this->unload($name); } if ($this instanceof EventDispatcherInterface && $object instanceof EventListenerInterface) { - $this->eventManager()->on($object); + $this->getEventManager()->on($object); } $this->_loaded[$name] = $object; @@ -331,21 +356,20 @@ public function set($objectName, $object) * * If this registry has an event manager, the object will be detached from any events as well. * - * @param string $objectName The name of the object to remove from the registry. + * @param string $name The name of the object to remove from the registry. * @return $this */ - public function unload($objectName) + public function unload(string $name) { - if (empty($this->_loaded[$objectName])) { - list($plugin, $objectName) = pluginSplit($objectName); - $this->_throwMissingClassError($objectName, $plugin); + if (!isset($this->_loaded[$name])) { + throw new CakeException(sprintf('Object named `%s` is not loaded.', $name)); } - $object = $this->_loaded[$objectName]; + $object = $this->_loaded[$name]; if ($this instanceof EventDispatcherInterface && $object instanceof EventListenerInterface) { - $this->eventManager()->off($object); + $this->getEventManager()->off($object); } - unset($this->_loaded[$objectName]); + unset($this->_loaded[$name]); return $this; } @@ -353,9 +377,9 @@ public function unload($objectName) /** * Returns an array iterator. * - * @return \ArrayIterator + * @return \Traversable */ - public function getIterator() + public function getIterator(): Traversable { return new ArrayIterator($this->_loaded); } @@ -365,7 +389,7 @@ public function getIterator() * * @return int */ - public function count() + public function count(): int { return count($this->_loaded); } @@ -373,9 +397,9 @@ public function count() /** * Debug friendly object properties. * - * @return array + * @return array */ - public function __debugInfo() + public function __debugInfo(): array { $properties = get_object_vars($this); if (isset($properties['_loaded'])) { diff --git a/src/Core/Plugin.php b/src/Core/Plugin.php index 660dcb91c7f..a14270c22a5 100644 --- a/src/Core/Plugin.php +++ b/src/Core/Plugin.php @@ -1,4 +1,6 @@ true, 'routes' => true])` - * - * Will load the bootstrap.php and routes.php files. - * - * `Plugin::load('DebugKit', ['bootstrap' => false, 'routes' => true])` - * - * Will load routes.php file but not bootstrap.php - * - * `Plugin::load('FOC/Authenticate')` - * - * Will load plugin from `plugins/FOC/Authenticate`. - * - * It is also possible to load multiple plugins at once. Examples: - * - * `Plugin::load(['DebugKit', 'ApiGenerator'])` - * - * Will load the DebugKit and ApiGenerator plugins. - * - * `Plugin::load(['DebugKit', 'ApiGenerator'], ['bootstrap' => true])` - * - * Will load bootstrap file for both plugins - * - * ``` - * Plugin::load([ - * 'DebugKit' => ['routes' => true], - * 'ApiGenerator' - * ], - * ['bootstrap' => true]) - * ``` - * - * Will only load the bootstrap for ApiGenerator and only the routes for DebugKit - * - * ### Configuration options - * - * - `bootstrap` - array - Whether or not you want the $plugin/config/bootstrap.php file loaded. - * - `routes` - boolean - Whether or not you want to load the $plugin/config/routes.php file. - * - `ignoreMissing` - boolean - Set to true to ignore missing bootstrap/routes files. - * - `path` - string - The path the plugin can be found on. If empty the default plugin path (App.pluginPaths) will be used. - * - `classBase` - The path relative to `path` which contains the folders with class files. - * Defaults to "src". - * - `autoload` - boolean - Whether or not you want an autoloader registered. This defaults to false. The framework - * assumes you have configured autoloaders using composer. However, if your application source tree is made up of - * plugins, this can be a useful option. - * - * @param string|array $plugin name of the plugin to be loaded in CamelCase format or array or plugins to load - * @param array $config configuration options for the plugin - * @throws \Cake\Core\Exception\MissingPluginException if the folder for the plugin to be loaded is not found - * @return void - */ - public static function load($plugin, array $config = []) - { - if (is_array($plugin)) { - foreach ($plugin as $name => $conf) { - list($name, $conf) = is_numeric($name) ? [$conf, $config] : [$name, $conf]; - static::load($name, $conf); - } - - return; - } - - static::_loadConfig(); - - $config += [ - 'autoload' => false, - 'bootstrap' => false, - 'routes' => false, - 'classBase' => 'src', - 'ignoreMissing' => false - ]; - - if (!isset($config['path'])) { - $config['path'] = Configure::read('plugins.' . $plugin); - } - - if (empty($config['path'])) { - $paths = App::path('Plugin'); - $pluginPath = str_replace('/', DIRECTORY_SEPARATOR, $plugin); - foreach ($paths as $path) { - if (is_dir($path . $pluginPath)) { - $config['path'] = $path . $pluginPath . DIRECTORY_SEPARATOR; - break; - } - } - } - - if (empty($config['path'])) { - throw new MissingPluginException(['plugin' => $plugin]); - } - - $config['classPath'] = $config['path'] . $config['classBase'] . DIRECTORY_SEPARATOR; - if (!isset($config['configPath'])) { - $config['configPath'] = $config['path'] . 'config' . DIRECTORY_SEPARATOR; - } - - static::$_plugins[$plugin] = $config; - - if ($config['autoload'] === true) { - if (empty(static::$_loader)) { - static::$_loader = new ClassLoader(); - static::$_loader->register(); - } - static::$_loader->addNamespace( - str_replace('/', '\\', $plugin), - $config['path'] . $config['classBase'] . DIRECTORY_SEPARATOR - ); - static::$_loader->addNamespace( - str_replace('/', '\\', $plugin) . '\Test', - $config['path'] . 'tests' . DIRECTORY_SEPARATOR - ); - } - - if ($config['bootstrap'] === true) { - static::bootstrap($plugin); - } - } - - /** - * Load the plugin path configuration file. - * - * @return void - */ - protected static function _loadConfig() - { - if (Configure::check('plugins')) { - return; - } - $vendorFile = dirname(dirname(__DIR__)) . DIRECTORY_SEPARATOR . 'cakephp-plugins.php'; - if (!file_exists($vendorFile)) { - $vendorFile = dirname(dirname(dirname(dirname(__DIR__)))) . DIRECTORY_SEPARATOR . 'cakephp-plugins.php'; - if (!file_exists($vendorFile)) { - Configure::write(['plugins' => []]); - - return; - } - } - - $config = require $vendorFile; - Configure::write($config); - } - - /** - * Will load all the plugins located in the default plugin folder. - * - * If passed an options array, it will be used as a common default for all plugins to be loaded - * It is possible to set specific defaults for each plugins in the options array. Examples: - * - * ``` - * Plugin::loadAll([ - * ['bootstrap' => true], - * 'DebugKit' => ['routes' => true], - * ]); - * ``` - * - * The above example will load the bootstrap file for all plugins, but for DebugKit it will only load the routes file - * and will not look for any bootstrap script. - * - * If a plugin has been loaded already, it will not be reloaded by loadAll(). - * - * @param array $options Options. - * @return void - * @throws \Cake\Core\Exception\MissingPluginException - */ - public static function loadAll(array $options = []) - { - static::_loadConfig(); - $plugins = []; - foreach (App::path('Plugin') as $path) { - if (!is_dir($path)) { - continue; - } - $dir = new DirectoryIterator($path); - foreach ($dir as $dirPath) { - if ($dirPath->isDir() && !$dirPath->isDot()) { - $plugins[] = $dirPath->getBasename(); - } - } - } - if (Configure::check('plugins')) { - $plugins = array_merge($plugins, array_keys(Configure::read('plugins'))); - $plugins = array_unique($plugins); - } - - foreach ($plugins as $p) { - $opts = isset($options[$p]) ? $options[$p] : null; - if ($opts === null && isset($options[0])) { - $opts = $options[0]; - } - if (isset(static::$_plugins[$p])) { - continue; - } - static::load($p, (array)$opts); - } - } + protected static ?PluginCollection $plugins = null; /** * Returns the filesystem path for a plugin * - * @param string $plugin name of the plugin in CamelCase format + * @param string $name name of the plugin in CamelCase format * @return string path to the plugin folder - * @throws \Cake\Core\Exception\MissingPluginException if the folder for plugin was not found or plugin has not been loaded + * @throws \Cake\Core\Exception\MissingPluginException If the folder for plugin was not found + * or plugin has not been loaded. */ - public static function path($plugin) + public static function path(string $name): string { - if (empty(static::$_plugins[$plugin])) { - throw new MissingPluginException(['plugin' => $plugin]); - } + $plugin = static::getCollection()->get($name); - return static::$_plugins[$plugin]['path']; + return $plugin->getPath(); } /** - * Returns the filesystem path for plugin's folder containing class folders. + * Returns the filesystem path for plugin's folder containing class files. * - * @param string $plugin name of the plugin in CamelCase format. - * @return string Path to the plugin folder container class folders. + * @param string $name name of the plugin in CamelCase format. + * @return string Path to the plugin folder containing class files. * @throws \Cake\Core\Exception\MissingPluginException If plugin has not been loaded. */ - public static function classPath($plugin) + public static function classPath(string $name): string { - if (empty(static::$_plugins[$plugin])) { - throw new MissingPluginException(['plugin' => $plugin]); - } + $plugin = static::getCollection()->get($name); - return static::$_plugins[$plugin]['classPath']; + return $plugin->getClassPath(); } /** * Returns the filesystem path for plugin's folder containing config files. * - * @param string $plugin name of the plugin in CamelCase format. - * @return string Path to the plugin folder container config files. + * @param string $name name of the plugin in CamelCase format. + * @return string Path to the plugin folder containing config files. * @throws \Cake\Core\Exception\MissingPluginException If plugin has not been loaded. */ - public static function configPath($plugin) + public static function configPath(string $name): string { - if (empty(static::$_plugins[$plugin])) { - throw new MissingPluginException(['plugin' => $plugin]); - } + $plugin = static::getCollection()->get($name); - return static::$_plugins[$plugin]['configPath']; + return $plugin->getConfigPath(); } /** - * Loads the bootstrapping files for a plugin, or calls the initialization setup in the configuration + * Returns the filesystem path for plugin's folder containing template files. * - * @param string $plugin name of the plugin - * @return mixed - * @see \Cake\Core\Plugin::load() for examples of bootstrap configuration + * @param string $name name of the plugin in CamelCase format. + * @return string Path to the plugin folder containing template files. + * @throws \Cake\Core\Exception\MissingPluginException If plugin has not been loaded. */ - public static function bootstrap($plugin) + public static function templatePath(string $name): string { - $config = static::$_plugins[$plugin]; - if ($config['bootstrap'] === false) { - return false; - } - if ($config['bootstrap'] === true) { - return static::_includeFile( - $config['configPath'] . 'bootstrap.php', - $config['ignoreMissing'] - ); - } + $plugin = static::getCollection()->get($name); + + return $plugin->getTemplatePath(); } /** - * Loads the routes file for a plugin, or all plugins configured to load their respective routes file. + * Returns true if the plugin $plugin is already loaded. * - * If you need fine grained control over how routes are loaded for plugins, you - * can use {@see Cake\Routing\RouteBuilder::loadPlugin()} - * - * @param string|null $plugin name of the plugin, if null will operate on all - * plugins having enabled the loading of routes files. + * @param string $plugin Plugin name. * @return bool + * @since 3.7.0 */ - public static function routes($plugin = null) + public static function isLoaded(string $plugin): bool { - if ($plugin === null) { - foreach (static::loaded() as $p) { - static::routes($p); - } - - return true; - } - $config = static::$_plugins[$plugin]; - if ($config['routes'] === false) { - return false; - } - - return (bool)static::_includeFile( - $config['configPath'] . 'routes.php', - $config['ignoreMissing'] - ); + return static::getCollection()->has($plugin); } /** - * Returns true if the plugin $plugin is already loaded - * If plugin is null, it will return a list of all loaded plugins + * Return a list of loaded plugins. * - * @param string|null $plugin Plugin name. - * @return bool|array Boolean true if $plugin is already loaded. - * If $plugin is null, returns a list of plugins that have been loaded + * @return array A list of plugins that have been loaded */ - public static function loaded($plugin = null) + public static function loaded(): array { - if ($plugin !== null) { - return isset(static::$_plugins[$plugin]); + $names = []; + foreach (static::getCollection() as $plugin) { + $names[] = $plugin->getName(); } - $return = array_keys(static::$_plugins); - sort($return); + sort($names); - return $return; + return $names; } /** - * Forgets a loaded plugin or all of them if first parameter is null + * Get the shared plugin collection. * - * @param string|null $plugin name of the plugin to forget - * @return void + * This method should generally not be used during application + * runtime as plugins should be set during Application startup. + * + * @return \Cake\Core\PluginCollection */ - public static function unload($plugin = null) + public static function getCollection(): PluginCollection { - if ($plugin === null) { - static::$_plugins = []; - } else { - unset(static::$_plugins[$plugin]); - } + return static::$plugins ??= new PluginCollection(); } /** - * Include file, ignoring include error if needed if file is missing + * Set the shared plugin collection. * - * @param string $file File to include - * @param bool $ignoreMissing Whether to ignore include error for missing files - * @return mixed + * @param \Cake\Core\PluginCollection $collection + * @return void */ - protected static function _includeFile($file, $ignoreMissing = false) + public static function setCollection(PluginCollection $collection): void { - if ($ignoreMissing && !is_file($file)) { - return false; - } - - return include $file; + static::$plugins = $collection; } } diff --git a/src/Core/PluginApplicationInterface.php b/src/Core/PluginApplicationInterface.php new file mode 100644 index 00000000000..fa07b7ffa29 --- /dev/null +++ b/src/Core/PluginApplicationInterface.php @@ -0,0 +1,75 @@ + $config The configuration data for the plugin if using a string for $name + * @return $this + */ + public function addPlugin(PluginInterface|string $name, array $config = []); + + /** + * Run bootstrap logic for loaded plugins. + * + * @return void + */ + public function pluginBootstrap(): void; + + /** + * Run routes hooks for loaded plugins + * + * @param \Cake\Routing\RouteBuilder $routes The route builder to use. + * @return \Cake\Routing\RouteBuilder + */ + public function pluginRoutes(RouteBuilder $routes): RouteBuilder; + + /** + * Run middleware hooks for plugins + * + * @param \Cake\Http\MiddlewareQueue $middleware The MiddlewareQueue to use. + * @return \Cake\Http\MiddlewareQueue + */ + public function pluginMiddleware(MiddlewareQueue $middleware): MiddlewareQueue; + + /** + * Run console hooks for plugins + * + * @param \Cake\Console\CommandCollection $commands The CommandCollection to use. + * @return \Cake\Console\CommandCollection + */ + public function pluginConsole(CommandCollection $commands): CommandCollection; +} diff --git a/src/Core/PluginCollection.php b/src/Core/PluginCollection.php new file mode 100644 index 00000000000..83d5a298b95 --- /dev/null +++ b/src/Core/PluginCollection.php @@ -0,0 +1,401 @@ + + */ +class PluginCollection implements Iterator, Countable +{ + /** + * Plugin list + * + * @var array + */ + protected array $plugins = []; + + /** + * Names of plugins + * + * @var array + */ + protected array $names = []; + + /** + * Iterator position stack. + * + * @var array + */ + protected array $positions = []; + + /** + * Loop depth + * + * @var int + */ + protected int $loopDepth = -1; + + /** + * Constructor + * + * @param array<\Cake\Core\PluginInterface> $plugins The map of plugins to add to the collection. + */ + public function __construct(array $plugins = []) + { + foreach ($plugins as $plugin) { + $this->add($plugin); + } + PluginConfig::loadInstallerConfig(); + } + + /** + * Add plugins from config array. + * + * @param array $config Configuration array. For e.g.: + * ``` + * [ + * 'Company/TestPluginThree', + * 'TestPlugin' => ['onlyDebug' => true, 'onlyCli' => true], + * 'Nope' => ['optional' => true], + * 'Named' => ['routes' => false, 'bootstrap' => false], + * ] + * ``` + * @return void + */ + public function addFromConfig(array $config): void + { + $notDebug = !Configure::read('debug'); + $notCli = PHP_SAPI !== 'cli'; + + /** @var array{onlyDebug?: bool, onlyCli?: bool, optional?: bool} $options */ + foreach (Hash::normalize($config, default: []) as $name => $options) { + $onlyDebug = $options['onlyDebug'] ?? false; + $onlyCli = $options['onlyCli'] ?? false; + $optional = $options['optional'] ?? false; + + if ( + ($onlyDebug && $notDebug) + || ($onlyCli && $notCli) + ) { + continue; + } + + try { + $plugin = $this->create($name, $options); + $this->add($plugin); + } catch (MissingPluginException $e) { + if (!$optional) { + throw $e; + } + } + } + } + + /** + * Locate a plugin path by looking at configuration data. + * + * This will use the `plugins` Configure key, and fallback to enumerating `App::path('plugins')` + * + * This method is not part of the official public API as plugins with + * no plugin class are being phased out. + * + * @param string $name The plugin name to locate a path for. + * @return string + * @throws \Cake\Core\Exception\MissingPluginException when a plugin path cannot be resolved. + * @internal + */ + public function findPath(string $name): string + { + // Ensure plugin config is loaded each time. This is necessary primarily + // for testing because the Configure::clear() call in TestCase::tearDown() + // wipes out all configuration including plugin paths config. + PluginConfig::loadInstallerConfig(); + + /** @var string|null $path */ + $path = Configure::read('plugins.' . $name); + if ($path) { + return $path; + } + + $pluginPath = str_replace('/', DIRECTORY_SEPARATOR, $name); + $paths = App::path('plugins'); + foreach ($paths as $path) { + if (is_dir($path . $pluginPath)) { + return $path . $pluginPath . DIRECTORY_SEPARATOR; + } + } + + throw new MissingPluginException(['plugin' => $name]); + } + + /** + * Add a plugin to the collection + * + * Plugins will be keyed by their names. + * + * @param \Cake\Core\PluginInterface $plugin The plugin to load. + * @return $this + */ + public function add(PluginInterface $plugin) + { + $name = $plugin->getName(); + if (isset($this->plugins[$name])) { + throw new CakeException(sprintf('Plugin named `%s` is already loaded', $name)); + } + + $this->plugins[$name] = $plugin; + $this->names = array_keys($this->plugins); + + return $this; + } + + /** + * Remove a plugin from the collection if it exists. + * + * @param string $name The named plugin. + * @return $this + */ + public function remove(string $name) + { + unset($this->plugins[$name]); + $this->names = array_keys($this->plugins); + + return $this; + } + + /** + * Remove all plugins from the collection + * + * @return $this + */ + public function clear() + { + $this->plugins = []; + $this->names = []; + $this->positions = []; + $this->loopDepth = -1; + + return $this; + } + + /** + * Check whether the named plugin exists in the collection. + * + * @param string $name The named plugin. + * @return bool + */ + public function has(string $name): bool + { + return isset($this->plugins[$name]); + } + + /** + * Get the a plugin by name. + * + * If a plugin isn't already loaded it will be autoloaded on first access + * and that plugins loaded this way may miss some hook methods. + * + * @param string $name The plugin to get. + * @return \Cake\Core\PluginInterface The plugin. + * @throws \Cake\Core\Exception\MissingPluginException when unknown plugins are fetched. + */ + public function get(string $name): PluginInterface + { + if ($this->has($name)) { + return $this->plugins[$name]; + } + + $plugin = $this->create($name); + $this->add($plugin); + + return $plugin; + } + + /** + * Create a plugin instance from a name/classname and configuration. + * + * @param class-string<\Cake\Core\PluginInterface>|string $name The plugin name or classname + * @param array $config Configuration options for the plugin. + * @return \Cake\Core\PluginInterface + * @throws \Cake\Core\Exception\MissingPluginException When plugin instance could not be created. + * @throws \InvalidArgumentException When class name cannot be found or an empty name is provided. + */ + public function create(string $name, array $config = []): PluginInterface + { + if ($name === '') { + throw new InvalidArgumentException('Plugin name cannot be empty.'); + } + + if (str_contains($name, '\\')) { + if (!is_subclass_of($name, PluginInterface::class)) { + throw new InvalidArgumentException(sprintf( + 'Class `%s` does not exist or does not extend `Cake\Core\PluginInterface`.', + $name, + )); + } + + return new $name($config); + } + + $config += ['name' => $name]; + $namespace = str_replace('/', '\\', $name); + + $pos = strpos($name, '/'); + $namePart = $pos === false ? $name : substr($name, $pos + 1); + + // Check for [Vendor/]Foo/FooPlugin class + $className = $namespace . '\\' . $namePart . 'Plugin'; + + if (!class_exists($className)) { + // Check for [Vendor/]Foo/Plugin class + $className = $namespace . '\\' . 'Plugin'; + + if (class_exists($className)) { + deprecationWarning( + '5.3.0', + 'Loading plugins with a plugin class named `Plugin` is deprecated.' + . " Rename the class to `{$namePart}Plugin` instead.", + ); + } else { + $className = BasePlugin::class; + if (empty($config['path'])) { + $config['path'] = $this->findPath($name); + } + + deprecationWarning( + '5.3.0', + 'Loading plugins without a plugin class is deprecated.' + . " You can create the missing class using `bin/cake bake plugin {$name} --class-only`.", + ); + } + } + + /** @var class-string<\Cake\Core\PluginInterface> $className */ + return new $className($config); + } + + /** + * Implementation of Countable. + * + * Get the number of plugins in the collection. + * + * @return int + */ + public function count(): int + { + return count($this->plugins); + } + + /** + * Part of Iterator Interface + * + * @return void + */ + public function next(): void + { + $this->positions[$this->loopDepth]++; + } + + /** + * Part of Iterator Interface + * + * @return string + */ + public function key(): string + { + return $this->names[$this->positions[$this->loopDepth]]; + } + + /** + * Part of Iterator Interface + * + * @return \Cake\Core\PluginInterface + */ + public function current(): PluginInterface + { + $position = $this->positions[$this->loopDepth]; + $name = $this->names[$position]; + + return $this->plugins[$name]; + } + + /** + * Part of Iterator Interface + * + * @return void + */ + public function rewind(): void + { + $this->positions[] = 0; + $this->loopDepth += 1; + } + + /** + * Part of Iterator Interface + * + * @return bool + */ + public function valid(): bool + { + $valid = isset($this->names[$this->positions[$this->loopDepth]]); + if (!$valid) { + array_pop($this->positions); + $this->loopDepth -= 1; + } + + return $valid; + } + + /** + * Filter the plugins to those with the named hook enabled. + * + * @param string $hook The hook to filter plugins by + * @return \Generator<\Cake\Core\PluginInterface> A generator containing matching plugins. + * @throws \InvalidArgumentException on invalid hooks + */ + public function with(string $hook): Generator + { + if (!in_array($hook, PluginInterface::VALID_HOOKS, true)) { + throw new InvalidArgumentException(sprintf('The `%s` hook is not a known plugin hook.', $hook)); + } + foreach ($this as $plugin) { + if ($plugin->isEnabled($hook)) { + yield $plugin; + } + } + } +} diff --git a/src/Core/PluginConfig.php b/src/Core/PluginConfig.php new file mode 100644 index 00000000000..6cfe257928f --- /dev/null +++ b/src/Core/PluginConfig.php @@ -0,0 +1,257 @@ +>|null + */ + private static ?array $cachedPlugins = null; + + /** + * Load the path information stored in vendor/cakephp-plugins.php + * + * This file is generated by the cakephp/plugin-installer package and used + * to locate plugins on the filesystem as applications can use `extra.plugin-paths` + * in their composer.json file to move plugin outside of vendor/ + * + * @internal + * @return void + */ + public static function loadInstallerConfig(): void + { + if (Configure::check('plugins')) { + return; + } + $vendorFile = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'cakephp-plugins.php'; + if (!is_file($vendorFile)) { + $vendorFile = dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'cakephp-plugins.php'; + if (!is_file($vendorFile)) { + Configure::write(['plugins' => []]); + + return; + } + } + + $config = require $vendorFile; + Configure::write($config); + } + + /** + * Get an array of all installed plugins and their configuration options. + * + * Returns an array of plugin configurations with keys: + * - bootstrap: Enable bootstrap hook (if isLoaded) + * - console: Enable console hook (if isLoaded) + * - events: Enable events hook (if isLoaded) + * - isLoaded: Whether plugin is configured to load + * - isUnknown: Present and set to true when a plugin is configured but not found in the installed plugins list + * - middleware: Enable middleware hook (if isLoaded) + * - onlyCli: Load only in CLI mode (if isLoaded) + * - onlyDebug: Load only in debug mode (if isLoaded) + * - optional: Plugin is optional (if isLoaded) + * - path: Plugin filesystem path (only present for installed plugins, not for unknown ones) + * - routes: Enable routes hook (if isLoaded) + * - services: Enable services hook (if isLoaded) + * + * @return array> Plugin name => configuration + */ + public static function getInstalledPlugins(): array + { + if (self::$cachedPlugins !== null) { + return self::$cachedPlugins; + } + + self::loadInstallerConfig(); + + // phpcs:ignore + $pluginLoadConfig = @include CONFIG . 'plugins.php'; + if (is_array($pluginLoadConfig)) { + $pluginLoadConfig = Hash::normalize($pluginLoadConfig); + } else { + $pluginLoadConfig = []; + } + + $result = []; + $availablePlugins = Configure::read('plugins', []); + if ($availablePlugins && is_array($availablePlugins)) { + foreach ($availablePlugins as $pluginName => $pluginPath) { + if ($pluginLoadConfig && array_key_exists($pluginName, $pluginLoadConfig)) { + $options = $pluginLoadConfig[$pluginName]; + $hooks = PluginInterface::VALID_HOOKS; + $mainConfig = [ + 'path' => $pluginPath, + 'isLoaded' => true, + 'onlyDebug' => $options['onlyDebug'] ?? false, + 'onlyCli' => $options['onlyCli'] ?? false, + 'optional' => $options['optional'] ?? false, + ]; + foreach ($hooks as $hook) { + $mainConfig[$hook] = $options[$hook] ?? true; + } + $result[$pluginName] = $mainConfig; + } else { + $result[$pluginName] = [ + 'path' => $pluginPath, + 'isLoaded' => false, + ]; + } + } + } + + $diff = array_diff(array_keys($pluginLoadConfig), array_keys($availablePlugins)); + foreach ($diff as $unknownPlugin) { + $result[$unknownPlugin]['isLoaded'] = false; + $result[$unknownPlugin]['isUnknown'] = true; + } + + return self::$cachedPlugins = $result; + } + + /** + * Clear the cached plugins data. Useful for testing. + * + * @return void + */ + public static function clearCache(): void + { + self::$cachedPlugins = null; + } + + /** + * Get the config how plugins should be loaded with enriched package metadata. + * + * @param string|null $path The absolute path to the composer.lock file to retrieve the versions from + * @return array> Plugin name => enriched configuration with package metadata + */ + public static function getAppConfig(?string $path = null): array + { + // Get base plugin configuration (paths and load config) + $result = self::getInstalledPlugins(); + + try { + $composerVersions = self::getVersions($path); + } catch (CakeException) { + $composerVersions = []; + } + + // Enrich with package metadata and versions + foreach ($result as $pluginName => $config) { + // Skip unknown plugins (no path available) + if (!isset($config['path'])) { + continue; + } + + try { + $packageName = self::getPackageNameFromPath($config['path']); + $result[$pluginName]['packagePath'] = $config['path']; + $result[$pluginName]['package'] = $packageName; + } catch (CakeException) { + $packageName = null; + } + + if ($composerVersions && $packageName) { + foreach (['packages' => false, 'devPackages' => true] as $key => $isDev) { + if (array_key_exists($packageName, $composerVersions[$key])) { + $result[$pluginName]['version'] = $composerVersions[$key][$packageName]; + $result[$pluginName]['isDevPackage'] = $isDev; + break; + } + } + } + + // Remove 'path' key to maintain BC (getAppConfig uses packagePath instead) + unset($result[$pluginName]['path']); + } + + return $result; + } + + /** + * Get package versions from composer.lock file. + * + * @param string|null $path The absolute path to the composer.lock file to retrieve the versions from + * @return array{packages: array, devPackages: array} Array with 'packages' and 'devPackages' keys + * @throws \Cake\Core\Exception\CakeException When composer.lock is missing, unreadable, or invalid + */ + public static function getVersions(?string $path = null): array + { + $lockFilePath = $path ?? ROOT . DIRECTORY_SEPARATOR . 'composer.lock'; + if (!file_exists($lockFilePath)) { + throw new CakeException(sprintf('composer.lock does not exist in %s', $lockFilePath)); + } + $lockFile = file_get_contents($lockFilePath); + if ($lockFile === false) { + throw new CakeException(sprintf('Could not read composer.lock: %s', $lockFilePath)); + } + $lockFileJson = json_decode($lockFile, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new CakeException(sprintf( + 'Error parsing composer.lock: %s', + json_last_error_msg(), + )); + } + + $packages = Hash::combine($lockFileJson['packages'], '{n}.name', '{n}.version'); + $devPackages = Hash::combine($lockFileJson['packages-dev'], '{n}.name', '{n}.version'); + + return [ + 'packages' => $packages, + 'devPackages' => $devPackages, + ]; + } + + /** + * Extract package name from composer.json in the given path. + * + * @param string $path The plugin path containing composer.json + * @return string The package name (e.g., 'cakephp/debug-kit') + * @throws \Cake\Core\Exception\CakeException When composer.json is missing, unreadable, or invalid + */ + protected static function getPackageNameFromPath(string $path): string + { + $jsonPath = $path . DS . 'composer.json'; + if (!file_exists($jsonPath)) { + throw new CakeException(sprintf('composer.json does not exist in %s', $jsonPath)); + } + $jsonString = file_get_contents($jsonPath); + if ($jsonString === false) { + throw new CakeException(sprintf('Could not read composer.json: %s', $jsonPath)); + } + $json = json_decode($jsonString, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new CakeException(sprintf( + 'Error parsing %s: %s', + $jsonPath, + json_last_error_msg(), + )); + } + + return $json['name']; + } +} diff --git a/src/Core/PluginInterface.php b/src/Core/PluginInterface.php new file mode 100644 index 00000000000..768d4f38067 --- /dev/null +++ b/src/Core/PluginInterface.php @@ -0,0 +1,142 @@ + + */ + public const VALID_HOOKS = ['bootstrap', 'console', 'middleware', 'routes', 'services', 'events']; + + /** + * Get the name of this plugin. + * + * @return string + */ + public function getName(): string; + + /** + * Get the filesystem path to this plugin + * + * @return string + */ + public function getPath(): string; + + /** + * Get the filesystem path to configuration for this plugin + * + * @return string + */ + public function getConfigPath(): string; + + /** + * Get the filesystem path to configuration for this plugin + * + * @return string + */ + public function getClassPath(): string; + + /** + * Get the filesystem path to templates for this plugin + * + * @return string + */ + public function getTemplatePath(): string; + + /** + * Load all the application configuration and bootstrap logic. + * + * The default implementation of this method will include the `config/bootstrap.php` in the plugin if it exist. You + * can override this method to replace that behavior. + * + * The host application is provided as an argument. This allows you to load additional + * plugin dependencies, or attach events. + * + * @param \Cake\Core\PluginApplicationInterface $app The host application + * @return void + */ + public function bootstrap(PluginApplicationInterface $app): void; + + /** + * Add console commands for the plugin. + * + * @param \Cake\Console\CommandCollection $commands The command collection to update + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection; + + /** + * Add middleware for the plugin. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue; + + /** + * Add routes for the plugin. + * + * The default implementation of this method will include the `config/routes.php` in the plugin if it exists. You + * can override this method to replace that behavior. + * + * @param \Cake\Routing\RouteBuilder $routes The route builder to update. + * @return void + */ + public function routes(RouteBuilder $routes): void; + + /** + * Register plugin services to the application's container + * + * @param \Cake\Core\ContainerInterface $container Container instance. + * @return void + */ + public function services(ContainerInterface $container): void; + + /** + * Disables the named hook + * + * @param string $hook The hook to disable + * @return $this + */ + public function disable(string $hook); + + /** + * Enables the named hook + * + * @param string $hook The hook to disable + * @return $this + */ + public function enable(string $hook); + + /** + * Check if the named hook is enabled + * + * @param string $hook The hook to check + * @return bool + */ + public function isEnabled(string $hook): bool; +} diff --git a/src/Core/README.md b/src/Core/README.md index ca10e4f6259..12d115f5252 100644 --- a/src/Core/README.md +++ b/src/Core/README.md @@ -26,7 +26,7 @@ Configure::load('app', 'default', false); Configure::load('other_config', 'default'); ``` -And Write the configuration back into files: +And write the configuration back into files: ```php Configure::dump('my_config', 'default'); @@ -34,4 +34,4 @@ Configure::dump('my_config', 'default'); ## Documentation -Please make sure you check the [official documentation](https://book.cakephp.org/3.0/en/development/configuration.html) +Please make sure you check the [official documentation](https://book.cakephp.org/5/en/development/configuration.html) diff --git a/src/Core/Retry/CommandRetry.php b/src/Core/Retry/CommandRetry.php new file mode 100644 index 00000000000..e7ab321f42f --- /dev/null +++ b/src/Core/Retry/CommandRetry.php @@ -0,0 +1,95 @@ +strategy = $strategy; + $this->maxRetries = $maxRetries; + } + + /** + * The number of retries to perform in case of failure + * + * @param \Closure $action Callback to run for each attempt + * @return mixed The return value of the passed action callable + * @throws \Exception Throws exception from last failure + */ + public function run(Closure $action): mixed + { + $this->numRetries = 0; + while (true) { + try { + return $action(); + } catch (Exception $e) { + if ( + $this->numRetries < $this->maxRetries && + $this->strategy->shouldRetry($e, $this->numRetries) + ) { + $this->numRetries++; + continue; + } + + throw $e; + } + } + } + + /** + * Returns the last number of retry attempts. + * + * @return int + */ + public function getRetries(): int + { + return $this->numRetries; + } +} diff --git a/src/Core/Retry/RetryStrategyInterface.php b/src/Core/Retry/RetryStrategyInterface.php new file mode 100644 index 00000000000..e2ef9e21f0c --- /dev/null +++ b/src/Core/Retry/RetryStrategyInterface.php @@ -0,0 +1,35 @@ + + * @see ServiceProvider::provides() + */ + protected array $provides = []; + + /** + * Get the container. + * + * @return \Cake\Core\ContainerInterface + */ + public function getContainer(): DefinitionContainerInterface + { + $container = parent::getContainer(); + + assert( + $container instanceof ContainerInterface, + sprintf( + 'Unexpected container type. Expected `%s` got `%s` instead.', + ContainerInterface::class, + get_debug_type($container), + ), + ); + + return $container; + } + + /** + * Delegate to the bootstrap() method + * + * This method wraps the league/container function so users + * only need to use the CakePHP bootstrap() interface. + * + * @return void + */ + public function boot(): void + { + $this->bootstrap($this->getContainer()); + } + + /** + * Bootstrap hook for ServiceProviders + * + * This hook should be implemented if your service provider + * needs to register additional service providers, load configuration + * files or do any other work when the service provider is added to the + * container. + * + * @param \Cake\Core\ContainerInterface $container The container to add services to. + * @return void + */ + public function bootstrap(ContainerInterface $container): void + { + } + + /** + * Call the abstract services() method. + * + * This method primarily exists as a shim between the interface + * that league/container has and the one we want to offer in CakePHP. + * + * @return void + */ + public function register(): void + { + $this->services($this->getContainer()); + } + + /** + * The provides method is a way to let the container know that a service + * is provided by this service provider. + * + * Every service registered via this service provider must have an + * alias added to this array or it will be ignored. + * + * @param string $id Identifier. + * @return bool + */ + public function provides(string $id): bool + { + if (!$this->provides) { + throw new LogicException( + 'The property `$provides` should contain a list with service ids for this service provider', + ); + } + + return in_array($id, $this->provides, true); + } + + /** + * Register the services in a provider. + * + * All services registered in this method should also be included in the $provides + * property so that services can be located. + * + * @param \Cake\Core\ContainerInterface $container The container to add services to. + * @return void + */ + abstract public function services(ContainerInterface $container): void; +} diff --git a/src/Core/StaticConfigTrait.php b/src/Core/StaticConfigTrait.php index c278aaf0cf4..c9c4408123d 100644 --- a/src/Core/StaticConfigTrait.php +++ b/src/Core/StaticConfigTrait.php @@ -1,4 +1,6 @@ > */ - protected static $_config = []; + protected static array $_config = []; /** * This method can be used to define configuration adapters for an application. @@ -66,34 +67,37 @@ trait StaticConfigTrait * Cache::setConfig($arrayOfConfig); * ``` * - * @param string|array $key The name of the configuration, or an array of multiple configs. - * @param array $config An array of name => configuration data for adapter. + * @param array|string $key The name of the configuration, or an array of multiple configs. + * @param mixed $config Configuration value. Generally an array of name => configuration data for adapter. * @throws \BadMethodCallException When trying to modify an existing config. * @throws \LogicException When trying to store an invalid structured config array. * @return void */ - public static function setConfig($key, $config = null) + public static function setConfig(array|string $key, mixed $config = null): void { if ($config === null) { if (!is_array($key)) { throw new LogicException('If config is null, key must be an array.'); } foreach ($key as $name => $settings) { - static::setConfig($name, $settings); + static::setConfig((string)$name, $settings); } return; } + if (!is_string($key)) { + throw new LogicException('If config is not null, key must be a string.'); + } if (isset(static::$_config[$key])) { - throw new BadMethodCallException(sprintf('Cannot reconfigure existing key "%s"', $key)); + throw new BadMethodCallException(sprintf('Cannot reconfigure existing key `%s`.', $key)); } if (is_object($config)) { $config = ['className' => $config]; } - if (isset($config['url'])) { + if (is_array($config) && isset($config['url'])) { $parsed = static::parseDsn($config['url']); unset($config['url']); $config = $parsed + $config; @@ -103,6 +107,7 @@ public static function setConfig($key, $config = null) $config['className'] = $config['engine']; unset($config['engine']); } + static::$_config[$key] = $config; } @@ -110,66 +115,29 @@ public static function setConfig($key, $config = null) * Reads existing configuration. * * @param string $key The name of the configuration. - * @return array|null Array of configuration data. + * @return mixed|null Configuration data at the named key or null if the key does not exist. */ - public static function getConfig($key) + public static function getConfig(string $key): mixed { - return isset(static::$_config[$key]) ? static::$_config[$key] : null; + return static::$_config[$key] ?? null; } /** - * This method can be used to define configuration adapters for an application - * or read existing configuration. - * - * To change an adapter's configuration at runtime, first drop the adapter and then - * reconfigure it. - * - * Adapters will not be constructed until the first operation is done. - * - * ### Usage - * - * Assuming that the class' name is `Cache` the following scenarios - * are supported: - * - * Reading config data back: - * - * ``` - * Cache::config('default'); - * ``` - * - * Setting a cache engine up. - * - * ``` - * Cache::config('default', $settings); - * ``` + * Reads existing configuration for a specific key. * - * Injecting a constructed adapter in: - * - * ``` - * Cache::config('default', $instance); - * ``` - * - * Configure multiple adapters at once: + * The config value for this key must exist, it can never be null. * - * ``` - * Cache::config($arrayOfConfig); - * ``` - * - * @deprecated 3.4.0 Use setConfig()/getConfig() instead. - * @param string|array $key The name of the configuration, or an array of multiple configs. - * @param array|null $config An array of name => configuration data for adapter. - * @return array|null Null when adding configuration or an array of configuration data when reading. - * @throws \BadMethodCallException When trying to modify an existing config. + * @param string $key The name of the configuration. + * @return mixed Configuration data at the named key. + * @throws \InvalidArgumentException If value does not exist. */ - public static function config($key, $config = null) + public static function getConfigOrFail(string $key): mixed { - if ($config !== null || is_array($key)) { - static::setConfig($key, $config); - - return null; + if (!isset(static::$_config[$key])) { + throw new InvalidArgumentException(sprintf('Expected configuration `%s` not found.', $key)); } - return static::getConfig($key); + return static::$_config[$key]; } /** @@ -184,11 +152,12 @@ public static function config($key, $config = null) * @param string $config An existing configuration you wish to remove. * @return bool Success of the removal, returns false when the config does not exist. */ - public static function drop($config) + public static function drop(string $config): bool { if (!isset(static::$_config[$config])) { return false; } + /** @phpstan-ignore-next-line */ if (isset(static::$_registry)) { static::$_registry->unload($config); } @@ -200,11 +169,15 @@ public static function drop($config) /** * Returns an array containing the named configurations * - * @return array Array of configurations. + * @return array Array of configurations. */ - public static function configured() + public static function configured(): array { - return array_keys(static::$_config); + $configurations = array_keys(static::$_config); + + return array_map(function (int|string $key) { + return (string)$key; + }, $configurations); } /** @@ -226,7 +199,7 @@ public static function configured() * $dsn = 'file:///?className=\My\Cache\Engine\FileEngine'; * $config = Cache::parseDsn($dsn); * - * $dsn = 'File://?prefix=myapp_cake_core_&serialize=true&duration=+2 minutes&path=/tmp/persistent/'; + * $dsn = 'File://?prefix=myapp_cake_translations_&serialize=true&duration=+2 minutes&path=/tmp/persistent/'; * $config = Cache::parseDsn($dsn); * ``` * @@ -236,19 +209,15 @@ public static function configured() * Note that querystring arguments are also parsed and set as values in the returned configuration. * * @param string $dsn The DSN string to convert to a configuration array - * @return array The configuration array to be stored after parsing the DSN + * @return array The configuration array to be stored after parsing the DSN * @throws \InvalidArgumentException If not passed a string, or passed an invalid string */ - public static function parseDsn($dsn) + public static function parseDsn(string $dsn): array { - if (empty($dsn)) { + if (!$dsn) { return []; } - if (!is_string($dsn)) { - throw new InvalidArgumentException('Only strings can be passed to parseDsn'); - } - $pattern = <<<'REGEXP' { ^ @@ -263,7 +232,7 @@ public static function parseDsn($dsn) @ )? (?P<_host> - (?P[^?#/:@]+) + (?P\[[^]]+]|[^?#/:@]+) (?P<_port> :(?P\d+) )? @@ -284,14 +253,17 @@ public static function parseDsn($dsn) preg_match($pattern, $dsn, $parsed); if (!$parsed) { - throw new InvalidArgumentException("The DSN string '{$dsn}' could not be parsed."); + throw new InvalidArgumentException(sprintf('The DSN string `%s` could not be parsed.', $dsn)); } $exists = []; + /** + * @var string|int $k + */ foreach ($parsed as $k => $v) { if (is_int($k)) { unset($parsed[$k]); - } elseif (strpos($k, '_') === 0) { + } elseif (str_starts_with($k, '_')) { $exists[substr($k, 1)] = ($v !== ''); unset($parsed[$k]); } elseif ($v === '' && !$exists[$k]) { @@ -308,6 +280,9 @@ public static function parseDsn($dsn) parse_str($query, $queryArgs); + /** + * @var string $key + */ foreach ($queryArgs as $key => $value) { if ($value === 'true') { $queryArgs[$key] = true; @@ -323,9 +298,11 @@ public static function parseDsn($dsn) if (empty($parsed['className'])) { $classMap = static::getDsnClassMap(); - $parsed['className'] = $parsed['scheme']; - if (isset($classMap[$parsed['scheme']])) { - $parsed['className'] = $classMap[$parsed['scheme']]; + /** @var string $scheme */ + $scheme = $parsed['scheme']; + $parsed['className'] = $scheme; + if (isset($classMap[$scheme])) { + $parsed['className'] = $classMap[$scheme]; } } @@ -335,10 +312,10 @@ public static function parseDsn($dsn) /** * Updates the DSN class map for this class. * - * @param array $map Additions/edits to the class map to apply. + * @param array $map Additions/edits to the class map to apply. * @return void */ - public static function setDsnClassMap(array $map) + public static function setDsnClassMap(array $map): void { static::$_dsnClassMap = $map + static::$_dsnClassMap; } @@ -346,26 +323,10 @@ public static function setDsnClassMap(array $map) /** * Returns the DSN class map for this class. * - * @return array + * @return array */ - public static function getDsnClassMap() + public static function getDsnClassMap(): array { return static::$_dsnClassMap; } - - /** - * Returns or updates the DSN class map for this class. - * - * @deprecated 3.4.0 Use setDsnClassMap()/getDsnClassMap() instead. - * @param array|null $map Additions/edits to the class map to apply. - * @return array - */ - public static function dsnClassMap(array $map = null) - { - if ($map !== null) { - static::setDsnClassMap($map); - } - - return static::getDsnClassMap(); - } } diff --git a/src/Core/TestSuite/ContainerStubTrait.php b/src/Core/TestSuite/ContainerStubTrait.php new file mode 100644 index 00000000000..5b05ea24efb --- /dev/null +++ b/src/Core/TestSuite/ContainerStubTrait.php @@ -0,0 +1,199 @@ +|class-string<\Cake\Core\ConsoleApplicationInterface>|null + */ + protected ?string $_appClass = null; + + /** + * The customized application constructor arguments. + * + * @var array|null + */ + protected ?array $_appArgs = null; + + /** + * The collection of container services. + * + * @var array + */ + private array $containerServices = []; + + /** + * Configure the application class to use in integration tests. + * + * @param class-string<\Cake\Core\HttpApplicationInterface>|class-string<\Cake\Core\ConsoleApplicationInterface> $class The application class name. + * @param array|null $constructorArgs The constructor arguments for your application class. + * @return void + */ + public function configApplication(string $class, ?array $constructorArgs): void + { + $this->_appClass = $class; + $this->_appArgs = $constructorArgs; + } + + /** + * Create an application instance. + * + * Uses the configuration set in `configApplication()`. + * + * @return \Cake\Core\HttpApplicationInterface|\Cake\Core\ConsoleApplicationInterface + */ + protected function createApp(): HttpApplicationInterface|ConsoleApplicationInterface + { + if (class_exists(Router::class)) { + Router::resetRoutes(); + } + + if ($this->_appClass) { + $appClass = $this->_appClass; + } else { + /** @var class-string<\Cake\Core\HttpApplicationInterface>|class-string<\Cake\Core\ConsoleApplicationInterface> $appClass */ + $appClass = Configure::read('App.namespace') . '\Application'; + } + if (!class_exists($appClass)) { + throw new LogicException(sprintf('Cannot load `%s` for use in integration testing.', $appClass)); + } + $appArgs = $this->_appArgs ?: [CONFIG]; + + $app = new $appClass(...$appArgs); + if ($this->containerServices && $app instanceof EventDispatcherInterface) { + $app->getEventManager()->on('Application.buildContainer', [$this, 'modifyContainer']); + } + + if ($app instanceof PluginApplicationInterface) { + foreach ($this->appPluginsToLoad as $pluginName => $config) { + if (is_array($config)) { + $app->addPlugin($pluginName, $config); + } else { + $app->addPlugin($config); + } + } + } + + return $app; + } + + /** + * Add a mocked service to the container. + * + * When the container is created the provided classname + * will be mapped to the factory function. The factory + * function will be used to create mocked services. + * + * @param string $class The class or interface you want to define. + * @param \Closure $factory The factory function for mocked services. + * @return $this + */ + public function mockService(string $class, Closure $factory) + { + $this->containerServices[$class] = $factory; + + return $this; + } + + /** + * Remove a mocked service to the container. + * + * @param string $class The class or interface you want to remove. + * @return $this + */ + public function removeMockService(string $class) + { + unset($this->containerServices[$class]); + + return $this; + } + + /** + * Wrap the application's container with one containing mocks. + * + * If any mocked services are defined, the application's container + * will be replaced with one containing mocks. The original + * container will be set as a delegate to the mock container. + * + * @param \Cake\Event\EventInterface $event The event + * @param \Cake\Core\ContainerInterface $container The container to wrap. + * @return void + */ + public function modifyContainer(EventInterface $event, ContainerInterface $container): void + { + if (!$this->containerServices) { + return; + } + foreach ($this->containerServices as $key => $factory) { + if ($container->has($key)) { + try { + $container->extend($key)->setConcrete($factory); + } catch (NotFoundExceptionInterface) { + $container->add($key, $factory); + } + } else { + $container->add($key, $factory); + } + } + + $event->setResult($container); + } + + /** + * Clears any mocks that were defined and cleans + * up application class configuration. + * + * @return void + */ + #[After] + public function cleanupContainer(): void + { + $this->_appArgs = null; + $this->_appClass = null; + $this->containerServices = []; + } +} + +// phpcs:disable +class_alias( + 'Cake\Core\TestSuite\ContainerStubTrait', + 'Cake\TestSuite\ContainerStubTrait' +); +// phpcs:enable diff --git a/src/Core/composer.json b/src/Core/composer.json index 1023003b364..99de1380ed4 100644 --- a/src/Core/composer.json +++ b/src/Core/composer.json @@ -22,8 +22,10 @@ "source": "https://github.com/cakephp/core" }, "require": { - "php": ">=5.6.0", - "cakephp/utility": "^3.0.0" + "php": ">=8.2", + "cakephp/utility": "^5.4.0", + "league/container": "^5.1", + "psr/container": "^1.1 || ^2.0" }, "autoload": { "psr-4": { @@ -32,5 +34,20 @@ "files": [ "functions.php" ] + }, + "provide": { + "psr/container-implementation": "^2.0" + }, + "suggest": { + "cakephp/event": "To use PluginApplicationInterface or plugin applications.", + "cakephp/cache": "To use Configure::store() and restore().", + "league/container": "To use Container and ServiceProvider classes" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" + } } } diff --git a/src/Core/functions.php b/src/Core/functions.php index 96d653aa39a..c2e3bfdda3a 100644 --- a/src/Core/functions.php +++ b/src/Core/functions.php @@ -1,4 +1,6 @@ $parts + * @param bool|null $trailing Determines how trailing slashes are handled + * - If true, ensures a trailing forward-slash is added if one doesn't exist + * - If false, ensures any trailing slash is removed + * - if null, ignores trailing slashes + * @return string + */ + function pathCombine(array $parts, ?bool $trailing = null): string + { + $numParts = count($parts); + if ($numParts === 0) { + if ($trailing === true) { + return '/'; + } + + return ''; + } + + $path = $parts[0]; + for ($i = 1; $i < $numParts; ++$i) { + $part = $parts[$i]; + if ($part === '') { + continue; + } + + if ($path[-1] === '/' || $path[-1] === '\\') { + if ($part[0] === '/' || $part[0] === '\\') { + $path .= substr($part, 1); + } else { + $path .= $part; + } + } elseif ($part[0] === '/' || $part[0] === '\\') { + $path .= $part; + } else { + $path .= '/' . $part; + } + } + + if ($trailing === true) { + if ($path === '' || ($path[-1] !== '/' && $path[-1] !== '\\')) { + $path .= '/'; + } + } elseif ($trailing === false) { + if ($path !== '' && ($path[-1] === '/' || $path[-1] === '\\')) { + $path = substr($path, 0, -1); + } + } + + return $path; + } +} + +if (!function_exists('Cake\Core\h')) { /** * Convenience method for htmlspecialchars. * - * @param string|array|object $text Text to wrap through htmlspecialchars. Also works with arrays, and objects. + * @param mixed $text Text to wrap through htmlspecialchars. Also works with arrays, and objects. * Arrays will be mapped and have all their elements escaped. Objects will be string cast if they - * implement a `__toString` method. Otherwise the class name will be used. + * implement a `__toString` method. Otherwise, the class name will be used. + * Other scalar types will be returned unchanged. * @param bool $double Encode existing html entities. - * @param string|null $charset Character set to use when escaping. Defaults to config value in `mb_internal_encoding()` - * or 'UTF-8'. - * @return string|array Wrapped text. - * @link https://book.cakephp.org/3.0/en/core-libraries/global-constants-and-functions.html#h + * @param string|null $charset Character set to use when escaping. + * Defaults to config value in `mb_internal_encoding()` or 'UTF-8'. + * @return mixed Wrapped text. + * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#h */ - function h($text, $double = true, $charset = null) + function h(mixed $text, bool $double = true, ?string $charset = null): mixed { if (is_string($text)) { //optimize for strings @@ -46,32 +114,25 @@ function h($text, $double = true, $charset = null) return $texts; } elseif (is_object($text)) { - if (method_exists($text, '__toString')) { + if ($text instanceof Stringable) { $text = (string)$text; } else { - $text = '(object)' . get_class($text); + $text = '(object)' . $text::class; } - } elseif (is_bool($text) || is_null($text) || is_int($text)) { + } elseif ($text === null || is_scalar($text)) { return $text; } static $defaultCharset = false; if ($defaultCharset === false) { - $defaultCharset = mb_internal_encoding(); - if ($defaultCharset === null) { - $defaultCharset = 'UTF-8'; - } - } - if (is_string($double)) { - $charset = $double; + $defaultCharset = mb_internal_encoding() ?: 'UTF-8'; } return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, $charset ?: $defaultCharset, $double); } - } -if (!function_exists('pluginSplit')) { +if (!function_exists('Cake\Core\pluginSplit')) { /** * Splits a dot syntax plugin name into its plugin and class name. * If $name does not have a dot, then index 0 will be null. @@ -84,35 +145,35 @@ function h($text, $double = true, $charset = null) * @param string $name The name you want to plugin split. * @param bool $dotAppend Set to true if you want the plugin to have a '.' appended to it. * @param string|null $plugin Optional default plugin to use if no plugin is found. Defaults to null. - * @return array Array with 2 indexes. 0 => plugin name, 1 => class name. - * @link https://book.cakephp.org/3.0/en/core-libraries/global-constants-and-functions.html#pluginSplit + * @return array{0: string|null, 1: string} Array with 2 indexes. 0 => plugin name, 1 => class name. + * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#pluginsplit */ - function pluginSplit($name, $dotAppend = false, $plugin = null) + function pluginSplit(string $name, bool $dotAppend = false, ?string $plugin = null): array { - if (strpos($name, '.') !== false) { + if (str_contains($name, '.')) { $parts = explode('.', $name, 2); if ($dotAppend) { $parts[0] .= '.'; } + /** @var array{string, string} */ return $parts; } return [$plugin, $name]; } - } -if (!function_exists('namespaceSplit')) { +if (!function_exists('Cake\Core\namespaceSplit')) { /** * Split the namespace from the classname. * * Commonly used like `list($namespace, $className) = namespaceSplit($class);`. * * @param string $class The full class name, ie `Cake\Core\App`. - * @return array Array with 2 indexes. 0 => namespace, 1 => classname. + * @return array{0: string, 1: string} Array with 2 indexes. 0 => namespace, 1 => classname. */ - function namespaceSplit($class) + function namespaceSplit(string $class): array { $pos = strrpos($class, '\\'); if ($pos === false) { @@ -121,66 +182,64 @@ function namespaceSplit($class) return [substr($class, 0, $pos), substr($class, $pos + 1)]; } - } -if (!function_exists('pr')) { +if (!function_exists('Cake\Core\pr')) { /** * print_r() convenience function. * - * In terminals this will act similar to using print_r() directly, when not run on cli - * print_r() will also wrap
 tags around the output of given variable. Similar to debug().
+     * In terminals this will act similar to using print_r() directly, when not run on CLI
+     * print_r() will also wrap `
` tags around the output of given variable. Similar to debug().
      *
      * This function returns the same variable that was passed.
      *
      * @param mixed $var Variable to print out.
      * @return mixed the same $var that was passed to this function
-     * @link https://book.cakephp.org/3.0/en/core-libraries/global-constants-and-functions.html#pr
+     * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#pr
      * @see debug()
      */
-    function pr($var)
+    function pr(mixed $var): mixed
     {
         if (!Configure::read('debug')) {
             return $var;
         }
 
-        $template = (PHP_SAPI !== 'cli' && PHP_SAPI !== 'phpdbg') ? '
%s
' : "\n%s\n\n"; + $template = PHP_SAPI !== 'cli' && PHP_SAPI !== 'phpdbg' ? '
%s
' : "\n%s\n\n"; printf($template, trim(print_r($var, true))); return $var; } - } -if (!function_exists('pj')) { +if (!function_exists('Cake\Core\pj')) { /** - * json pretty print convenience function. + * JSON pretty print convenience function. * - * In terminals this will act similar to using json_encode() with JSON_PRETTY_PRINT directly, when not run on cli - * will also wrap
 tags around the output of given variable. Similar to pr().
+     * In terminals this will act similar to using json_encode() with JSON_PRETTY_PRINT directly, when not run on CLI
+     * will also wrap `
` tags around the output of given variable. Similar to pr().
      *
      * This function returns the same variable that was passed.
      *
      * @param mixed $var Variable to print out.
      * @return mixed the same $var that was passed to this function
      * @see pr()
-     * @link https://book.cakephp.org/3.0/en/core-libraries/global-constants-and-functions.html#pj
+     * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#pj
      */
-    function pj($var)
+    function pj(mixed $var): mixed
     {
         if (!Configure::read('debug')) {
             return $var;
         }
 
-        $template = (PHP_SAPI !== 'cli' && PHP_SAPI !== 'phpdbg') ? '
%s
' : "\n%s\n\n"; - printf($template, trim(json_encode($var, JSON_PRETTY_PRINT))); + $template = PHP_SAPI !== 'cli' && PHP_SAPI !== 'phpdbg' ? '
%s
' : "\n%s\n\n"; + $flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; + printf($template, trim((string)json_encode($var, $flags))); return $var; } - } -if (!function_exists('env')) { +if (!function_exists('Cake\Core\env')) { /** * Gets an environment variable from available sources, and provides emulation * for unsupported or inconsistent environment variables (i.e. DOCUMENT_ROOT on @@ -188,33 +247,28 @@ function pj($var) * environment information. * * @param string $key Environment variable name. - * @param string|null $default Specify a default value in case the environment variable is not defined. - * @return string|bool|null Environment variable setting. - * @link https://book.cakephp.org/3.0/en/core-libraries/global-constants-and-functions.html#env + * @param string|float|int|bool|null $default Specify a default value in case the environment variable is not defined. + * @return string|float|int|bool|null Environment variable setting. + * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#env */ - function env($key, $default = null) + function env(string $key, string|float|int|bool|null $default = null): string|float|int|bool|null { if ($key === 'HTTPS') { if (isset($_SERVER['HTTPS'])) { - return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'); + return !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; } - return (strpos((string)env('SCRIPT_URI'), 'https://') === 0); + return str_starts_with((string)env('SCRIPT_URI'), 'https://'); } - if ($key === 'SCRIPT_NAME') { - if (env('CGI_MODE') && isset($_ENV['SCRIPT_URL'])) { - $key = 'SCRIPT_URL'; - } + if ($key === 'SCRIPT_NAME' && env('CGI_MODE') && isset($_ENV['SCRIPT_URL'])) { + $key = 'SCRIPT_URL'; } - $val = null; - if (isset($_SERVER[$key])) { - $val = $_SERVER[$key]; - } elseif (isset($_ENV[$key])) { - $val = $_ENV[$key]; - } elseif (getenv($key) !== false) { - $val = getenv($key); + $val = $_SERVER[$key] ?? $_ENV[$key] ?? null; + assert($val === null || is_scalar($val)); + if ($val == null && getenv($key) !== false) { + $val = (string)getenv($key); } if ($key === 'REMOTE_ADDR' && $val === env('SERVER_ADDR')) { @@ -230,21 +284,255 @@ function env($key, $default = null) switch ($key) { case 'DOCUMENT_ROOT': - $name = env('SCRIPT_NAME'); - $filename = env('SCRIPT_FILENAME'); + $name = (string)env('SCRIPT_NAME'); + $filename = (string)env('SCRIPT_FILENAME'); $offset = 0; - if (!strpos($name, '.php')) { + if (!str_ends_with($name, '.php')) { $offset = 4; } return substr($filename, 0, -(strlen($name) + $offset)); case 'PHP_SELF': - return str_replace(env('DOCUMENT_ROOT'), '', env('SCRIPT_FILENAME')); + return str_replace((string)env('DOCUMENT_ROOT'), '', (string)env('SCRIPT_FILENAME')); case 'CGI_MODE': - return (PHP_SAPI === 'cgi'); + return PHP_SAPI === 'cgi'; } return $default; } +} + +if (!function_exists('Cake\Core\triggerWarning')) { + /** + * Triggers an E_USER_WARNING. + * + * @param string $message The warning message. + * @return void + */ + function triggerWarning(string $message): void + { + trigger_error($message, E_USER_WARNING); + } +} + +if (!function_exists('Cake\Core\deprecationWarning')) { + /** + * Helper method for outputting deprecation warnings + * + * @param string $version The version that added this deprecation warning. + * @param string $message The message to output as a deprecation warning. + * @param int $stackFrame The stack frame to include in the error. Defaults to 1 + * as that should point to application/plugin code. + * @return void + */ + function deprecationWarning(string $version, string $message, int $stackFrame = 1): void + { + if (!(error_reporting() & E_USER_DEPRECATED)) { + return; + } + + $trace = debug_backtrace(); + if (isset($trace[$stackFrame])) { + $frame = $trace[$stackFrame]; + $frame += ['file' => '[internal]', 'line' => '??']; + + // Assuming we're installed in vendor/cakephp/cakephp/src/Core/functions.php + $root = dirname(__DIR__, 5); + if (defined('ROOT')) { + $root = ROOT; + } + $relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($frame['file'], strlen($root) + 1)); + $patterns = (array)Configure::read('Error.ignoredDeprecationPaths'); + foreach ($patterns as $pattern) { + $pattern = str_replace(DIRECTORY_SEPARATOR, '/', $pattern); + if (fnmatch($pattern, $relative)) { + return; + } + } + + $message = sprintf( + "Since %s: %s\n%s, line: %s\n" . + 'You can disable all deprecation warnings by setting `Error.errorLevel` to ' . + '`E_ALL & ~E_USER_DEPRECATED`. Adding `%s` to `Error.ignoredDeprecationPaths` ' . + 'in your `config/app.php` config will mute deprecations from that file only.', + $version, + $message, + $frame['file'], + $frame['line'], + $relative, + ); + } + + static $errors = []; + $checksum = hash('xxh128', $message); + $duplicate = (bool)Configure::read('Error.allowDuplicateDeprecations', false); + if (isset($errors[$checksum]) && !$duplicate) { + return; + } + if (!$duplicate) { + $errors[$checksum] = true; + } + + trigger_error($message, E_USER_DEPRECATED); + } +} + +if (!function_exists('Cake\Core\toString')) { + /** + * Converts the given value to a string. + * + * This method attempts to convert the given value to a string. + * If the value is already a string, it returns the value as it is. + * ``null`` is returned if the conversion is not possible. + * + * @param mixed $value The value to be converted. + * @return ?string Returns the string representation of the value, or null if the value is not a string. + * @since 5.1.0 + */ + function toString(mixed $value): ?string + { + if (is_string($value)) { + return $value; + } + if (is_int($value)) { + return (string)$value; + } + if (is_bool($value)) { + return $value ? '1' : '0'; + } + if (is_float($value)) { + if (is_nan($value) || is_infinite($value)) { + return null; + } + try { + $return = json_encode($value, JSON_THROW_ON_ERROR); + } catch (JsonException) { + $return = null; + } + + if ($return === null || str_contains($return, 'e')) { + return rtrim(sprintf('%.' . (PHP_FLOAT_DIG + 3) . 'F', $value), '.0'); + } + + return $return; + } + if ($value instanceof Stringable) { + return (string)$value; + } + + return null; + } +} + +if (!function_exists('Cake\Core\toInt')) { + /** + * Converts a value to an integer. + * + * This method attempts to convert the given value to an integer. + * If the conversion is successful, it returns the value as an integer. + * If the conversion fails, it returns NULL. + * + * String values are trimmed using trim(). + * + * @param mixed $value The value to be converted to an integer. + * @return int|null Returns the converted integer value or null if the conversion fails. + * @since 5.1.0 + */ + function toInt(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + if (is_string($value)) { + $value = trim($value); + if (preg_match('/^0+[^0]{1}/', $value)) { + $value = ltrim($value, '0'); + } + + $value = filter_var($value, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE); + + return $value === PHP_INT_MIN ? null : $value; + } + if (is_float($value)) { + if (is_nan($value) || is_infinite($value)) { + return null; + } + + return (int)$value; + } + if (is_bool($value)) { + return (int)$value; + } + + return null; + } +} + +if (!function_exists('Cake\Core\toFloat')) { + /** + * Converts a value to a float. + * + * This method attempts to convert the given value to a float. + * If the conversion is successful, it returns the value as an float. + * If the conversion fails, it returns NULL. + * + * String values are trimmed using trim(). + * + * @param mixed $value The value to be converted to a float. + * @return float|null Returns the converted float value or null if the conversion fails. + * @since 5.1.0 + */ + function toFloat(mixed $value): ?float + { + if (is_string($value)) { + $value = trim($value); + if (preg_match('/^0+[^0]{1}/', $value)) { + $value = ltrim($value, '0'); + } + + $value = filter_var($value, FILTER_VALIDATE_FLOAT, FILTER_NULL_ON_FAILURE); + + return $value === PHP_FLOAT_MIN ? null : $value; + } + if (is_float($value)) { + if (is_nan($value) || is_infinite($value)) { + return null; + } + + return $value; + } + if (is_int($value)) { + return (float)$value; + } + if (is_bool($value)) { + return (float)$value; + } + + return null; + } +} + +if (!function_exists('Cake\Core\toBool')) { + /** + * Converts a value to boolean. + * + * 1 | '1' | 1.0 | true - values returns as true + * 0 | '0' | 0.0 | false - values returns as false + * Other values returns as null. + * + * @param mixed $value The value to convert to boolean. + * @return bool|null Returns true if the value is truthy, false if it's falsy, or NULL otherwise. + * @since 5.1.0 + */ + function toBool(mixed $value): ?bool + { + if (in_array($value, ['1', 1, 1.0, true], true)) { + return true; + } + if (in_array($value, ['0', 0, 0.0, false], true)) { + return false; + } + return null; + } } diff --git a/src/Core/functions_global.php b/src/Core/functions_global.php new file mode 100644 index 00000000000..0644ecc15d8 --- /dev/null +++ b/src/Core/functions_global.php @@ -0,0 +1,270 @@ + $parts + * @param bool|null $trailing Determines how trailing slashes are handled + * - If true, ensures a trailing forward-slash is added if one doesn't exist + * - If false, ensures any trailing slash is removed + * - if null, ignores trailing slashes + * @return string + */ + function pathCombine(array $parts, ?bool $trailing = null): string + { + return cakePathCombine($parts, $trailing); + } +} + +if (!function_exists('h')) { + /** + * Convenience method for htmlspecialchars. + * + * @param mixed $text Text to wrap through htmlspecialchars. Also works with arrays, and objects. + * Arrays will be mapped and have all their elements escaped. Objects will be string cast if they + * implement a `__toString` method. Otherwise, the class name will be used. + * Other scalar types will be returned unchanged. + * @param bool $double Encode existing html entities. + * @param string|null $charset Character set to use when escaping. + * Defaults to config value in `mb_internal_encoding()` or 'UTF-8'. + * @return mixed Wrapped text. + * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#h + */ + function h(mixed $text, bool $double = true, ?string $charset = null): mixed + { + return cakeH($text, $double, $charset); + } +} + +if (!function_exists('pluginSplit')) { + /** + * Splits a dot syntax plugin name into its plugin and class name. + * If $name does not have a dot, then index 0 will be null. + * + * Commonly used like + * ``` + * list($plugin, $name) = pluginSplit($name); + * ``` + * + * @param string $name The name you want to plugin split. + * @param bool $dotAppend Set to true if you want the plugin to have a '.' appended to it. + * @param string|null $plugin Optional default plugin to use if no plugin is found. Defaults to null. + * @return array{0: string|null, 1: string} Array with 2 indexes. 0 => plugin name, 1 => class name. + * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#pluginsplit + */ + function pluginSplit(string $name, bool $dotAppend = false, ?string $plugin = null): array + { + return cakePluginSplit($name, $dotAppend, $plugin); + } +} + +if (!function_exists('namespaceSplit')) { + /** + * Split the namespace from the classname. + * + * Commonly used like `list($namespace, $className) = namespaceSplit($class);`. + * + * @param string $class The full class name, ie `Cake\Core\App`. + * @return array{0: string, 1: string} Array with 2 indexes. 0 => namespace, 1 => classname. + */ + function namespaceSplit(string $class): array + { + return cakeNamespaceSplit($class); + } +} + +if (!function_exists('pr')) { + /** + * print_r() convenience function. + * + * In terminals this will act similar to using print_r() directly, when not run on CLI + * print_r() will also wrap `
` tags around the output of given variable. Similar to debug().
+     *
+     * This function returns the same variable that was passed.
+     *
+     * @param mixed $var Variable to print out.
+     * @return mixed the same $var that was passed to this function
+     * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#pr
+     * @see debug()
+     */
+    function pr(mixed $var): mixed
+    {
+        return cakePr($var);
+    }
+}
+
+if (!function_exists('pj')) {
+    /**
+     * JSON pretty print convenience function.
+     *
+     * In terminals this will act similar to using json_encode() with JSON_PRETTY_PRINT directly, when not run on CLI
+     * will also wrap `
` tags around the output of given variable. Similar to pr().
+     *
+     * This function returns the same variable that was passed.
+     *
+     * @param mixed $var Variable to print out.
+     * @return mixed the same $var that was passed to this function
+     * @see pr()
+     * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#pj
+     */
+    function pj(mixed $var): mixed
+    {
+        return cakePj($var);
+    }
+}
+
+if (!function_exists('env')) {
+    /**
+     * Gets an environment variable from available sources, and provides emulation
+     * for unsupported or inconsistent environment variables (i.e. DOCUMENT_ROOT on
+     * IIS, or SCRIPT_NAME in CGI mode). Also exposes some additional custom
+     * environment information.
+     *
+     * @param string $key Environment variable name.
+     * @param string|float|int|bool|null $default Specify a default value in case the environment variable is not defined.
+     * @return string|float|int|bool|null Environment variable setting.
+     * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#env
+     */
+    function env(string $key, string|float|int|bool|null $default = null): string|float|int|bool|null
+    {
+        return cakeEnv($key, $default);
+    }
+}
+
+if (!function_exists('triggerWarning')) {
+    /**
+     * Triggers an E_USER_WARNING.
+     *
+     * @param string $message The warning message.
+     * @return void
+     */
+    function triggerWarning(string $message): void
+    {
+        cakeTriggerWarning($message);
+    }
+}
+
+if (!function_exists('deprecationWarning')) {
+    /**
+     * Helper method for outputting deprecation warnings
+     *
+     * @param string $version The version that added this deprecation warning.
+     * @param string $message The message to output as a deprecation warning.
+     * @param int $stackFrame The stack frame to include in the error. Defaults to 1
+     *   as that should point to application/plugin code.
+     * @return void
+     */
+    function deprecationWarning(string $version, string $message, int $stackFrame = 1): void
+    {
+        cakeDeprecationWarning($version, $message, $stackFrame + 1);
+    }
+}
+
+if (!function_exists('toString')) {
+    /**
+     * Converts the given value to a string.
+     *
+     * This method attempts to convert the given value to a string.
+     * If the value is already a string, it returns the value as it is.
+     * ``null`` is returned if the conversion is not possible.
+     *
+     * @param mixed $value The value to be converted.
+     * @return ?string Returns the string representation of the value, or null if the value is not a string.
+     * @since 5.1.1
+     */
+    function toString(mixed $value): ?string
+    {
+        return cakeToString($value);
+    }
+}
+
+if (!function_exists('toInt')) {
+    /**
+     * Converts a value to an integer.
+     *
+     * This method attempts to convert the given value to an integer.
+     * If the conversion is successful, it returns the value as an integer.
+     * If the conversion fails, it returns NULL.
+     *
+     * String values are trimmed using trim().
+     *
+     * @param mixed $value The value to be converted to an integer.
+     * @return int|null Returns the converted integer value or null if the conversion fails.
+     * @since 5.1.1
+     */
+    function toInt(mixed $value): ?int
+    {
+        return cakeToInt($value);
+    }
+}
+
+if (!function_exists('toFloat')) {
+    /**
+     * Converts a value to a float.
+     *
+     * This method attempts to convert the given value to a float.
+     * If the conversion is successful, it returns the value as an float.
+     * If the conversion fails, it returns NULL.
+     *
+     * String values are trimmed using trim().
+     *
+     * @param mixed $value The value to be converted to a float.
+     * @return float|null Returns the converted float value or null if the conversion fails.
+     * @since 5.1.1
+     */
+    function toFloat(mixed $value): ?float
+    {
+        return cakeToFloat($value);
+    }
+}
+
+if (!function_exists('toBool')) {
+    /**
+     * Converts a value to boolean.
+     *
+     *  1 | '1' | 1.0 | true  - values returns as true
+     *  0 | '0' | 0.0 | false - values returns as false
+     *  Other values returns as null.
+     *
+     * @param mixed $value The value to convert to boolean.
+     * @return bool|null Returns true if the value is truthy, false if it's falsy, or NULL otherwise.
+     * @since 5.1.1
+     */
+    function toBool(mixed $value): ?bool
+    {
+        return cakeToBool($value);
+    }
+}
diff --git a/src/Database/.gitattributes b/src/Database/.gitattributes
new file mode 100644
index 00000000000..0086560d10e
--- /dev/null
+++ b/src/Database/.gitattributes
@@ -0,0 +1,10 @@
+# Define the line ending behavior of the different file extensions
+# Set default behavior, in case users don't have core.autocrlf set.
+* text text=auto eol=lf
+
+.php diff=php
+
+# Remove files for archives generated using `git archive`
+.gitattributes export-ignore
+phpstan.neon.dist export-ignore
+tests/ export-ignore
diff --git a/src/Database/Connection.php b/src/Database/Connection.php
index 6c43d0e06dd..8c0f3d26331 100644
--- a/src/Database/Connection.php
+++ b/src/Database/Connection.php
@@ -1,69 +1,89 @@
 
      */
-    protected $_config;
+    protected array $_config;
 
     /**
-     * Driver object, responsible for creating the real connection
-     * and provide specific SQL dialect.
-     *
      * @var \Cake\Database\Driver
      */
-    protected $_driver;
+    protected Driver $readDriver;
+
+    /**
+     * @var \Cake\Database\Driver
+     */
+    protected Driver $writeDriver;
 
     /**
      * Contains how many nested transactions have been started.
      *
      * @var int
      */
-    protected $_transactionLevel = 0;
+    protected int $_transactionLevel = 0;
 
     /**
      * Whether a transaction is active in this connection.
      *
      * @var bool
      */
-    protected $_transactionStarted = false;
+    protected bool $_transactionStarted = false;
 
     /**
      * Whether this connection can and should use savepoints for nested
@@ -71,28 +91,21 @@ class Connection implements ConnectionInterface
      *
      * @var bool
      */
-    protected $_useSavePoints = false;
-
-    /**
-     * Whether to log queries generated during this connection.
-     *
-     * @var bool
-     */
-    protected $_logQueries = false;
+    protected bool $_useSavePoints = false;
 
     /**
-     * Logger object instance.
+     * Cacher object instance.
      *
-     * @var \Cake\Database\Log\QueryLogger|null
+     * @var \Psr\SimpleCache\CacheInterface|null
      */
-    protected $_logger;
+    protected ?CacheInterface $cacher = null;
 
     /**
      * The schema collection object
      *
-     * @var \Cake\Database\Schema\Collection|null
+     * @var \Cake\Database\Schema\CollectionInterface|null
      */
-    protected $_schemaCollection;
+    protected ?SchemaCollectionInterface $_schemaCollection = null;
 
     /**
      * NestedTransactionRollbackException object instance, will be stored if
@@ -100,26 +113,88 @@ class Connection implements ConnectionInterface
      *
      * @var \Cake\Database\Exception\NestedTransactionRollbackException|null
      */
-    protected $nestedTransactionRollbackException;
+    protected ?NestedTransactionRollbackException $nestedTransactionRollbackException = null;
+
+    /**
+     * Callbacks to execute after the outermost transaction commits.
+     *
+     * @var array<\Closure>
+     */
+    protected array $afterCommitCallbacks = [];
+
+    protected QueryFactory $queryFactory;
 
     /**
      * Constructor.
      *
-     * @param array $config configuration for connecting to database
+     * ### Available options:
+     *
+     * - `driver` Sort name or FQCN for driver.
+     * - `log` Boolean indicating whether to use query logging.
+     * - `name` Connection name.
+     * - `cacheMetaData` Boolean indicating whether metadata (datasource schemas) should be cached.
+     *    If set to a string it will be used as the name of cache config to use.
+     * - `cacheKeyPrefix` Custom prefix to use when generation cache keys. Defaults to connection name.
+     *
+     * @param array $config Configuration array.
+     * @throws \Cake\Database\Exception\MissingDriverException when the driver class cannot be found
+     * @throws \Cake\Database\Exception\MissingExtensionException when the database extension is not enabled
      */
-    public function __construct($config)
+    public function __construct(array $config)
     {
         $this->_config = $config;
+        [self::ROLE_READ => $this->readDriver, self::ROLE_WRITE => $this->writeDriver] = $this->createDrivers($config);
+    }
+
+    /**
+     * Creates read and write drivers.
+     *
+     * @param array $config Connection config
+     * @return array{self::ROLE_READ: \Cake\Database\Driver, self::ROLE_WRITE: \Cake\Database\Driver}
+     */
+    protected function createDrivers(array $config): array
+    {
+        $driver = $config['driver'] ?? '';
+        if (!is_string($driver)) {
+            assert($driver instanceof Driver);
+            if (!$driver->enabled()) {
+                throw new MissingExtensionException(['driver' => $driver::class, 'name' => $this->configName()]);
+            }
 
-        $driver = '';
-        if (!empty($config['driver'])) {
-            $driver = $config['driver'];
+            // Legacy support for setting instance instead of driver class
+            return [self::ROLE_READ => $driver, self::ROLE_WRITE => $driver];
         }
-        $this->setDriver($driver, $config);
 
-        if (!empty($config['log'])) {
-            $this->logQueries($config['log']);
+        /** @var class-string<\Cake\Database\Driver>|null $driverClass */
+        $driverClass = App::className($driver, 'Database/Driver');
+        if ($driverClass === null) {
+            throw new MissingDriverException(['driver' => $driver, 'connection' => $this->configName()]);
+        }
+
+        $sharedConfig = array_diff_key($config, array_flip([
+            'className',
+            'driver',
+            'cacheMetaData',
+            'cacheKeyPrefix',
+            'read',
+            'write',
+        ]));
+
+        $writeConfig = ($config['write'] ?? []) + $sharedConfig;
+        $readConfig = ($config['read'] ?? []) + $sharedConfig;
+        if (array_key_exists('write', $config) || array_key_exists('read', $config)) {
+            $readDriver = new $driverClass(['_role' => self::ROLE_READ] + $readConfig);
+            $writeDriver = new $driverClass(['_role' => self::ROLE_WRITE] + $writeConfig);
+        } else {
+            $readDriver = new $driverClass(['_role' => self::ROLE_WRITE] + $writeConfig);
+            $writeDriver = $readDriver;
         }
+
+        if (!$writeDriver->enabled()) {
+            throw new MissingExtensionException(['driver' => $writeDriver::class, 'name' => $this->configName()]);
+        }
+
+        return [self::ROLE_READ => $readDriver, self::ROLE_WRITE => $writeDriver];
     }
 
     /**
@@ -129,225 +204,196 @@ public function __construct($config)
      */
     public function __destruct()
     {
-        if ($this->_transactionStarted && class_exists('Cake\Log\Log')) {
-            Log::warning('The connection is going to be closed but there is an active transaction.');
+        if ($this->_transactionStarted && class_exists(Log::class)) {
+            $message = 'The connection is going to be closed but there is an active transaction.';
+
+            $requestUrl = env('REQUEST_URI');
+            if ($requestUrl) {
+                $message .= "\nRequest URL: " . $requestUrl;
+            }
+
+            $clientIp = env('REMOTE_ADDR');
+            if ($clientIp) {
+                $message .= "\nClient IP: " . $clientIp;
+            }
+
+            Log::warning($message);
         }
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function config()
+    public function config(): array
     {
         return $this->_config;
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function configName()
+    public function configName(): string
     {
-        if (empty($this->_config['name'])) {
-            return '';
-        }
-
-        return $this->_config['name'];
+        return $this->_config['name'] ?? '';
     }
 
     /**
-     * Sets the driver instance. If a string is passed it will be treated
-     * as a class name and will be instantiated.
+     * Returns the connection role: read or write.
      *
-     * @param \Cake\Database\Driver|string $driver The driver instance to use.
-     * @param array $config Config for a new driver.
-     * @throws \Cake\Database\Exception\MissingDriverException When a driver class is missing.
-     * @throws \Cake\Database\Exception\MissingExtensionException When a driver's PHP extension is missing.
-     * @return $this
+     * @return string
      */
-    public function setDriver($driver, $config = [])
+    public function role(): string
     {
-        if (is_string($driver)) {
-            $className = App::className($driver, 'Database/Driver');
-            if (!$className || !class_exists($className)) {
-                throw new MissingDriverException(['driver' => $driver]);
-            }
-            $driver = new $className($config);
-        }
-        if (!$driver->enabled()) {
-            throw new MissingExtensionException(['driver' => get_class($driver)]);
-        }
-
-        $this->_driver = $driver;
-
-        return $this;
+        return preg_match('/:read$/', $this->configName()) === 1 ? static::ROLE_READ : static::ROLE_WRITE;
     }
 
     /**
-     * Gets the driver instance.
+     * Get the retry wrapper object that is allows recovery from server disconnects
+     * while performing certain database actions, such as executing a query.
      *
-     * @return \Cake\Database\Driver
+     * @return \Cake\Core\Retry\CommandRetry The retry wrapper
      */
-    public function getDriver()
+    public function getDisconnectRetry(): CommandRetry
     {
-        return $this->_driver;
+        return new CommandRetry(new ReconnectStrategy($this));
     }
 
     /**
-     * Sets the driver instance. If a string is passed it will be treated
-     * as a class name and will be instantiated.
+     * Gets the role-specific driver instance.
      *
-     * If no params are passed it will return the current driver instance.
-     *
-     * @deprecated 3.4.0 Use setDriver()/getDriver() instead.
-     * @param \Cake\Database\Driver|string|null $driver The driver instance to use.
-     * @param array $config Either config for a new driver or null.
-     * @throws \Cake\Database\Exception\MissingDriverException When a driver class is missing.
-     * @throws \Cake\Database\Exception\MissingExtensionException When a driver's PHP extension is missing.
+     * @param string $role Connection role ('read' or 'write')
      * @return \Cake\Database\Driver
      */
-    public function driver($driver = null, $config = [])
+    public function getDriver(string $role = self::ROLE_WRITE): Driver
     {
-        if ($driver !== null) {
-            $this->setDriver($driver, $config);
-        }
+        assert($role === self::ROLE_READ || $role === self::ROLE_WRITE);
 
-        return $this->getDriver();
+        return $role === self::ROLE_READ ? $this->getReadDriver() : $this->getWriteDriver();
     }
 
     /**
-     * Connects to the configured database.
+     * Gets the read-role driver instance.
      *
-     * @throws \Cake\Database\Exception\MissingConnectionException if credentials are invalid.
-     * @return bool true, if the connection was already established or the attempt was successful.
+     * @return \Cake\Database\Driver
      */
-    public function connect()
+    public function getReadDriver(): Driver
     {
-        try {
-            return $this->_driver->connect();
-        } catch (Exception $e) {
-            throw new MissingConnectionException(['reason' => $e->getMessage()]);
-        }
+        return $this->readDriver;
     }
 
     /**
-     * Disconnects from database server.
+     * Gets the write-role driver instance.
      *
-     * @return void
+     * @return \Cake\Database\Driver
      */
-    public function disconnect()
+    public function getWriteDriver(): Driver
     {
-        $this->_driver->disconnect();
+        return $this->writeDriver;
     }
 
     /**
-     * Returns whether connection to database server was already established.
+     * Executes a query using $params for interpolating values and $types as a hint for each
+     * those params.
      *
-     * @return bool
+     * @param string $sql SQL to be executed and interpolated with $params
+     * @param array $params list or associative array of params to be interpolated in $sql as values
+     * @param array $types list or associative array of types to be used for casting values in query
+     * @return \Cake\Database\StatementInterface executed statement
      */
-    public function isConnected()
+    public function execute(string $sql, array $params = [], array $types = []): StatementInterface
     {
-        return $this->_driver->isConnected();
+        return $this->getDisconnectRetry()->run(fn() => $this->getWriteDriver()->execute($sql, $params, $types));
     }
 
     /**
-     * Prepares a SQL statement to be executed.
+     * Executes the provided query after compiling it for the specific driver
+     * dialect and returns the executed Statement object.
      *
-     * @param string|\Cake\Database\Query $sql The SQL to convert into a prepared statement.
-     * @return \Cake\Database\StatementInterface
+     * @param \Cake\Database\Query $query The query to be executed
+     * @return \Cake\Database\StatementInterface executed statement
      */
-    public function prepare($sql)
+    public function run(Query $query): StatementInterface
     {
-        $statement = $this->_driver->prepare($sql);
-
-        if ($this->_logQueries) {
-            $statement = $this->_newLogger($statement);
-        }
-
-        return $statement;
+        return $this->getDisconnectRetry()->run(fn() => $this->getDriver($query->getConnectionRole())->run($query));
     }
 
     /**
-     * Executes a query using $params for interpolating values and $types as a hint for each
-     * those params.
+     * Get query factory instance.
      *
-     * @param string $query SQL to be executed and interpolated with $params
-     * @param array $params list or associative array of params to be interpolated in $query as values
-     * @param array $types list or associative array of types to be used for casting values in query
-     * @return \Cake\Database\StatementInterface executed statement
+     * @return \Cake\Database\Query\QueryFactory
      */
-    public function execute($query, array $params = [], array $types = [])
+    public function queryFactory(): QueryFactory
     {
-        if (!empty($params)) {
-            $statement = $this->prepare($query);
-            $statement->bind($params, $types);
-            $statement->execute();
-        } else {
-            $statement = $this->query($query);
-        }
-
-        return $statement;
+        return $this->queryFactory ??= new QueryFactory($this);
     }
 
     /**
-     * Compiles a Query object into a SQL string according to the dialect for this
-     * connection's driver
+     * Create a new SelectQuery instance for this connection.
      *
-     * @param \Cake\Database\Query $query The query to be compiled
-     * @param \Cake\Database\ValueBinder $generator The placeholder generator to use
-     * @return string
+     * @param \Cake\Database\ExpressionInterface|\Closure|array|string|float|int $fields Fields/columns list for the query.
+     * @param array|string $table The table or list of tables to query.
+     * @param array $types Associative array containing the types to be used for casting.
+     * @return \Cake\Database\Query\SelectQuery
      */
-    public function compileQuery(Query $query, ValueBinder $generator)
-    {
-        return $this->getDriver()->compileQuery($query, $generator)[1];
+    public function selectQuery(
+        ExpressionInterface|Closure|array|string|float|int $fields = [],
+        array|string $table = [],
+        array $types = [],
+    ): SelectQuery {
+        return $this->queryFactory()->select($fields, $table, $types);
     }
 
     /**
-     * Executes the provided query after compiling it for the specific driver
-     * dialect and returns the executed Statement object.
+     * Create a new InsertQuery instance for this connection.
      *
-     * @param \Cake\Database\Query $query The query to be executed
-     * @return \Cake\Database\StatementInterface executed statement
+     * @param string|null $table The table to insert rows into.
+     * @param array $values Associative array of column => value to be inserted.
+     * @param array $types Associative array containing the types to be used for casting.
+     * @return \Cake\Database\Query\InsertQuery
      */
-    public function run(Query $query)
+    public function insertQuery(?string $table = null, array $values = [], array $types = []): InsertQuery
     {
-        $statement = $this->prepare($query);
-        $query->getValueBinder()->attachTo($statement);
-        $statement->execute();
-
-        return $statement;
+        return $this->queryFactory()->insert($table, $values, $types);
     }
 
     /**
-     * Executes a SQL statement and returns the Statement object as result.
+     * Create a new UpdateQuery instance for this connection.
      *
-     * @param string $sql The SQL query to execute.
-     * @return \Cake\Database\StatementInterface
+     * @param \Cake\Database\ExpressionInterface|string|null $table The table to update rows of.
+     * @param array $values Values to be updated.
+     * @param array $conditions Conditions to be set for the update statement.
+     * @param array $types Associative array containing the types to be used for casting.
+     * @return \Cake\Database\Query\UpdateQuery
      */
-    public function query($sql)
-    {
-        $statement = $this->prepare($sql);
-        $statement->execute();
-
-        return $statement;
+    public function updateQuery(
+        ExpressionInterface|string|null $table = null,
+        array $values = [],
+        array $conditions = [],
+        array $types = [],
+    ): UpdateQuery {
+        return $this->queryFactory()->update($table, $values, $conditions, $types);
     }
 
     /**
-     * Create a new Query instance for this connection.
+     * Create a new DeleteQuery instance for this connection.
      *
-     * @return \Cake\Database\Query
+     * @param string|null $table The table to delete rows from.
+     * @param array $conditions Conditions to be set for the delete statement.
+     * @param array $types Associative array containing the types to be used for casting.
+     * @return \Cake\Database\Query\DeleteQuery
      */
-    public function newQuery()
+    public function deleteQuery(?string $table = null, array $conditions = [], array $types = []): DeleteQuery
     {
-        return new Query($this);
+        return $this->queryFactory()->delete($table, $conditions, $types);
     }
 
     /**
      * Sets a Schema\Collection object for this connection.
      *
-     * @param \Cake\Database\Schema\Collection $collection The schema collection object
+     * @param \Cake\Database\Schema\CollectionInterface $collection The schema collection object
      * @return $this
      */
-    public function setSchemaCollection(SchemaCollection $collection)
+    public function setSchemaCollection(SchemaCollectionInterface $collection)
     {
         $this->_schemaCollection = $collection;
 
@@ -357,70 +403,50 @@ public function setSchemaCollection(SchemaCollection $collection)
     /**
      * Gets a Schema\Collection object for this connection.
      *
-     * @return \Cake\Database\Schema\Collection
+     * @return \Cake\Database\Schema\CollectionInterface
      */
-    public function getSchemaCollection()
+    public function getSchemaCollection(): SchemaCollectionInterface
     {
         if ($this->_schemaCollection !== null) {
             return $this->_schemaCollection;
         }
 
         if (!empty($this->_config['cacheMetadata'])) {
-            return $this->_schemaCollection = new CachedCollection($this, $this->_config['cacheMetadata']);
+            return $this->_schemaCollection = new CachedCollection(
+                new SchemaCollection($this),
+                empty($this->_config['cacheKeyPrefix']) ? $this->configName() : $this->_config['cacheKeyPrefix'],
+                $this->getCacher(),
+            );
         }
 
         return $this->_schemaCollection = new SchemaCollection($this);
     }
 
-    /**
-     * Gets or sets a Schema\Collection object for this connection.
-     *
-     * @deprecated 3.4.0 Use setSchemaCollection()/getSchemaCollection()
-     * @param \Cake\Database\Schema\Collection|null $collection The schema collection object
-     * @return \Cake\Database\Schema\Collection
-     */
-    public function schemaCollection(SchemaCollection $collection = null)
-    {
-        if ($collection !== null) {
-            $this->setSchemaCollection($collection);
-        }
-
-        return $this->getSchemaCollection();
-    }
-
     /**
      * Executes an INSERT query on the specified table.
      *
      * @param string $table the table to insert values in
-     * @param array $data values to be inserted
-     * @param array $types list of associative array containing the types to be used for casting
+     * @param array $values values to be inserted
+     * @param array $types Array containing the types to be used for casting
      * @return \Cake\Database\StatementInterface
      */
-    public function insert($table, array $data, array $types = [])
+    public function insert(string $table, array $values, array $types = []): StatementInterface
     {
-        $columns = array_keys($data);
-
-        return $this->newQuery()->insert($columns, $types)
-            ->into($table)
-            ->values($data)
-            ->execute();
+        return $this->insertQuery($table, $values, $types)->execute();
     }
 
     /**
      * Executes an UPDATE statement on the specified table.
      *
      * @param string $table the table to update rows from
-     * @param array $data values to be updated
+     * @param array $values values to be updated
      * @param array $conditions conditions to be set for update statement
-     * @param array $types list of associative array containing the types to be used for casting
+     * @param array $types list of associative array containing the types to be used for casting
      * @return \Cake\Database\StatementInterface
      */
-    public function update($table, array $data, array $conditions = [], $types = [])
+    public function update(string $table, array $values, array $conditions = [], array $types = []): StatementInterface
     {
-        return $this->newQuery()->update($table)
-            ->set($data, $types)
-            ->where($conditions, $types)
-            ->execute();
+        return $this->updateQuery($table, $values, $conditions, $types)->execute();
     }
 
     /**
@@ -428,14 +454,12 @@ public function update($table, array $data, array $conditions = [], $types = [])
      *
      * @param string $table the table to delete rows from
      * @param array $conditions conditions to be set for delete statement
-     * @param array $types list of associative array containing the types to be used for casting
+     * @param array $types list of associative array containing the types to be used for casting
      * @return \Cake\Database\StatementInterface
      */
-    public function delete($table, $conditions = [], $types = [])
+    public function delete(string $table, array $conditions = [], array $types = []): StatementInterface
     {
-        return $this->newQuery()->delete($table)
-            ->where($conditions, $types)
-            ->execute();
+        return $this->deleteQuery($table, $conditions, $types)->execute();
     }
 
     /**
@@ -443,13 +467,13 @@ public function delete($table, $conditions = [], $types = [])
      *
      * @return void
      */
-    public function begin()
+    public function begin(): void
     {
         if (!$this->_transactionStarted) {
-            if ($this->_logQueries) {
-                $this->log('BEGIN');
-            }
-            $this->_driver->beginTransaction();
+            $this->getDisconnectRetry()->run(function (): void {
+                $this->getWriteDriver()->beginTransaction();
+            });
+
             $this->_transactionLevel = 0;
             $this->_transactionStarted = true;
             $this->nestedTransactionRollbackException = null;
@@ -463,12 +487,33 @@ public function begin()
         }
     }
 
+    /**
+     * Register a callback to run after the outermost transaction commits.
+     *
+     * If no transaction is active, the callback executes immediately.
+     * Callbacks are discarded on rollback.
+     *
+     * @param \Closure $callback Callback to execute after commit.
+     * @return void
+     */
+    public function afterCommit(Closure $callback): void
+    {
+        if (!$this->_transactionStarted) {
+            $callback();
+
+            return;
+        }
+
+        $this->afterCommitCallbacks[] = $callback;
+    }
+
     /**
      * Commits current transaction.
      *
      * @return bool true on success, false otherwise
+     * @throws \Cake\Database\Exception\NestedTransactionRollbackException when a nested transaction was rolled back
      */
-    public function commit()
+    public function commit(): bool
     {
         if (!$this->_transactionStarted) {
             return false;
@@ -477,17 +522,25 @@ public function commit()
         if ($this->_transactionLevel === 0) {
             if ($this->wasNestedTransactionRolledback()) {
                 $e = $this->nestedTransactionRollbackException;
+                assert($e !== null);
                 $this->nestedTransactionRollbackException = null;
                 throw $e;
             }
 
             $this->_transactionStarted = false;
             $this->nestedTransactionRollbackException = null;
-            if ($this->_logQueries) {
-                $this->log('COMMIT');
+
+            $result = $this->getWriteDriver()->commitTransaction();
+
+            $callbacks = $this->afterCommitCallbacks;
+            $this->afterCommitCallbacks = [];
+            foreach ($callbacks as $cb) {
+                $cb();
             }
 
-            return $this->_driver->commitTransaction();
+            $this->dispatchEvent('Connection.afterCommit');
+
+            return $result;
         }
         if ($this->isSavePointsEnabled()) {
             $this->releaseSavePoint((string)$this->_transactionLevel);
@@ -501,28 +554,24 @@ public function commit()
     /**
      * Rollback current transaction.
      *
-     * @param bool|null $toBeginning Whether or not the transaction should be rolled back to the
+     * @param bool|null $toBeginning Whether the transaction should be rolled back to the
      * beginning of it. Defaults to false if using savepoints, or true if not.
      * @return bool
      */
-    public function rollback($toBeginning = null)
+    public function rollback(?bool $toBeginning = null): bool
     {
         if (!$this->_transactionStarted) {
             return false;
         }
 
         $useSavePoint = $this->isSavePointsEnabled();
-        if ($toBeginning === null) {
-            $toBeginning = !$useSavePoint;
-        }
+        $toBeginning ??= !$useSavePoint;
         if ($this->_transactionLevel === 0 || $toBeginning) {
             $this->_transactionLevel = 0;
             $this->_transactionStarted = false;
             $this->nestedTransactionRollbackException = null;
-            if ($this->_logQueries) {
-                $this->log('ROLLBACK');
-            }
-            $this->_driver->rollbackTransaction();
+            $this->afterCommitCallbacks = [];
+            $this->getWriteDriver()->rollbackTransaction();
 
             return true;
         }
@@ -530,106 +579,89 @@ public function rollback($toBeginning = null)
         $savePoint = $this->_transactionLevel--;
         if ($useSavePoint) {
             $this->rollbackSavepoint($savePoint);
-        } elseif ($this->nestedTransactionRollbackException === null) {
-            $this->nestedTransactionRollbackException = new NestedTransactionRollbackException();
+        } else {
+            $this->nestedTransactionRollbackException ??= new NestedTransactionRollbackException();
         }
 
         return true;
     }
 
     /**
-     * Enables/disables the usage of savepoints, enables only if driver the allows it.
+     * Enables/disables the usage of savepoints, enables only if the driver allows it.
      *
-     * If you are trying to enable this feature, make sure you check the return value of this
-     * function to verify it was enabled successfully.
+     * If you are trying to enable this feature, make sure you check
+     * `isSavePointsEnabled()` to verify that savepoints were enabled successfully.
      *
-     * ### Example:
-     *
-     * `$connection->enableSavePoints(true)` Returns true if drivers supports save points, false otherwise
-     * `$connection->enableSavePoints(false)` Disables usage of savepoints and returns false
-     *
-     * @param bool $enable Whether or not save points should be used.
+     * @param bool $enable Whether save points should be used.
      * @return $this
      */
-    public function enableSavePoints($enable)
+    public function enableSavePoints(bool $enable = true)
     {
         if ($enable === false) {
             $this->_useSavePoints = false;
         } else {
-            $this->_useSavePoints = $this->_driver->supportsSavePoints();
+            $this->_useSavePoints = $this->getWriteDriver()->supports(DriverFeatureEnum::SAVEPOINT);
         }
 
         return $this;
     }
 
     /**
-     * Returns whether this connection is using savepoints for nested transactions
+     * Disables the usage of savepoints.
      *
-     * @return bool true if enabled, false otherwise
+     * @return $this
      */
-    public function isSavePointsEnabled()
+    public function disableSavePoints()
     {
-        return $this->_useSavePoints;
+        $this->_useSavePoints = false;
+
+        return $this;
     }
 
     /**
      * Returns whether this connection is using savepoints for nested transactions
-     * If a boolean is passed as argument it will enable/disable the usage of savepoints
-     * only if driver the allows it.
-     *
-     * If you are trying to enable this feature, make sure you check the return value of this
-     * function to verify it was enabled successfully.
      *
-     * ### Example:
-     *
-     * `$connection->useSavePoints(true)` Returns true if drivers supports save points, false otherwise
-     * `$connection->useSavePoints(false)` Disables usage of savepoints and returns false
-     * `$connection->useSavePoints()` Returns current status
-     *
-     * @deprecated 3.4.0 Use enableSavePoints()/isSavePointsEnabled() instead.
-     * @param bool|null $enable Whether or not save points should be used.
      * @return bool true if enabled, false otherwise
      */
-    public function useSavePoints($enable = null)
+    public function isSavePointsEnabled(): bool
     {
-        if ($enable !== null) {
-            $this->enableSavePoints($enable);
-        }
-
-        return $this->isSavePointsEnabled();
+        return $this->_useSavePoints;
     }
 
     /**
      * Creates a new save point for nested transactions.
      *
-     * @param string $name The save point name.
+     * @param string|int $name Save point name or id
      * @return void
      */
-    public function createSavePoint($name)
+    public function createSavePoint(string|int $name): void
     {
-        $this->execute($this->_driver->savePointSQL($name))->closeCursor();
+        $this->execute($this->getWriteDriver()->savePointSQL($name));
     }
 
     /**
      * Releases a save point by its name.
      *
-     * @param string $name The save point name.
+     * @param string|int $name Save point name or id
      * @return void
      */
-    public function releaseSavePoint($name)
+    public function releaseSavePoint(string|int $name): void
     {
-        $this->execute($this->_driver->releaseSavePointSQL($name))->closeCursor();
+        $sql = $this->getWriteDriver()->releaseSavePointSQL($name);
+        if ($sql) {
+            $this->execute($sql);
+        }
     }
 
     /**
      * Rollback a save point by its name.
      *
-     * @param string $name The save point name.
+     * @param string|int $name Save point name or id
      * @return void
      */
-    public function rollbackSavepoint($name)
+    public function rollbackSavepoint(string|int $name): void
     {
-        $this->execute($this->_driver->rollbackSavePointSQL($name))->closeCursor();
+        $this->execute($this->getWriteDriver()->rollbackSavePointSQL($name));
     }
 
     /**
@@ -637,9 +669,11 @@ public function rollbackSavepoint($name)
      *
      * @return void
      */
-    public function disableForeignKeys()
+    public function disableForeignKeys(): void
     {
-        $this->execute($this->_driver->disableForeignKeySQL())->closeCursor();
+        $this->getDisconnectRetry()->run(function (): void {
+            $this->execute($this->getWriteDriver()->disableForeignKeySQL());
+        });
     }
 
     /**
@@ -647,40 +681,42 @@ public function disableForeignKeys()
      *
      * @return void
      */
-    public function enableForeignKeys()
+    public function enableForeignKeys(): void
     {
-        $this->execute($this->_driver->enableForeignKeySQL())->closeCursor();
+        $this->getDisconnectRetry()->run(function (): void {
+            $this->execute($this->getWriteDriver()->enableForeignKeySQL());
+        });
     }
 
     /**
-     * Returns whether the driver supports adding or dropping constraints
-     * to already created tables.
+     * Executes a callback inside a transaction, if any exception occurs
+     * while executing the passed callback, the transaction will be rolled back
+     * If the result of the callback is `false`, the transaction will
+     * also be rolled back. Otherwise the transaction is committed after executing
+     * the callback.
      *
-     * @return bool true if driver supports dynamic constraints
-     */
-    public function supportsDynamicConstraints()
-    {
-        return $this->_driver->supportsDynamicConstraints();
-    }
-
-    /**
-     * {@inheritDoc}
+     * The callback will receive the connection instance as its first argument.
      *
      * ### Example:
      *
      * ```
      * $connection->transactional(function ($connection) {
-     *   $connection->newQuery()->delete('users')->execute();
+     *   $connection->deleteQuery('users')->execute();
      * });
      * ```
+     *
+     * @param \Closure $callback The callback to execute within a transaction.
+     * @return mixed The return value of the callback.
+     * @throws \Exception Will re-throw any exception raised in $callback after
+     *   rolling back the transaction.
      */
-    public function transactional(callable $callback)
+    public function transactional(Closure $callback): mixed
     {
         $this->begin();
 
         try {
             $result = $callback($this);
-        } catch (Exception $e) {
+        } catch (Throwable $e) {
             $this->rollback(false);
             throw $e;
         }
@@ -706,36 +742,42 @@ public function transactional(callable $callback)
      *
      * @return bool
      */
-    protected function wasNestedTransactionRolledback()
+    protected function wasNestedTransactionRolledback(): bool
     {
         return $this->nestedTransactionRollbackException instanceof NestedTransactionRollbackException;
     }
 
     /**
-     * {@inheritDoc}
+     * Run an operation with constraints disabled.
+     *
+     * Constraints should be re-enabled after the callback succeeds/fails.
      *
      * ### Example:
      *
      * ```
      * $connection->disableConstraints(function ($connection) {
-     *   $connection->newQuery()->delete('users')->execute();
+     *   $connection->insertQuery('users')->execute();
      * });
      * ```
+     *
+     * @param \Closure $callback Callback to run with constraints disabled
+     * @return mixed The return value of the callback.
+     * @throws \Exception Will re-throw any exception raised in $callback after
+     *   rolling back the transaction.
      */
-    public function disableConstraints(callable $callback)
+    public function disableConstraints(Closure $callback): mixed
     {
-        $this->disableForeignKeys();
-
-        try {
-            $result = $callback($this);
-        } catch (Exception $e) {
-            $this->enableForeignKeys();
-            throw $e;
-        }
+        return $this->getDisconnectRetry()->run(function () use ($callback) {
+            $this->disableForeignKeys();
 
-        $this->enableForeignKeys();
+            try {
+                $result = $callback($this);
+            } finally {
+                $this->enableForeignKeys();
+            }
 
-        return $result;
+            return $result;
+        });
     }
 
     /**
@@ -743,168 +785,95 @@ public function disableConstraints(callable $callback)
      *
      * @return bool True if a transaction is running else false.
      */
-    public function inTransaction()
+    public function inTransaction(): bool
     {
         return $this->_transactionStarted;
     }
 
-    /**
-     * Quotes value to be used safely in database query.
-     *
-     * @param mixed $value The value to quote.
-     * @param string|null $type Type to be used for determining kind of quoting to perform
-     * @return string Quoted value
-     */
-    public function quote($value, $type = null)
-    {
-        list($value, $type) = $this->cast($value, $type);
-
-        return $this->_driver->quote($value, $type);
-    }
-
-    /**
-     * Checks if the driver supports quoting.
-     *
-     * @return bool
-     */
-    public function supportsQuoting()
-    {
-        return $this->_driver->supportsQuoting();
-    }
-
-    /**
-     * Quotes a database identifier (a column name, table name, etc..) to
-     * be used safely in queries without the risk of using reserved words.
-     *
-     * @param string $identifier The identifier to quote.
-     * @return string
-     */
-    public function quoteIdentifier($identifier)
-    {
-        return $this->_driver->quoteIdentifier($identifier);
-    }
-
     /**
      * Enables or disables metadata caching for this connection
      *
      * Changing this setting will not modify existing schema collections objects.
      *
-     * @param bool|string $cache Either boolean false to disable metadata caching, or
+     * @param string|bool $cache Either boolean false to disable metadata caching, or
      *   true to use `_cake_model_` or the name of the cache config to use.
      * @return void
      */
-    public function cacheMetadata($cache)
+    public function cacheMetadata(string|bool $cache): void
     {
         $this->_schemaCollection = null;
         $this->_config['cacheMetadata'] = $cache;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function logQueries($enable = null)
-    {
-        if ($enable === null) {
-            return $this->_logQueries;
+        if (is_string($cache)) {
+            $this->cacher = null;
         }
-        $this->_logQueries = $enable;
     }
 
     /**
-     * {@inheritDoc}
-     *
-     * @deprecated 3.5.0 Use getLogger() and setLogger() instead.
+     * @inheritDoc
      */
-    public function logger($instance = null)
+    public function setCacher(CacheInterface $cacher)
     {
-        if ($instance === null) {
-            return $this->getLogger();
-        }
-
-        $this->setLogger($instance);
-    }
-
-    /**
-     * Sets a logger
-     *
-     * @param \Cake\Database\Log\QueryLogger $logger Logger object
-     * @return $this
-     */
-    public function setLogger($logger)
-    {
-        $this->_logger = $logger;
+        $this->cacher = $cacher;
 
         return $this;
     }
 
     /**
-     * Gets the logger object
-     *
-     * @return \Cake\Database\Log\QueryLogger logger instance
+     * @inheritDoc
      */
-    public function getLogger()
+    public function getCacher(): CacheInterface
     {
-        if ($this->_logger === null) {
-            $this->_logger = new QueryLogger();
+        if ($this->cacher !== null) {
+            return $this->cacher;
         }
 
-        return $this->_logger;
-    }
-
-    /**
-     * Logs a Query string using the configured logger object.
-     *
-     * @param string $sql string to be logged
-     * @return void
-     */
-    public function log($sql)
-    {
-        $query = new LoggedQuery();
-        $query->query = $sql;
-        $this->getLogger()->log($query);
-    }
+        $configName = $this->_config['cacheMetadata'] ?? '_cake_model_';
+        if (!is_string($configName)) {
+            $configName = '_cake_model_';
+        }
 
-    /**
-     * Returns a new statement object that will log the activity
-     * for the passed original statement instance.
-     *
-     * @param \Cake\Database\StatementInterface $statement the instance to be decorated
-     * @return \Cake\Database\Log\LoggingStatement
-     */
-    protected function _newLogger(StatementInterface $statement)
-    {
-        $log = new LoggingStatement($statement, $this->_driver);
-        $log->setLogger($this->getLogger());
+        if (!class_exists(Cache::class)) {
+            throw new CakeException(
+                'To use caching you must either set a cacher using Connection::setCacher()' .
+                ' or require the cakephp/cache package in your composer config.',
+            );
+        }
 
-        return $log;
+        return $this->cacher = Cache::pool($configName);
     }
 
     /**
      * Returns an array that can be used to describe the internal state of this
      * object.
      *
-     * @return array
+     * @return array
      */
-    public function __debugInfo()
+    public function __debugInfo(): array
     {
         $secrets = [
             'password' => '*****',
             'username' => '*****',
             'host' => '*****',
             'database' => '*****',
-            'port' => '*****'
+            'port' => '*****',
         ];
         $replace = array_intersect_key($secrets, $this->_config);
         $config = $replace + $this->_config;
 
+        if (isset($config['read'])) {
+            $config['read'] = array_intersect_key($secrets, $config['read']) + $config['read'];
+        }
+        if (isset($config['write'])) {
+            $config['write'] = array_intersect_key($secrets, $config['write']) + $config['write'];
+        }
+
         return [
             'config' => $config,
-            'driver' => $this->_driver,
+            'readDriver' => $this->readDriver,
+            'writeDriver' => $this->writeDriver,
             'transactionLevel' => $this->_transactionLevel,
             'transactionStarted' => $this->_transactionStarted,
             'useSavePoints' => $this->_useSavePoints,
-            'logQueries' => $this->_logQueries,
-            'logger' => $this->_logger
         ];
     }
 }
diff --git a/src/Database/ConstraintsInterface.php b/src/Database/ConstraintsInterface.php
new file mode 100644
index 00000000000..580dc49168a
--- /dev/null
+++ b/src/Database/ConstraintsInterface.php
@@ -0,0 +1,47 @@
+_schemaDialect) {
-            $this->_schemaDialect = new MysqlSchema($this);
-        }
-
-        return $this->_schemaDialect;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function disableForeignKeySQL()
-    {
-        return 'SET foreign_key_checks = 0';
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function enableForeignKeySQL()
-    {
-        return 'SET foreign_key_checks = 1';
-    }
-}
diff --git a/src/Database/Dialect/PostgresDialectTrait.php b/src/Database/Dialect/PostgresDialectTrait.php
deleted file mode 100644
index 3f3e5f4bfa6..00000000000
--- a/src/Database/Dialect/PostgresDialectTrait.php
+++ /dev/null
@@ -1,189 +0,0 @@
-clause('epilog')) {
-            $query->epilog('RETURNING *');
-        }
-
-        return $query;
-    }
-
-    /**
-     * Returns a dictionary of expressions to be transformed when compiling a Query
-     * to SQL. Array keys are method names to be called in this class
-     *
-     * @return array
-     */
-    protected function _expressionTranslators()
-    {
-        $namespace = 'Cake\Database\Expression';
-
-        return [
-            $namespace . '\FunctionExpression' => '_transformFunctionExpression'
-        ];
-    }
-
-    /**
-     * Receives a FunctionExpression and changes it so that it conforms to this
-     * SQL dialect.
-     *
-     * @param \Cake\Database\Expression\FunctionExpression $expression The function expression to convert
-     *   to postgres SQL.
-     * @return void
-     */
-    protected function _transformFunctionExpression(FunctionExpression $expression)
-    {
-        switch ($expression->getName()) {
-            case 'CONCAT':
-                // CONCAT function is expressed as exp1 || exp2
-                $expression->setName('')->setConjunction(' ||');
-                break;
-            case 'DATEDIFF':
-                $expression
-                    ->setName('')
-                    ->setConjunction('-')
-                    ->iterateParts(function ($p) {
-                        if (is_string($p)) {
-                            $p = ['value' => [$p => 'literal'], 'type' => null];
-                        } else {
-                            $p['value'] = [$p['value']];
-                        }
-
-                        return new FunctionExpression('DATE', $p['value'], [$p['type']]);
-                    });
-                break;
-            case 'CURRENT_DATE':
-                $time = new FunctionExpression('LOCALTIMESTAMP', [' 0 ' => 'literal']);
-                $expression->setName('CAST')->setConjunction(' AS ')->add([$time, 'date' => 'literal']);
-                break;
-            case 'CURRENT_TIME':
-                $time = new FunctionExpression('LOCALTIMESTAMP', [' 0 ' => 'literal']);
-                $expression->setName('CAST')->setConjunction(' AS ')->add([$time, 'time' => 'literal']);
-                break;
-            case 'NOW':
-                $expression->setName('LOCALTIMESTAMP')->add([' 0 ' => 'literal']);
-                break;
-            case 'DATE_ADD':
-                $expression
-                    ->setName('')
-                    ->setConjunction(' + INTERVAL')
-                    ->iterateParts(function ($p, $key) {
-                        if ($key === 1) {
-                            $p = sprintf("'%s'", $p);
-                        }
-
-                        return $p;
-                    });
-                break;
-            case 'DAYOFWEEK':
-                $expression
-                    ->setName('EXTRACT')
-                    ->setConjunction(' ')
-                    ->add(['DOW FROM' => 'literal'], [], true)
-                    ->add([') + (1' => 'literal']); // Postgres starts on index 0 but Sunday should be 1
-                break;
-        }
-    }
-
-    /**
-     * Get the schema dialect.
-     *
-     * Used by Cake\Database\Schema package to reflect schema and
-     * generate schema.
-     *
-     * @return \Cake\Database\Schema\PostgresSchema
-     */
-    public function schemaDialect()
-    {
-        if (!$this->_schemaDialect) {
-            $this->_schemaDialect = new PostgresSchema($this);
-        }
-
-        return $this->_schemaDialect;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function disableForeignKeySQL()
-    {
-        return 'SET CONSTRAINTS ALL DEFERRED';
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function enableForeignKeySQL()
-    {
-        return 'SET CONSTRAINTS ALL IMMEDIATE';
-    }
-}
diff --git a/src/Database/Dialect/SqliteDialectTrait.php b/src/Database/Dialect/SqliteDialectTrait.php
deleted file mode 100644
index 24550909ce2..00000000000
--- a/src/Database/Dialect/SqliteDialectTrait.php
+++ /dev/null
@@ -1,196 +0,0 @@
- 'd',
-        'hour' => 'H',
-        'month' => 'm',
-        'minute' => 'M',
-        'second' => 'S',
-        'week' => 'W',
-        'year' => 'Y'
-    ];
-
-    /**
-     * Returns a dictionary of expressions to be transformed when compiling a Query
-     * to SQL. Array keys are method names to be called in this class
-     *
-     * @return array
-     */
-    protected function _expressionTranslators()
-    {
-        $namespace = 'Cake\Database\Expression';
-
-        return [
-            $namespace . '\FunctionExpression' => '_transformFunctionExpression',
-            $namespace . '\TupleComparison' => '_transformTupleComparison'
-        ];
-    }
-
-    /**
-     * Receives a FunctionExpression and changes it so that it conforms to this
-     * SQL dialect.
-     *
-     * @param \Cake\Database\Expression\FunctionExpression $expression The function expression
-     *   to translate for SQLite.
-     * @return void
-     */
-    protected function _transformFunctionExpression(FunctionExpression $expression)
-    {
-        switch ($expression->getName()) {
-            case 'CONCAT':
-                // CONCAT function is expressed as exp1 || exp2
-                $expression->setName('')->setConjunction(' ||');
-                break;
-            case 'DATEDIFF':
-                $expression
-                    ->setName('ROUND')
-                    ->setConjunction('-')
-                    ->iterateParts(function ($p) {
-                        return new FunctionExpression('JULIANDAY', [$p['value']], [$p['type']]);
-                    });
-                break;
-            case 'NOW':
-                $expression->setName('DATETIME')->add(["'now'" => 'literal']);
-                break;
-            case 'CURRENT_DATE':
-                $expression->setName('DATE')->add(["'now'" => 'literal']);
-                break;
-            case 'CURRENT_TIME':
-                $expression->setName('TIME')->add(["'now'" => 'literal']);
-                break;
-            case 'EXTRACT':
-                $expression
-                    ->setName('STRFTIME')
-                    ->setConjunction(' ,')
-                    ->iterateParts(function ($p, $key) {
-                        if ($key === 0) {
-                            $value = rtrim(strtolower($p), 's');
-                            if (isset($this->_dateParts[$value])) {
-                                $p = ['value' => '%' . $this->_dateParts[$value], 'type' => null];
-                            }
-                        }
-
-                        return $p;
-                    });
-                break;
-            case 'DATE_ADD':
-                $expression
-                    ->setName('DATE')
-                    ->setConjunction(',')
-                    ->iterateParts(function ($p, $key) {
-                        if ($key === 1) {
-                            $p = ['value' => $p, 'type' => null];
-                        }
-
-                        return $p;
-                    });
-                break;
-            case 'DAYOFWEEK':
-                $expression
-                    ->setName('STRFTIME')
-                    ->setConjunction(' ')
-                    ->add(["'%w', " => 'literal'], [], true)
-                    ->add([') + (1' => 'literal']); // Sqlite starts on index 0 but Sunday should be 1
-                break;
-        }
-    }
-
-    /**
-     * Get the schema dialect.
-     *
-     * Used by Cake\Database\Schema package to reflect schema and
-     * generate schema.
-     *
-     * @return \Cake\Database\Schema\SqliteSchema
-     */
-    public function schemaDialect()
-    {
-        if (!$this->_schemaDialect) {
-            $this->_schemaDialect = new SqliteSchema($this);
-        }
-
-        return $this->_schemaDialect;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function disableForeignKeySQL()
-    {
-        return 'PRAGMA foreign_keys = OFF';
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function enableForeignKeySQL()
-    {
-        return 'PRAGMA foreign_keys = ON';
-    }
-
-    /**
-     * {@inheritDoc}
-     *
-     * @return \Cake\Database\SqliteCompiler
-     */
-    public function newCompiler()
-    {
-        return new SqliteCompiler();
-    }
-}
diff --git a/src/Database/Dialect/SqlserverDialectTrait.php b/src/Database/Dialect/SqlserverDialectTrait.php
deleted file mode 100644
index a58161c0861..00000000000
--- a/src/Database/Dialect/SqlserverDialectTrait.php
+++ /dev/null
@@ -1,393 +0,0 @@
-clause('limit');
-        $offset = $query->clause('offset');
-
-        if ($limit && $offset === null) {
-            $query->modifier(['_auto_top_' => sprintf('TOP %d', $limit)]);
-        }
-
-        if ($offset !== null && !$query->clause('order')) {
-            $query->order($query->newExpr()->add('(SELECT NULL)'));
-        }
-
-        if ($this->_version() < 11 && $offset !== null) {
-            return $this->_pagingSubquery($query, $limit, $offset);
-        }
-
-        return $this->_transformDistinct($query);
-    }
-
-    /**
-     * Get the version of SQLserver we are connected to.
-     *
-     * @return int
-     */
-    // @codingStandardsIgnoreLine
-    public function _version()
-    {
-        $this->connect();
-
-        return $this->_connection->getAttribute(PDO::ATTR_SERVER_VERSION);
-    }
-
-    /**
-     * Generate a paging subquery for older versions of SQLserver.
-     *
-     * Prior to SQLServer 2012 there was no equivalent to LIMIT OFFSET, so a subquery must
-     * be used.
-     *
-     * @param \Cake\Database\Query $original The query to wrap in a subquery.
-     * @param int $limit The number of rows to fetch.
-     * @param int $offset The number of rows to offset.
-     * @return \Cake\Database\Query Modified query object.
-     */
-    protected function _pagingSubquery($original, $limit, $offset)
-    {
-        $field = '_cake_paging_._cake_page_rownum_';
-
-        if ($original->clause('order')) {
-            // SQL server does not support column aliases in OVER clauses.  But
-            // the only practical way to specify the use of calculated columns
-            // is with their alias.  So substitute the select SQL in place of
-            // any column aliases for those entries in the order clause.
-            $select = $original->clause('select');
-            $order = new OrderByExpression();
-            $original
-                ->clause('order')
-                ->iterateParts(function ($direction, $orderBy) use ($select, $order) {
-                    $key = $orderBy;
-                    if (isset($select[$orderBy]) &&
-                        $select[$orderBy] instanceof ExpressionInterface
-                    ) {
-                        $key = $select[$orderBy]->sql(new ValueBinder());
-                    }
-                    $order->add([$key => $direction]);
-
-                    // Leave original order clause unchanged.
-                    return $orderBy;
-                });
-        } else {
-            $order = new OrderByExpression('(SELECT NULL)');
-        }
-
-        $query = clone $original;
-        $query->select([
-                '_cake_page_rownum_' => new UnaryExpression('ROW_NUMBER() OVER', $order)
-            ])->limit(null)
-            ->offset(null)
-            ->order([], true);
-
-        $outer = new Query($query->getConnection());
-        $outer->select('*')
-            ->from(['_cake_paging_' => $query]);
-
-        if ($offset) {
-            $outer->where(["$field > " . (int)$offset]);
-        }
-        if ($limit) {
-            $value = (int)$offset + (int)$limit;
-            $outer->where(["$field <= $value"]);
-        }
-
-        // Decorate the original query as that is what the
-        // end developer will be calling execute() on originally.
-        $original->decorateResults(function ($row) {
-            if (isset($row['_cake_page_rownum_'])) {
-                unset($row['_cake_page_rownum_']);
-            }
-
-            return $row;
-        });
-
-        return $outer;
-    }
-
-    /**
-     * Returns the passed query after rewriting the DISTINCT clause, so that drivers
-     * that do not support the "ON" part can provide the actual way it should be done
-     *
-     * @param \Cake\Database\Query $original The query to be transformed
-     * @return \Cake\Database\Query
-     */
-    protected function _transformDistinct($original)
-    {
-        if (!is_array($original->clause('distinct'))) {
-            return $original;
-        }
-
-        $query = clone $original;
-        $distinct = $query->clause('distinct');
-        $query->distinct(false);
-
-        $order = new OrderByExpression($distinct);
-        $query
-            ->select(function ($q) use ($distinct, $order) {
-                $over = $q->newExpr('ROW_NUMBER() OVER')
-                    ->add('(PARTITION BY')
-                    ->add($q->newExpr()->add($distinct)->setConjunction(','))
-                    ->add($order)
-                    ->add(')')
-                    ->setConjunction(' ');
-
-                return [
-                    '_cake_distinct_pivot_' => $over
-                ];
-            })
-            ->limit(null)
-            ->offset(null)
-            ->order([], true);
-
-        $outer = new Query($query->getConnection());
-        $outer->select('*')
-            ->from(['_cake_distinct_' => $query])
-            ->where(['_cake_distinct_pivot_' => 1]);
-
-        // Decorate the original query as that is what the
-        // end developer will be calling execute() on originally.
-        $original->decorateResults(function ($row) {
-            if (isset($row['_cake_distinct_pivot_'])) {
-                unset($row['_cake_distinct_pivot_']);
-            }
-
-            return $row;
-        });
-
-        return $outer;
-    }
-
-    /**
-     * Returns a dictionary of expressions to be transformed when compiling a Query
-     * to SQL. Array keys are method names to be called in this class
-     *
-     * @return array
-     */
-    protected function _expressionTranslators()
-    {
-        $namespace = 'Cake\Database\Expression';
-
-        return [
-            $namespace . '\FunctionExpression' => '_transformFunctionExpression',
-            $namespace . '\TupleComparison' => '_transformTupleComparison'
-        ];
-    }
-
-    /**
-     * Receives a FunctionExpression and changes it so that it conforms to this
-     * SQL dialect.
-     *
-     * @param \Cake\Database\Expression\FunctionExpression $expression The function expression to convert to TSQL.
-     * @return void
-     */
-    protected function _transformFunctionExpression(FunctionExpression $expression)
-    {
-        switch ($expression->getName()) {
-            case 'CONCAT':
-                // CONCAT function is expressed as exp1 + exp2
-                $expression->setName('')->setConjunction(' +');
-                break;
-            case 'DATEDIFF':
-                $hasDay = false;
-                $visitor = function ($value) use (&$hasDay) {
-                    if ($value === 'day') {
-                        $hasDay = true;
-                    }
-
-                    return $value;
-                };
-                $expression->iterateParts($visitor);
-
-                if (!$hasDay) {
-                    $expression->add(['day' => 'literal'], [], true);
-                }
-                break;
-            case 'CURRENT_DATE':
-                $time = new FunctionExpression('GETUTCDATE');
-                $expression->setName('CONVERT')->add(['date' => 'literal', $time]);
-                break;
-            case 'CURRENT_TIME':
-                $time = new FunctionExpression('GETUTCDATE');
-                $expression->setName('CONVERT')->add(['time' => 'literal', $time]);
-                break;
-            case 'NOW':
-                $expression->setName('GETUTCDATE');
-                break;
-            case 'EXTRACT':
-                $expression->setName('DATEPART')->setConjunction(' ,');
-                break;
-            case 'DATE_ADD':
-                $params = [];
-                $visitor = function ($p, $key) use (&$params) {
-                    if ($key === 0) {
-                        $params[2] = $p;
-                    } else {
-                        $valueUnit = explode(' ', $p);
-                        $params[0] = rtrim($valueUnit[1], 's');
-                        $params[1] = $valueUnit[0];
-                    }
-
-                    return $p;
-                };
-                $manipulator = function ($p, $key) use (&$params) {
-                    return $params[$key];
-                };
-
-                $expression
-                    ->setName('DATEADD')
-                    ->setConjunction(',')
-                    ->iterateParts($visitor)
-                    ->iterateParts($manipulator)
-                    ->add([$params[2] => 'literal']);
-                break;
-            case 'DAYOFWEEK':
-                $expression
-                    ->setName('DATEPART')
-                    ->setConjunction(' ')
-                    ->add(['weekday, ' => 'literal'], [], true);
-                break;
-            case 'SUBSTR':
-                $expression->setName('SUBSTRING');
-                if (count($expression) < 4) {
-                    $params = [];
-                    $expression
-                        ->iterateParts(function ($p) use (&$params) {
-                            return $params[] = $p;
-                        })
-                        ->add([new FunctionExpression('LEN', [$params[0]]), ['string']]);
-                }
-
-                break;
-        }
-    }
-
-    /**
-     * Get the schema dialect.
-     *
-     * Used by Cake\Schema package to reflect schema and
-     * generate schema.
-     *
-     * @return \Cake\Database\Schema\SqlserverSchema
-     */
-    public function schemaDialect()
-    {
-        return new SqlserverSchema($this);
-    }
-
-    /**
-     * Returns a SQL snippet for creating a new transaction savepoint
-     *
-     * @param string $name save point name
-     * @return string
-     */
-    public function savePointSQL($name)
-    {
-        return 'SAVE TRANSACTION t' . $name;
-    }
-
-    /**
-     * Returns a SQL snippet for releasing a previously created save point
-     *
-     * @param string $name save point name
-     * @return string
-     */
-    public function releaseSavePointSQL($name)
-    {
-        return 'COMMIT TRANSACTION t' . $name;
-    }
-
-    /**
-     * Returns a SQL snippet for rollbacking a previously created save point
-     *
-     * @param string $name save point name
-     * @return string
-     */
-    public function rollbackSavePointSQL($name)
-    {
-        return 'ROLLBACK TRANSACTION t' . $name;
-    }
-
-    /**
-     * {@inheritDoc}
-     *
-     * @return \Cake\Database\SqlserverCompiler
-     */
-    public function newCompiler()
-    {
-        return new SqlserverCompiler();
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function disableForeignKeySQL()
-    {
-        return 'EXEC sp_msforeachtable "ALTER TABLE ? NOCHECK CONSTRAINT all"';
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function enableForeignKeySQL()
-    {
-        return 'EXEC sp_msforeachtable "ALTER TABLE ? WITH CHECK CHECK CONSTRAINT all"';
-    }
-}
diff --git a/src/Database/Dialect/TupleComparisonTranslatorTrait.php b/src/Database/Dialect/TupleComparisonTranslatorTrait.php
deleted file mode 100644
index 9f825ba9877..00000000000
--- a/src/Database/Dialect/TupleComparisonTranslatorTrait.php
+++ /dev/null
@@ -1,95 +0,0 @@
-getField();
-
-        if (!is_array($fields)) {
-            return;
-        }
-
-        $value = $expression->getValue();
-        $op = $expression->getOperator();
-        $true = new QueryExpression('1');
-
-        if ($value instanceof Query) {
-            $selected = array_values($value->clause('select'));
-            foreach ($fields as $i => $field) {
-                $value->andWhere([$field . " $op" => new IdentifierExpression($selected[$i])]);
-            }
-            $value->select($true, true);
-            $expression->setField($true);
-            $expression->setOperator('=');
-
-            return;
-        }
-
-        $surrogate = $query->getConnection()
-            ->newQuery()
-            ->select($true);
-
-        if (!is_array(current($value))) {
-            $value = [$value];
-        }
-
-        foreach ($value as $tuple) {
-            $surrogate->orWhere(function ($exp) use ($fields, $tuple) {
-                foreach (array_values($tuple) as $i => $value) {
-                    $exp->add([$fields[$i] => $value]);
-                }
-
-                return $exp;
-            });
-        }
-
-        $expression->setField($true);
-        $expression->setValue($surrogate);
-        $expression->setOperator('=');
-    }
-}
diff --git a/src/Database/Driver.php b/src/Database/Driver.php
index a40f6c591dd..ccbb66800fd 100644
--- a/src/Database/Driver.php
+++ b/src/Database/Driver.php
@@ -1,4 +1,6 @@
   DB-specific error codes that allow connect retry
+     */
+    protected const RETRY_ERROR_CODES = [];
+
+    /**
+     * @var class-string<\Cake\Database\Statement\Statement>
+     */
+    protected const STATEMENT_CLASS = Statement::class;
+
+    /**
+     * Instance of PDO.
+     *
+     * @var \PDO|null
+     */
+    protected ?PDO $pdo = null;
 
     /**
      * Configuration data.
      *
-     * @var array
+     * @var array
      */
-    protected $_config;
+    protected array $_config = [];
 
     /**
      * Base configuration that is merged into the user
      * supplied configuration data.
      *
-     * @var array
+     * @var array
      */
-    protected $_baseConfig = [];
+    protected array $_baseConfig = [];
 
     /**
-     * Indicates whether or not the driver is doing automatic identifier quoting
+     * Indicates whether the driver is doing automatic identifier quoting
      * for all queries
      *
      * @var bool
      */
-    protected $_autoQuoting = false;
+    protected bool $_autoQuoting = false;
+
+    /**
+     * String used to start a database identifier quoting to make it safe
+     *
+     * @var string
+     */
+    protected string $_startQuote = '';
+
+    /**
+     * String used to end a database identifier quoting to make it safe
+     *
+     * @var string
+     */
+    protected string $_endQuote = '';
+
+    /**
+     * Identifier quoter
+     *
+     * @var \Cake\Database\IdentifierQuoter|null
+     */
+    protected ?IdentifierQuoter $quoter = null;
+
+    /**
+     * The server version
+     *
+     * @var string|null
+     */
+    protected ?string $_version = null;
+
+    /**
+     * Whether to log queries generated during this connection.
+     *
+     * @var bool
+     */
+    protected bool $logQueries = false;
+
+    /**
+     * The last number of connection retry attempts.
+     *
+     * @var int
+     */
+    protected int $connectRetries = 0;
+
+    /**
+     * The schema dialect for this driver
+     *
+     * @var \Cake\Database\Schema\SchemaDialect
+     */
+    protected SchemaDialect $_schemaDialect;
 
     /**
      * Constructor
      *
-     * @param array $config The configuration for the driver.
+     * @param array $config The configuration for the driver.
      * @throws \InvalidArgumentException
      */
-    public function __construct($config = [])
+    public function __construct(#[SensitiveParameter] array $config = [])
     {
         if (empty($config['username']) && !empty($config['login'])) {
             throw new InvalidArgumentException(
-                'Please pass "username" instead of "login" for connecting to the database'
+                'Please pass "username" instead of "login" for connecting to the database',
             );
         }
-        $config += $this->_baseConfig;
+        $config += $this->_baseConfig + ['log' => false];
         $this->_config = $config;
         if (!empty($config['quoteIdentifiers'])) {
             $this->enableAutoQuoting();
         }
+        if ($config['log'] !== false) {
+            $this->logQueries = true;
+            $this->logger = $this->createLogger($config['log'] === true ? null : $config['log']);
+        }
+    }
+
+    /**
+     * Get the configuration data used to create the driver.
+     *
+     * @return array
+     */
+    public function config(): array
+    {
+        return $this->_config;
     }
 
     /**
      * Establishes a connection to the database server
      *
-     * @return bool true on success
+     * @param string $dsn A Driver-specific PDO-DSN
+     * @param array $config configuration to be used for creating connection
+     * @return \PDO
      */
-    abstract public function connect();
+    protected function createPdo(string $dsn, #[SensitiveParameter] array $config): PDO
+    {
+        $action = fn(): PDO => new PDO(
+            $dsn,
+            $config['username'] ?: null,
+            $config['password'] ?: null,
+            $config['flags'],
+        );
+
+        $retry = new CommandRetry(new ErrorCodeWaitStrategy(static::RETRY_ERROR_CODES, 5), 4);
+        try {
+            return $retry->run($action);
+        } catch (PDOException $e) {
+            throw new MissingConnectionException(
+                [
+                    'driver' => App::shortName(static::class, 'Database/Driver'),
+                    'reason' => $e->getMessage(),
+                ],
+                null,
+                $e,
+            );
+        } finally {
+            $this->connectRetries = $retry->getRetries();
+        }
+    }
 
     /**
-     * Disconnects from database server
+     * Establishes a connection to the database server.
      *
+     * @throws \Cake\Database\Exception\MissingConnectionException If database connection could not be established.
      * @return void
      */
-    abstract public function disconnect();
+    abstract public function connect(): void;
 
     /**
-     * Returns correct connection resource or object that is internally used
-     * If first argument is passed,
+     * Disconnects from database server.
+     *
+     * @return void
+     */
+    public function disconnect(): void
+    {
+        $this->pdo = null;
+        $this->_version = null;
+    }
+
+    /**
+     * Returns connected server version.
+     *
+     * @return string
+     */
+    public function version(): string
+    {
+        return $this->_version ??= (string)$this->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION);
+    }
+
+    /**
+     * Get the PDO connection instance.
      *
-     * @param null|\PDO $connection The connection object
      * @return \PDO
      */
-    abstract public function connection($connection = null);
+    protected function getPdo(): PDO
+    {
+        if ($this->pdo === null) {
+            $this->connect();
+        }
+        assert($this->pdo !== null);
+
+        return $this->pdo;
+    }
+
+    /**
+     * Execute the SQL query using the internal PDO instance.
+     *
+     * @param string $sql SQL query.
+     * @return int|false Number of affected rows or false on failure
+     * @throws \Cake\Database\Exception\QueryException On database error
+     */
+    public function exec(string $sql): int|false
+    {
+        try {
+            return $this->getPdo()->exec($sql);
+        } catch (PDOException $e) {
+            $loggedQuery = new LoggedQuery();
+            $loggedQuery->setContext([
+                'query' => $sql,
+                'driver' => $this,
+            ]);
+            throw new QueryException($loggedQuery, $e);
+        }
+    }
 
     /**
-     * Returns whether php is able to use this driver for connecting to database
+     * Returns whether php is able to use this driver for connecting to database.
      *
-     * @return bool true if it is valid to use this driver
+     * @return bool True if it is valid to use this driver.
      */
-    abstract public function enabled();
+    abstract public function enabled(): bool;
 
     /**
-     * Prepares a sql statement to be executed
+     * Executes a query using $params for interpolating values and $types as a hint for each
+     * those params.
      *
-     * @param string|\Cake\Database\Query $query The query to convert into a statement.
+     * @param string $sql SQL to be executed and interpolated with $params
+     * @param array $params List or associative array of params to be interpolated in $sql as values.
+     * @param array $types List or associative array of types to be used for casting values in query.
+     * @return \Cake\Database\StatementInterface Executed statement
+     */
+    public function execute(string $sql, array $params = [], array $types = []): StatementInterface
+    {
+        $statement = $this->prepare($sql);
+        if ($params) {
+            $statement->bind($params, $types);
+        }
+        $this->executeStatement($statement);
+
+        return $statement;
+    }
+
+    /**
+     * Executes the provided query after compiling it for the specific driver
+     * dialect and returns the executed Statement object.
+     *
+     * @param \Cake\Database\Query $query The query to be executed.
+     * @return \Cake\Database\StatementInterface Executed statement
+     */
+    public function run(Query $query): StatementInterface
+    {
+        $statement = $this->prepare($query);
+        $query->getValueBinder()->attachTo($statement);
+        $this->executeStatement($statement);
+
+        return $statement;
+    }
+
+    /**
+     * Execute the statement and log the query string.
+     *
+     * @param \Cake\Database\StatementInterface $statement Statement to execute.
+     * @param array|null $params List of values to be bound to query.
+     * @return void
+     */
+    protected function executeStatement(StatementInterface $statement, ?array $params = null): void
+    {
+        if ($this->logger === null) {
+            try {
+                $statement->execute($params);
+            } catch (PDOException $e) {
+                throw $this->createQueryException($e, $statement, $params);
+            }
+
+            return;
+        }
+
+        $exception = null;
+        $took = 0.0;
+
+        try {
+            $start = microtime(true);
+            $statement->execute($params);
+            $took = (float)number_format((microtime(true) - $start) * 1000, 1);
+        } catch (PDOException $e) {
+            $exception = $e;
+        }
+
+        $logContext = [
+            'driver' => $this,
+            'error' => $exception,
+            'params' => $params ?? $statement->getBoundParams(),
+        ];
+        if (!$exception) {
+            $logContext['numRows'] = $statement->rowCount();
+            $logContext['took'] = $took;
+        }
+        $this->log($statement->queryString(), $logContext);
+
+        if ($exception) {
+            throw $this->createQueryException($exception, $statement, $params);
+        }
+    }
+
+    /**
+     * Create a QueryException from a PDOException
+     *
+     * @param \PDOException $exception
+     * @param \Cake\Database\StatementInterface $statement
+     * @param array|null $params
+     * @return \Cake\Database\Exception\QueryException
+     */
+    protected function createQueryException(
+        PDOException $exception,
+        StatementInterface $statement,
+        ?array $params = null,
+    ): QueryException {
+        $loggedQuery = new LoggedQuery();
+        $loggedQuery->setContext([
+            'query' => $statement->queryString(),
+            'driver' => $this,
+            'params' => $params ?? $statement->getBoundParams(),
+        ]);
+
+        return new QueryException($loggedQuery, $exception);
+    }
+
+    /**
+     * Prepares a sql statement to be executed.
+     *
+     * @param \Cake\Database\Query|string $query The query to turn into a prepared statement.
      * @return \Cake\Database\StatementInterface
      */
-    abstract public function prepare($query);
+    public function prepare(Query|string $query): StatementInterface
+    {
+        try {
+            $statement = $this->getPdo()->prepare($query instanceof Query ? $query->sql() : $query);
+        } catch (PDOException $e) {
+            throw new QueryException(
+                $query instanceof Query ? $query->sql() : $query,
+                $e,
+            );
+        }
+
+        /** @var \Cake\Database\StatementInterface */
+        return new (static::STATEMENT_CLASS)($statement, $this, $this->getResultSetDecorators($query));
+    }
+
+    /**
+     * Returns the decorators to be applied to the result set incase of a SelectQuery.
+     *
+     * @param \Cake\Database\Query|string $query The query to be decorated.
+     * @return array<\Closure>
+     */
+    protected function getResultSetDecorators(Query|string $query): array
+    {
+        if ($query instanceof SelectQuery) {
+            $decorators = $query->getResultDecorators();
+            if ($query->isResultsCastingEnabled()) {
+                $typeConverter = new FieldTypeConverter($query->getSelectTypeMap(), $this);
+                array_unshift($decorators, $typeConverter(...));
+            }
+
+            return $decorators;
+        }
+
+        return [];
+    }
+
+    /**
+     * Starts a transaction.
+     *
+     * @return bool True on success, false otherwise.
+     */
+    public function beginTransaction(): bool
+    {
+        if ($this->getPdo()->inTransaction()) {
+            return true;
+        }
+
+        $this->log('BEGIN');
+
+        return $this->getPdo()->beginTransaction();
+    }
 
     /**
-     * Starts a transaction
+     * Commits a transaction.
      *
-     * @return bool true on success, false otherwise
+     * @return bool True on success, false otherwise.
      */
-    abstract public function beginTransaction();
+    public function commitTransaction(): bool
+    {
+        if (!$this->getPdo()->inTransaction()) {
+            return false;
+        }
+
+        $this->log('COMMIT');
+
+        return $this->getPdo()->commit();
+    }
 
     /**
-     * Commits a transaction
+     * Rollbacks a transaction.
      *
-     * @return bool true on success, false otherwise
+     * @return bool True on success, false otherwise.
      */
-    abstract public function commitTransaction();
+    public function rollbackTransaction(): bool
+    {
+        if (!$this->getPdo()->inTransaction()) {
+            return false;
+        }
+
+        $this->log('ROLLBACK');
+
+        return $this->getPdo()->rollBack();
+    }
 
     /**
-     * Rollsback a transaction
+     * Returns whether a transaction is active for connection.
      *
-     * @return bool true on success, false otherwise
+     * @return bool
      */
-    abstract public function rollbackTransaction();
+    public function inTransaction(): bool
+    {
+        return $this->getPdo()->inTransaction();
+    }
 
     /**
-     * Get the SQL for releasing a save point.
+     * Returns a SQL snippet for creating a new transaction savepoint
      *
-     * @param string $name The table name
+     * @param string|int $name save point name
      * @return string
      */
-    abstract public function releaseSavePointSQL($name);
+    public function savePointSQL(string|int $name): string
+    {
+        return 'SAVEPOINT LEVEL' . $name;
+    }
 
     /**
-     * Get the SQL for creating a save point.
+     * Returns a SQL snippet for releasing a previously created save point
      *
-     * @param string $name The table name
+     * @param string|int $name save point name
      * @return string
      */
-    abstract public function savePointSQL($name);
+    public function releaseSavePointSQL(string|int $name): string
+    {
+        return 'RELEASE SAVEPOINT LEVEL' . $name;
+    }
 
     /**
-     * Get the SQL for rollingback a save point.
+     * Returns a SQL snippet for rollbacking a previously created save point
      *
-     * @param string $name The table name
+     * @param string|int $name save point name
      * @return string
      */
-    abstract public function rollbackSavePointSQL($name);
+    public function rollbackSavePointSQL(string|int $name): string
+    {
+        return 'ROLLBACK TO SAVEPOINT LEVEL' . $name;
+    }
 
     /**
-     * Get the SQL for disabling foreign keys
+     * Get the SQL for disabling foreign keys.
      *
      * @return string
      */
-    abstract public function disableForeignKeySQL();
+    abstract public function disableForeignKeySQL(): string;
 
     /**
-     * Get the SQL for enabling foreign keys
+     * Get the SQL for enabling foreign keys.
      *
      * @return string
      */
-    abstract public function enableForeignKeySQL();
+    abstract public function enableForeignKeySQL(): string;
+
+    /**
+     * Transform the query to accommodate any specificities of the SQL dialect in use.
+     *
+     * It will also quote the identifiers if auto quoting is enabled.
+     *
+     * @param \Cake\Database\Query $query Query to transform.
+     * @return \Cake\Database\Query
+     */
+    protected function transformQuery(Query $query): Query
+    {
+        if ($this->isAutoQuotingEnabled()) {
+            $query = $this->quoter()->quote($query);
+        }
+
+        $query = match (true) {
+            $query instanceof SelectQuery => $this->_selectQueryTranslator($query),
+            $query instanceof InsertQuery => $this->_insertQueryTranslator($query),
+            $query instanceof UpdateQuery => $this->_updateQueryTranslator($query),
+            $query instanceof DeleteQuery => $this->_deleteQueryTranslator($query),
+            default => throw new InvalidArgumentException(sprintf(
+                'Instance of SelectQuery, UpdateQuery, InsertQuery, DeleteQuery expected. Found `%s` instead.',
+                get_debug_type($query),
+            )),
+        };
+
+        $translators = $this->_expressionTranslators();
+        if (!$translators) {
+            return $query;
+        }
+
+        $query->traverseExpressions(function ($expression) use ($translators, $query): void {
+            foreach ($translators as $class => $method) {
+                if ($expression instanceof $class) {
+                    $this->{$method}($expression, $query);
+                }
+            }
+        });
+
+        return $query;
+    }
 
     /**
-     * Returns whether the driver supports adding or dropping constraints
-     * to already created tables.
+     * Returns an associative array of methods that will transform Expression
+     * objects to conform with the specific SQL dialect. Keys are class names
+     * and values a method in this class.
      *
-     * @return bool true if driver supports dynamic constraints
+     * @return array
      */
-    abstract public function supportsDynamicConstraints();
+    protected function _expressionTranslators(): array
+    {
+        return [];
+    }
 
     /**
-     * Returns whether this driver supports save points for nested transactions
+     * Apply translation steps to select queries.
      *
-     * @return bool true if save points are supported, false otherwise
+     * @param \Cake\Database\Query\SelectQuery $query The query to translate
+     * @return \Cake\Database\Query\SelectQuery The modified query
      */
-    public function supportsSavePoints()
+    protected function _selectQueryTranslator(SelectQuery $query): SelectQuery
     {
-        return true;
+        return $this->_transformDistinct($query);
     }
 
     /**
-     * Returns a value in a safe representation to be used in a query string
+     * Returns the passed query after rewriting the DISTINCT clause, so that drivers
+     * that do not support the "ON" part can provide the actual way it should be done
      *
-     * @param mixed $value The value to quote.
-     * @param string $type Type to be used for determining kind of quoting to perform
-     * @return string
+     * @param \Cake\Database\Query\SelectQuery $query The query to be transformed
+     * @return \Cake\Database\Query\SelectQuery
      */
-    abstract public function quote($value, $type);
+    protected function _transformDistinct(SelectQuery $query): SelectQuery
+    {
+        if (is_array($query->clause('distinct'))) {
+            $query->groupBy($query->clause('distinct'), true);
+            $query->distinct(false);
+        }
+
+        return $query;
+    }
 
     /**
-     * Checks if the driver supports quoting
+     * Apply translation steps to delete queries.
      *
-     * @return bool
+     * Chops out aliases on delete query conditions as most database dialects do not
+     * support aliases in delete queries. This also removes aliases
+     * in table names as they frequently don't work either.
+     *
+     * We are intentionally not supporting deletes with joins as they have even poorer support.
+     *
+     * @param \Cake\Database\Query\DeleteQuery $query The query to translate
+     * @return \Cake\Database\Query\DeleteQuery The modified query
      */
-    public function supportsQuoting()
+    protected function _deleteQueryTranslator(DeleteQuery $query): DeleteQuery
     {
-        return true;
+        $hadAlias = false;
+        $tables = [];
+        foreach ($query->clause('from') as $alias => $table) {
+            if (is_string($alias)) {
+                $hadAlias = true;
+            }
+            $tables[] = $table;
+        }
+        if ($hadAlias) {
+            $query->from($tables, true);
+        }
+
+        if (!$hadAlias) {
+            return $query;
+        }
+
+        return $this->_removeAliasesFromConditions($query);
+    }
+
+    /**
+     * Apply translation steps to update queries.
+     *
+     * Chops out aliases on update query conditions as not all database dialects do support
+     * aliases in update queries.
+     *
+     * Just like for delete queries, joins are currently not supported for update queries.
+     *
+     * @param \Cake\Database\Query\UpdateQuery $query The query to translate
+     * @return \Cake\Database\Query\UpdateQuery The modified query
+     */
+    protected function _updateQueryTranslator(UpdateQuery $query): UpdateQuery
+    {
+        return $this->_removeAliasesFromConditions($query);
+    }
+
+    /**
+     * Removes aliases from the `WHERE` clause of a query.
+     *
+     * @template T of \Cake\Database\Query\UpdateQuery|\Cake\Database\Query\DeleteQuery
+     * @param T $query The query to process.
+     * @return T The modified query.
+     * @throws \Cake\Database\Exception\DatabaseException In case the processed query contains any joins, as removing
+     *  aliases from the conditions can break references to the joined tables.
+     */
+    protected function _removeAliasesFromConditions(UpdateQuery|DeleteQuery $query): UpdateQuery|DeleteQuery
+    {
+        if ($query->clause('join')) {
+            throw new DatabaseException(
+                'Aliases are being removed from conditions for UPDATE/DELETE queries, ' .
+                'this can break references to joined tables.',
+            );
+        }
+
+        $conditions = $query->clause('where');
+        assert($conditions === null || $conditions instanceof ExpressionInterface);
+        if ($conditions) {
+            $conditions->traverse(function ($expression) {
+                if ($expression instanceof ComparisonExpression) {
+                    $field = $expression->getField();
+                    if (
+                        is_string($field) &&
+                        str_contains($field, '.')
+                    ) {
+                        [, $unaliasedField] = explode('.', $field, 2);
+                        $expression->setField($unaliasedField);
+                    }
+
+                    return $expression;
+                }
+
+                if ($expression instanceof IdentifierExpression) {
+                    $identifier = $expression->getIdentifier();
+                    if (str_contains($identifier, '.')) {
+                        [, $unaliasedIdentifier] = explode('.', $identifier, 2);
+                        $expression->setIdentifier($unaliasedIdentifier);
+                    }
+
+                    return $expression;
+                }
+
+                return $expression;
+            });
+        }
+
+        return $query;
     }
 
     /**
-     * Returns a callable function that will be used to transform a passed Query object.
-     * This function, in turn, will return an instance of a Query object that has been
-     * transformed to accommodate any specificities of the SQL dialect in use.
+     * Apply translation steps to insert queries.
      *
-     * @param string $type the type of query to be transformed
-     * (select, insert, update, delete)
-     * @return callable
+     * @param \Cake\Database\Query\InsertQuery $query The query to translate
+     * @return \Cake\Database\Query\InsertQuery The modified query
      */
-    abstract public function queryTranslator($type);
+    protected function _insertQueryTranslator(InsertQuery $query): InsertQuery
+    {
+        return $query;
+    }
 
     /**
      * Get the schema dialect.
      *
-     * Used by Cake\Database\Schema package to reflect schema and
+     * Used by {@link \Cake\Database\Schema} package to reflect schema and
      * generate schema.
      *
      * If all the tables that use this Driver specify their
      * own schemas, then this may return null.
      *
-     * @return \Cake\Database\Schema\BaseSchema
+     * @return \Cake\Database\Schema\SchemaDialect
      */
-    abstract public function schemaDialect();
+    abstract public function schemaDialect(): SchemaDialect;
 
     /**
      * Quotes a database identifier (a column name, table name, etc..) to
      * be used safely in queries without the risk of using reserved words
      *
-     * @param string $identifier The identifier expression to quote.
+     * @param string $identifier The identifier to quote.
      * @return string
      */
-    abstract public function quoteIdentifier($identifier);
+    public function quoteIdentifier(string $identifier): string
+    {
+        return $this->quoter()->quoteIdentifier($identifier);
+    }
+
+    /**
+     * Quotes a database value.
+     *
+     * This makes values safe for concatenation in SQL queries.
+     *
+     * Using this method **is not** recommended. You should use `execute()`
+     * instead, as it uses prepared statements which are safer than
+     * string concatenation.
+     *
+     * This method should only be used for queries that do not support placeholders.
+     *
+     * @param string $value The value to quote.
+     * @return string
+     */
+    public function quote(string $value): string
+    {
+        return $this->getPdo()->quote($value);
+    }
+
+    /**
+     * Get identifier quoter instance.
+     *
+     * @return \Cake\Database\IdentifierQuoter
+     */
+    public function quoter(): IdentifierQuoter
+    {
+        return $this->quoter ??= new IdentifierQuoter($this->_startQuote, $this->_endQuote);
+    }
 
     /**
      * Escapes values for use in schema definitions.
@@ -242,7 +791,7 @@ abstract public function quoteIdentifier($identifier);
      * @param mixed $value The value to escape.
      * @return string String for use in schema definitions.
      */
-    public function schemaValue($value)
+    public function schemaValue(mixed $value): string
     {
         if ($value === null) {
             return 'NULL';
@@ -256,139 +805,280 @@ public function schemaValue($value)
         if (is_float($value)) {
             return str_replace(',', '.', (string)$value);
         }
-        if ((is_int($value) || $value === '0') || (
-            is_numeric($value) && strpos($value, ',') === false &&
-            $value[0] !== '0' && strpos($value, 'e') === false)
+        if (
+            (
+                is_int($value) ||
+                $value === '0'
+            ) ||
+            (
+                is_numeric($value) &&
+                !str_contains($value, ',') &&
+                !str_starts_with($value, '0') &&
+                !str_contains($value, 'e')
+            )
         ) {
             return (string)$value;
         }
+        if ($value instanceof QueryExpression) {
+            return $value->sql(new ValueBinder());
+        }
 
-        return $this->_connection->quote($value, PDO::PARAM_STR);
+        return $this->getPdo()->quote((string)$value, PDO::PARAM_STR);
     }
 
     /**
-     * Returns the schema name that's being used
+     * Returns the schema name that's being used.
      *
      * @return string
      */
-    public function schema()
+    public function schema(): string
     {
         return $this->_config['schema'];
     }
 
     /**
-     * Returns last id generated for a table or sequence in database
+     * Returns last id generated for a table or sequence in database.
      *
-     * @param string|null $table table name or sequence to get last insert value from
-     * @param string|null $column the name of the column representing the primary key
-     * @return string|int
+     * @param string|null $table table name or sequence to get last insert value from.
+     * @return string
      */
-    public function lastInsertId($table = null, $column = null)
+    public function lastInsertId(?string $table = null): string
     {
-        return $this->_connection->lastInsertId($table, $column);
+        return (string)$this->getPdo()->lastInsertId($table);
     }
 
     /**
-     * Check whether or not the driver is connected.
+     * Checks whether the driver is connected.
      *
      * @return bool
      */
-    public function isConnected()
+    public function isConnected(): bool
     {
-        return $this->_connection !== null;
+        if ($this->pdo === null) {
+            return false;
+        }
+
+        try {
+            return (bool)$this->pdo->query('SELECT 1');
+        } catch (PDOException) {
+            return false;
+        }
     }
 
     /**
-     * Sets whether or not this driver should automatically quote identifiers
+     * Sets whether this driver should automatically quote identifiers
      * in queries.
      *
      * @param bool $enable Whether to enable auto quoting
      * @return $this
      */
-    public function enableAutoQuoting($enable = true)
+    public function enableAutoQuoting(bool $enable = true)
     {
-        $this->_autoQuoting = (bool)$enable;
+        $this->_autoQuoting = $enable;
 
         return $this;
     }
 
     /**
-     * Returns whether or not this driver should automatically quote identifiers
-     * in queries
+     * Disable auto quoting of identifiers in queries.
+     *
+     * @return $this
+     */
+    public function disableAutoQuoting()
+    {
+        $this->_autoQuoting = false;
+
+        return $this;
+    }
+
+    /**
+     * Returns whether this driver should automatically quote identifiers
+     * in queries.
      *
      * @return bool
      */
-    public function isAutoQuotingEnabled()
+    public function isAutoQuotingEnabled(): bool
     {
         return $this->_autoQuoting;
     }
 
     /**
-     * Returns whether or not this driver should automatically quote identifiers
-     * in queries
+     * Returns whether the driver supports the feature.
      *
-     * If called with a boolean argument, it will toggle the auto quoting setting
-     * to the passed value
+     * Should return false for unknown features.
      *
-     * @deprecated 3.4.0 use enableAutoQuoting()/isAutoQuotingEnabled() instead.
-     * @param bool|null $enable Whether to enable auto quoting
+     * @param \Cake\Database\DriverFeatureEnum $feature Driver feature
      * @return bool
      */
-    public function autoQuoting($enable = null)
-    {
-        if ($enable !== null) {
-            $this->enableAutoQuoting($enable);
-        }
-
-        return $this->isAutoQuotingEnabled();
-    }
+    abstract public function supports(DriverFeatureEnum $feature): bool;
 
     /**
      * Transforms the passed query to this Driver's dialect and returns an instance
-     * of the transformed query and the full compiled SQL string
+     * of the transformed query and the full compiled SQL string.
      *
      * @param \Cake\Database\Query $query The query to compile.
-     * @param \Cake\Database\ValueBinder $generator The value binder to use.
-     * @return array containing 2 entries. The first entity is the transformed query
-     * and the second one the compiled SQL
+     * @param \Cake\Database\ValueBinder $binder The value binder to use.
+     * @return string The compiled SQL.
      */
-    public function compileQuery(Query $query, ValueBinder $generator)
+    public function compileQuery(Query $query, ValueBinder $binder): string
     {
         $processor = $this->newCompiler();
-        $translator = $this->queryTranslator($query->type());
-        $query = $translator($query);
+        $query = $this->transformQuery($query);
 
-        return [$query, $processor->compile($query, $generator)];
+        return $processor->compile($query, $binder);
     }
 
     /**
-     * Returns an instance of a QueryCompiler
-     *
      * @return \Cake\Database\QueryCompiler
      */
-    public function newCompiler()
+    public function newCompiler(): QueryCompiler
     {
         return new QueryCompiler();
     }
 
+    /**
+     * Constructs new TableSchema.
+     *
+     * @param string $table The table name.
+     * @param array $columns The list of columns for the schema.
+     * @return \Cake\Database\Schema\TableSchemaInterface
+     */
+    public function newTableSchema(string $table, array $columns = []): TableSchemaInterface
+    {
+        /** @var class-string<\Cake\Database\Schema\TableSchemaInterface> $className */
+        $className = $this->_config['tableSchema'] ?? TableSchema::class;
+
+        return new $className($table, $columns);
+    }
+
+    /**
+     * Returns the maximum alias length allowed.
+     *
+     * This can be different from the maximum identifier length for columns.
+     *
+     * @return int|null Maximum alias length or null if no limit
+     */
+    public function getMaxAliasLength(): ?int
+    {
+        return static::MAX_ALIAS_LENGTH;
+    }
+
+    /**
+     * Get the logger instance.
+     *
+     * @return \Psr\Log\LoggerInterface|null
+     */
+    public function getLogger(): ?LoggerInterface
+    {
+        return $this->logger;
+    }
+
+    /**
+     * Create logger instance.
+     *
+     * @param string|null $className Logger's class name
+     * @return \Psr\Log\LoggerInterface
+     */
+    protected function createLogger(?string $className): LoggerInterface
+    {
+        $className ??= QueryLogger::class;
+
+        /** @var class-string<\Psr\Log\LoggerInterface>|null $className */
+        $className = App::className($className, 'Cake/Log', 'Log');
+        if ($className === null) {
+            throw new CakeException(
+                'For logging you must either set the `log` config to a FQCN which implements Psr\Log\LoggerInterface' .
+                ' or require the cakephp/log package in your composer config.',
+            );
+        }
+
+        return new $className();
+    }
+
+    /**
+     * Logs a message or query using the configured logger object.
+     *
+     * @param \Stringable|string $message Message string or query.
+     * @param array $context Logging context.
+     * @return bool True if message was logged.
+     */
+    public function log(Stringable|string $message, array $context = []): bool
+    {
+        if ($this->logger === null || !$this->logQueries) {
+            return false;
+        }
+
+        $context['query'] = $message;
+        $loggedQuery = new LoggedQuery();
+        $loggedQuery->setContext($context);
+
+        $this->logger->debug((string)$loggedQuery, ['query' => $loggedQuery]);
+
+        return true;
+    }
+
+    /**
+     * Returns the connection role this driver performs.
+     *
+     * @return string
+     */
+    public function getRole(): string
+    {
+        return $this->_config['_role'] ?? Connection::ROLE_WRITE;
+    }
+
+    /**
+     * Enable query logging.
+     *
+     * @return $this
+     */
+    public function enableQueryLogging()
+    {
+        $this->logQueries = true;
+
+        return $this;
+    }
+
+    /**
+     * Disable query logging.
+     *
+     * @return $this
+     */
+    public function disableQueryLogging()
+    {
+        $this->logQueries = false;
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function setLogger(LoggerInterface $logger): void
+    {
+        $this->logger = $logger;
+        $this->enableQueryLogging();
+    }
+
     /**
      * Destructor
      */
     public function __destruct()
     {
-        $this->_connection = null;
+        $this->pdo = null;
     }
 
     /**
      * Returns an array that can be used to describe the internal state of this
      * object.
      *
-     * @return array
+     * @return array
      */
-    public function __debugInfo()
+    public function __debugInfo(): array
     {
         return [
-            'connected' => $this->_connection !== null
+            'connected' => $this->pdo !== null,
+            'role' => $this->getRole(),
         ];
     }
 }
diff --git a/src/Database/Driver/Mysql.php b/src/Database/Driver/Mysql.php
index d1f7bba1cef..6c3af56cbff 100644
--- a/src/Database/Driver/Mysql.php
+++ b/src/Database/Driver/Mysql.php
@@ -1,4 +1,6 @@
  'transformStringAggExpression',
+            DistinctComparisonExpression::class => 'transformDistinctComparisonExpression',
+        ];
+    }
+
+    /**
+     * Translates IS [NOT] DISTINCT FROM into MySQL-specific syntax.
+     *
+     * @param \Cake\Database\Expression\DistinctComparisonExpression $expression The expression to translate.
+     * @return void
+     */
+    protected function transformDistinctComparisonExpression(DistinctComparisonExpression $expression): void
+    {
+        $operator = strtoupper($expression->getOperator());
+        if ($operator === 'IS NOT DISTINCT FROM') {
+            $expression->setOperator('<=>');
+        } elseif ($operator === 'IS DISTINCT FROM') {
+            $expression->setOperator('<=>');
+            $expression->setNot(true);
+        }
+    }
+
+    /**
+     * Translates portable string aggregation to MySQL/MariaDB specific syntax.
+     *
+     * @param \Cake\Database\Expression\StringAggExpression $expression The expression to translate.
+     * @return void
+     */
+    protected function transformStringAggExpression(StringAggExpression $expression): void
+    {
+        if ($this->supports(DriverFeatureEnum::STRING_AGG)) {
+            $expression
+                ->setName('STRING_AGG')
+                ->setSyntax(StringAggExpression::SYNTAX_STANDARD);
+
+            return;
+        }
+
+        $expression
+            ->setName('GROUP_CONCAT')
+            ->setSyntax(StringAggExpression::SYNTAX_GROUP_CONCAT);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected const MAX_ALIAS_LENGTH = 256;
+
+    /**
+     * Server type MySQL
+     *
+     * @var string
+     */
+    protected const SERVER_TYPE_MYSQL = 'mysql';
 
-    use MysqlDialectTrait;
-    use PDODriverTrait;
+    /**
+     * Server type MariaDB
+     *
+     * @var string
+     */
+    protected const SERVER_TYPE_MARIADB = 'mariadb';
 
     /**
      * Base configuration settings for MySQL driver
      *
-     * @var array
+     * @var array
      */
-    protected $_baseConfig = [
+    protected array $_baseConfig = [
         'persistent' => true,
         'host' => 'localhost',
         'username' => 'root',
@@ -39,34 +114,72 @@ class Mysql extends Driver
         'database' => 'cake',
         'port' => '3306',
         'flags' => [],
-        'encoding' => 'utf8',
+        'encoding' => 'utf8mb4',
         'timezone' => null,
         'init' => [],
     ];
 
     /**
-     * The server version
+     * String used to start a database identifier quoting to make it safe
      *
      * @var string
      */
-    protected $_version;
+    protected string $_startQuote = '`';
 
     /**
-     * Whether or not the server supports native JSON
+     * String used to end a database identifier quoting to make it safe
+     *
+     * @var string
+     */
+    protected string $_endQuote = '`';
+
+    /**
+     * Server type.
+     *
+     * If the underlying server is MariaDB, its value will get set to `'mariadb'`
+     * after `version()` method is called.
      *
-     * @var bool
+     * @var string
      */
-    protected $_supportsNativeJson;
+    protected string $serverType = self::SERVER_TYPE_MYSQL;
 
     /**
-     * Establishes a connection to the database server
+     * Mapping of feature to db server version for feature availability checks.
      *
-     * @return bool true on success
+     * @var array>
      */
-    public function connect()
+    protected array $featureVersions = [
+        'mysql' => [
+            'json' => '5.7.0',
+            'cte' => '8.0.0',
+            'window' => '8.0.0',
+            'string-agg' => '99.0.0',
+            'intersect' => '8.0.31',
+            'intersect-all' => '8.0.31',
+            'except' => '8.0.31',
+            'except-all' => '8.0.31',
+            'check-constraints' => '8.0.16',
+        ],
+        'mariadb' => [
+            'json' => '10.2.7',
+            'cte' => '10.2.1',
+            'window' => '10.2.0',
+            'string-agg' => '10.5.0',
+            'intersect' => '10.3.0',
+            'intersect-all' => '10.5.0',
+            'except' => '10.3.0',
+            'except-all' => '10.5.0',
+            'check-constraints' => '10.2.1',
+        ],
+    ];
+
+    /**
+     * @inheritDoc
+     */
+    public function connect(): void
     {
-        if ($this->_connection) {
-            return true;
+        if ($this->pdo !== null) {
+            return;
         }
         $config = $this->_config;
 
@@ -77,40 +190,60 @@ public function connect()
         if (!empty($config['timezone'])) {
             $config['init'][] = sprintf("SET time_zone = '%s'", $config['timezone']);
         }
-        if (!empty($config['encoding'])) {
-            $config['init'][] = sprintf('SET NAMES %s', $config['encoding']);
-        }
 
         $config['flags'] += [
             PDO::ATTR_PERSISTENT => $config['persistent'],
-            PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
+            $this->attrUseBufferedQueryId() => true,
             PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
         ];
 
         if (!empty($config['ssl_key']) && !empty($config['ssl_cert'])) {
-            $config['flags'][PDO::MYSQL_ATTR_SSL_KEY] = $config['ssl_key'];
-            $config['flags'][PDO::MYSQL_ATTR_SSL_CERT] = $config['ssl_cert'];
+            $config['flags'][$this->attrSslKeyId()] = $config['ssl_key'];
+            $config['flags'][$this->attrSslCertId()] = $config['ssl_cert'];
         }
         if (!empty($config['ssl_ca'])) {
-            $config['flags'][PDO::MYSQL_ATTR_SSL_CA] = $config['ssl_ca'];
+            $config['flags'][$this->attrSslCaId()] = $config['ssl_ca'];
         }
 
         if (empty($config['unix_socket'])) {
-            $dsn = "mysql:host={$config['host']};port={$config['port']};dbname={$config['database']};charset={$config['encoding']}";
+            $dsn = "mysql:host={$config['host']};port={$config['port']};dbname={$config['database']}";
         } else {
             $dsn = "mysql:unix_socket={$config['unix_socket']};dbname={$config['database']}";
         }
 
-        $this->_connect($dsn, $config);
+        if (!empty($config['encoding'])) {
+            $dsn .= ";charset={$config['encoding']}";
+        }
+
+        $this->pdo = $this->createPdo($dsn, $config);
 
         if (!empty($config['init'])) {
-            $connection = $this->connection();
             foreach ((array)$config['init'] as $command) {
-                $connection->exec($command);
+                $this->pdo->exec($command);
             }
         }
+    }
 
-        return true;
+    /**
+     * @inheritDoc
+     */
+    public function run(Query $query): StatementInterface
+    {
+        $statement = $this->prepare($query);
+        $query->getValueBinder()->attachTo($statement);
+
+        if ($query instanceof SelectQuery) {
+            try {
+                $this->getPdo()->setAttribute($this->attrUseBufferedQueryId(), $query->isBufferedResultsEnabled());
+                $this->executeStatement($statement);
+            } finally {
+                $this->getPdo()->setAttribute($this->attrUseBufferedQueryId(), true);
+            }
+        } else {
+            $this->executeStatement($statement);
+        }
+
+        return $statement;
     }
 
     /**
@@ -118,61 +251,147 @@ public function connect()
      *
      * @return bool true if it is valid to use this driver
      */
-    public function enabled()
+    public function enabled(): bool
     {
-        return in_array('mysql', PDO::getAvailableDrivers());
+        return in_array('mysql', PDO::getAvailableDrivers(), true);
     }
 
     /**
-     * Prepares a sql statement to be executed
-     *
-     * @param string|\Cake\Database\Query $query The query to prepare.
-     * @return \Cake\Database\StatementInterface
+     * @inheritDoc
      */
-    public function prepare($query)
+    public function schemaDialect(): SchemaDialect
     {
-        $this->connect();
-        $isObject = $query instanceof Query;
-        $statement = $this->_connection->prepare($isObject ? $query->sql() : $query);
-        $result = new MysqlStatement($statement, $this);
-        if ($isObject && $query->isBufferedResultsEnabled() === false) {
-            $result->bufferResults(false);
-        }
-
-        return $result;
+        return $this->_schemaDialect ?? ($this->_schemaDialect = new MysqlSchemaDialect($this));
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function schema()
+    public function schema(): string
     {
         return $this->_config['database'];
     }
 
     /**
-     * {@inheritDoc}
+     * Get the SQL for disabling foreign keys.
+     *
+     * @return string
+     */
+    public function disableForeignKeySQL(): string
+    {
+        return 'SET foreign_key_checks = 0';
+    }
+
+    /**
+     * @inheritDoc
      */
-    public function supportsDynamicConstraints()
+    public function enableForeignKeySQL(): string
     {
-        return true;
+        return 'SET foreign_key_checks = 1';
     }
 
     /**
-     * Returns true if the server supports native JSON columns
+     * @inheritDoc
+     */
+    public function supports(DriverFeatureEnum $feature): bool
+    {
+        $versionCompare = function () use ($feature) {
+            return version_compare(
+                $this->version(),
+                $this->featureVersions[$this->serverType][$feature->value],
+                '>=',
+            );
+        };
+
+        return match ($feature) {
+            DriverFeatureEnum::DISABLE_CONSTRAINT_WITHOUT_TRANSACTION,
+            DriverFeatureEnum::SAVEPOINT => true,
+
+            DriverFeatureEnum::TRUNCATE_WITH_CONSTRAINTS => false,
+
+            DriverFeatureEnum::CTE,
+            DriverFeatureEnum::JSON,
+            DriverFeatureEnum::WINDOW => $versionCompare(),
+            DriverFeatureEnum::STRING_AGG => $versionCompare(),
+            DriverFeatureEnum::GROUP_CONCAT => true,
+            DriverFeatureEnum::INTERSECT => $versionCompare(),
+            DriverFeatureEnum::INTERSECT_ALL => $versionCompare(),
+            DriverFeatureEnum::EXCEPT => $versionCompare(),
+            DriverFeatureEnum::EXCEPT_ALL => $versionCompare(),
+            DriverFeatureEnum::CHECK_CONSTRAINTS => $versionCompare(),
+            DriverFeatureEnum::SET_OPERATIONS_ORDER_BY => true,
+            DriverFeatureEnum::OPTIMIZER_HINT_COMMENT => true,
+        };
+    }
+
+    /**
+     * Returns true if the connected server is MariaDB.
      *
      * @return bool
      */
-    public function supportsNativeJson()
+    public function isMariadb(): bool
     {
-        if ($this->_supportsNativeJson !== null) {
-            return $this->_supportsNativeJson;
-        }
+        $this->version();
+
+        return $this->serverType === static::SERVER_TYPE_MARIADB;
+    }
 
+    /**
+     * Returns connected server version.
+     *
+     * @return string
+     */
+    public function version(): string
+    {
         if ($this->_version === null) {
-            $this->_version = $this->_connection->getAttribute(PDO::ATTR_SERVER_VERSION);
+            $this->_version = (string)$this->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION);
+
+            if (preg_match('/^(?:5\.5\.5-)?(\d+\.\d+\.\d+.*-MariaDB[^:]*)/', $this->_version, $matches)) {
+                $this->serverType = static::SERVER_TYPE_MARIADB;
+                $this->_version = $matches[1];
+            }
         }
 
-        return $this->_supportsNativeJson = version_compare($this->_version, '5.7.0', '>=');
+        return $this->_version;
+    }
+
+    /**
+     * Get PDO ATTR_SSL_KEY id.
+     *
+     * @return int
+     */
+    private function attrSslKeyId(): int
+    {
+        return PHP_VERSION_ID < 80400 ? PDO::MYSQL_ATTR_SSL_KEY : PdoMysql::ATTR_SSL_KEY;
+    }
+
+    /**
+     * Get PDO ATTR_SSL_CERT id.
+     *
+     * @return int
+     */
+    private function attrSslCertId(): int
+    {
+        return PHP_VERSION_ID < 80400 ? PDO::MYSQL_ATTR_SSL_CERT : PdoMysql::ATTR_SSL_CERT;
+    }
+
+    /**
+     * Get PDO ATTR_SSL_CA id.
+     *
+     * @return int
+     */
+    private function attrSslCaId(): int
+    {
+        return PHP_VERSION_ID < 80400 ? PDO::MYSQL_ATTR_SSL_CA : PdoMysql::ATTR_SSL_CA;
+    }
+
+    /**
+     * Get PDO ATTR_USE_BUFFERED_QUERY id.
+     *
+     * @return int
+     */
+    private function attrUseBufferedQueryId(): int
+    {
+        return PHP_VERSION_ID < 80400 ? PDO::MYSQL_ATTR_USE_BUFFERED_QUERY : PdoMysql::ATTR_USE_BUFFERED_QUERY;
     }
 }
diff --git a/src/Database/Driver/PDODriverTrait.php b/src/Database/Driver/PDODriverTrait.php
deleted file mode 100644
index 1058d95b9d9..00000000000
--- a/src/Database/Driver/PDODriverTrait.php
+++ /dev/null
@@ -1,201 +0,0 @@
-connection($connection);
-
-        return true;
-    }
-
-    /**
-     * Returns correct connection resource or object that is internally used
-     * If first argument is passed, it will set internal connection object or
-     * result to the value passed
-     *
-     * @param null|\PDO $connection The PDO connection instance.
-     * @return \PDO connection object used internally
-     */
-    public function connection($connection = null)
-    {
-        if ($connection !== null) {
-            $this->_connection = $connection;
-        }
-
-        return $this->_connection;
-    }
-
-    /**
-     * Disconnects from database server
-     *
-     * @return void
-     */
-    public function disconnect()
-    {
-        $this->_connection = null;
-    }
-
-    /**
-     * Checks whether or not the driver is connected.
-     *
-     * @return bool
-     */
-    public function isConnected()
-    {
-        if ($this->_connection === null) {
-            $connected = false;
-        } else {
-            try {
-                $connected = $this->_connection->query('SELECT 1');
-            } catch (PDOException $e) {
-                $connected = false;
-            }
-        }
-
-        return (bool)$connected;
-    }
-
-    /**
-     * Prepares a sql statement to be executed
-     *
-     * @param string|\Cake\Database\Query $query The query to turn into a prepared statement.
-     * @return \Cake\Database\StatementInterface
-     */
-    public function prepare($query)
-    {
-        $this->connect();
-        $isObject = $query instanceof Query;
-        $statement = $this->_connection->prepare($isObject ? $query->sql() : $query);
-
-        return new PDOStatement($statement, $this);
-    }
-
-    /**
-     * Starts a transaction
-     *
-     * @return bool true on success, false otherwise
-     */
-    public function beginTransaction()
-    {
-        $this->connect();
-        if ($this->_connection->inTransaction()) {
-            return true;
-        }
-
-        return $this->_connection->beginTransaction();
-    }
-
-    /**
-     * Commits a transaction
-     *
-     * @return bool true on success, false otherwise
-     */
-    public function commitTransaction()
-    {
-        $this->connect();
-        if (!$this->_connection->inTransaction()) {
-            return false;
-        }
-
-        return $this->_connection->commit();
-    }
-
-    /**
-     * Rollback a transaction
-     *
-     * @return bool true on success, false otherwise
-     */
-    public function rollbackTransaction()
-    {
-        $this->connect();
-        if (!$this->_connection->inTransaction()) {
-            return false;
-        }
-
-        return $this->_connection->rollback();
-    }
-
-    /**
-     * Returns a value in a safe representation to be used in a query string
-     *
-     * @param mixed $value The value to quote.
-     * @param string $type Type to be used for determining kind of quoting to perform
-     * @return string
-     */
-    public function quote($value, $type)
-    {
-        $this->connect();
-
-        return $this->_connection->quote($value, $type);
-    }
-
-    /**
-     * Returns last id generated for a table or sequence in database
-     *
-     * @param string|null $table table name or sequence to get last insert value from
-     * @param string|null $column the name of the column representing the primary key
-     * @return string|int
-     */
-    public function lastInsertId($table = null, $column = null)
-    {
-        $this->connect();
-
-        return $this->_connection->lastInsertId($table);
-    }
-
-    /**
-     * Checks if the driver supports quoting, as PDO_ODBC does not support it.
-     *
-     * @return bool
-     */
-    public function supportsQuoting()
-    {
-        $this->connect();
-
-        return $this->_connection->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'odbc';
-    }
-}
diff --git a/src/Database/Driver/Postgres.php b/src/Database/Driver/Postgres.php
index 1862090faaf..6839f0b226d 100644
--- a/src/Database/Driver/Postgres.php
+++ b/src/Database/Driver/Postgres.php
@@ -1,4 +1,6 @@
 
      */
-    protected $_baseConfig = [
+    protected array $_baseConfig = [
         'persistent' => true,
         'host' => 'localhost',
         'username' => 'root',
@@ -41,23 +57,40 @@ class Postgres extends Driver
         'timezone' => null,
         'flags' => [],
         'init' => [],
+        'ssl_key' => null,
+        'ssl_cert' => null,
+        'ssl_ca' => null,
+        'ssl' => false,
+        'ssl_mode' => null,
     ];
 
     /**
-     * Establishes a connection to the database server
+     * String used to start a database identifier quoting to make it safe
      *
-     * @return bool true on success
+     * @var string
      */
-    public function connect()
+    protected string $_startQuote = '"';
+
+    /**
+     * String used to end a database identifier quoting to make it safe
+     *
+     * @var string
+     */
+    protected string $_endQuote = '"';
+
+    /**
+     * @inheritDoc
+     */
+    public function connect(): void
     {
-        if ($this->_connection) {
-            return true;
+        if ($this->pdo !== null) {
+            return;
         }
         $config = $this->_config;
         $config['flags'] += [
             PDO::ATTR_PERSISTENT => $config['persistent'],
             PDO::ATTR_EMULATE_PREPARES => false,
-            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
+            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
         ];
         if (empty($config['unix_socket'])) {
             $dsn = "pgsql:host={$config['host']};port={$config['port']};dbname={$config['database']}";
@@ -65,8 +98,25 @@ public function connect()
             $dsn = "pgsql:dbname={$config['database']}";
         }
 
-        $this->_connect($dsn, $config);
-        $this->_connection = $connection = $this->connection();
+        if ($this->_config['ssl']) {
+            if ($this->_config['ssl_mode']) {
+                $dsn .= ';sslmode=' . $this->_config['ssl_mode'];
+            } else {
+                $dsn .= ';sslmode=allow';
+            }
+
+            if ($this->_config['ssl_key']) {
+                $dsn .= ';sslkey=' . $this->_config['ssl_key'];
+            }
+            if ($this->_config['ssl_cert']) {
+                $dsn .= ';sslcert=' . $this->_config['ssl_cert'];
+            }
+            if ($this->_config['ssl_ca']) {
+                $dsn .= ';sslrootcert=' . $this->_config['ssl_ca'];
+            }
+        }
+
+        $this->pdo = $this->createPdo($dsn, $config);
         if (!empty($config['encoding'])) {
             $this->setEncoding($config['encoding']);
         }
@@ -76,14 +126,13 @@ public function connect()
         }
 
         if (!empty($config['timezone'])) {
-            $config['init'][] = sprintf('SET timezone = %s', $connection->quote($config['timezone']));
+            $config['init'][] = sprintf('SET timezone = %s', $this->getPdo()->quote($config['timezone']));
         }
 
         foreach ($config['init'] as $command) {
-            $connection->exec($command);
+            /** @phpstan-ignore-next-line */
+            $this->pdo->exec($command);
         }
-
-        return true;
     }
 
     /**
@@ -91,9 +140,17 @@ public function connect()
      *
      * @return bool true if it is valid to use this driver
      */
-    public function enabled()
+    public function enabled(): bool
     {
-        return in_array('pgsql', PDO::getAvailableDrivers());
+        return in_array('pgsql', PDO::getAvailableDrivers(), true);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function schemaDialect(): SchemaDialect
+    {
+        return $this->_schemaDialect ?? ($this->_schemaDialect = new PostgresSchemaDialect($this));
     }
 
     /**
@@ -102,10 +159,10 @@ public function enabled()
      * @param string $encoding The encoding to use.
      * @return void
      */
-    public function setEncoding($encoding)
+    public function setEncoding(string $encoding): void
     {
-        $this->connect();
-        $this->_connection->exec('SET NAMES ' . $this->_connection->quote($encoding));
+        $pdo = $this->getPdo();
+        $pdo->exec('SET NAMES ' . $pdo->quote($encoding));
     }
 
     /**
@@ -115,17 +172,210 @@ public function setEncoding($encoding)
      * @param string $schema The schema names to set `search_path` to.
      * @return void
      */
-    public function setSchema($schema)
+    public function setSchema(string $schema): void
+    {
+        $pdo = $this->getPdo();
+        $pdo->exec('SET search_path TO ' . $pdo->quote($schema));
+    }
+
+    /**
+     * Get the SQL for disabling foreign keys.
+     *
+     * @return string
+     */
+    public function disableForeignKeySQL(): string
+    {
+        return 'SET CONSTRAINTS ALL DEFERRED';
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function enableForeignKeySQL(): string
+    {
+        return 'SET CONSTRAINTS ALL IMMEDIATE';
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function supports(DriverFeatureEnum $feature): bool
+    {
+        return match ($feature) {
+            DriverFeatureEnum::CTE,
+            DriverFeatureEnum::JSON,
+            DriverFeatureEnum::SAVEPOINT,
+            DriverFeatureEnum::TRUNCATE_WITH_CONSTRAINTS,
+            DriverFeatureEnum::WINDOW => true,
+            DriverFeatureEnum::STRING_AGG => true,
+            DriverFeatureEnum::GROUP_CONCAT => false,
+            DriverFeatureEnum::INTERSECT => true,
+            DriverFeatureEnum::INTERSECT_ALL => true,
+            DriverFeatureEnum::EXCEPT => true,
+            DriverFeatureEnum::EXCEPT_ALL => true,
+            DriverFeatureEnum::SET_OPERATIONS_ORDER_BY => true,
+            DriverFeatureEnum::DISABLE_CONSTRAINT_WITHOUT_TRANSACTION => false,
+            DriverFeatureEnum::OPTIMIZER_HINT_COMMENT => true,
+            DriverFeatureEnum::CHECK_CONSTRAINTS => true,
+        };
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function _transformDistinct(SelectQuery $query): SelectQuery
+    {
+        return $query;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function _insertQueryTranslator(InsertQuery $query): InsertQuery
+    {
+        if (!$query->clause('epilog')) {
+            $query->epilog('RETURNING *');
+        }
+
+        return $query;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function _expressionTranslators(): array
+    {
+        return [
+            IdentifierExpression::class => '_transformIdentifierExpression',
+            StringAggExpression::class => '_transformStringAggExpression',
+            FunctionExpression::class => '_transformFunctionExpression',
+            StringExpression::class => '_transformStringExpression',
+        ];
+    }
+
+    /**
+     * Receives a StringAggExpression and changes it so that it conforms to this
+     * SQL dialect.
+     *
+     * @param \Cake\Database\Expression\StringAggExpression $expression The expression to convert.
+     * @return void
+     */
+    protected function _transformStringAggExpression(StringAggExpression $expression): void
     {
-        $this->connect();
-        $this->_connection->exec('SET search_path TO ' . $this->_connection->quote($schema));
+        $expression
+            ->setName('STRING_AGG')
+            ->setSyntax(StringAggExpression::SYNTAX_STANDARD);
+    }
+
+    /**
+     * Changes identifier expression into postgresql format.
+     *
+     * @param \Cake\Database\Expression\IdentifierExpression $expression The expression to transform.
+     * @return void
+     */
+    protected function _transformIdentifierExpression(IdentifierExpression $expression): void
+    {
+        $collation = $expression->getCollation();
+        if ($collation) {
+            // use trim() to work around expression being transformed multiple times
+            $expression->setCollation('"' . trim($collation, '"') . '"');
+        }
+    }
+
+    /**
+     * Receives a FunctionExpression and changes it so that it conforms to this
+     * SQL dialect.
+     *
+     * @param \Cake\Database\Expression\FunctionExpression $expression The function expression to convert
+     *   to postgres SQL.
+     * @return void
+     */
+    protected function _transformFunctionExpression(FunctionExpression $expression): void
+    {
+        switch ($expression->getName()) {
+            case 'CONCAT':
+                // CONCAT function is expressed as exp1 || exp2
+                $expression->setName('')->setConjunction(' ||');
+                break;
+            case 'DATEDIFF':
+                $expression
+                    ->setName('')
+                    ->setConjunction('-')
+                    ->iterateParts(function ($p) {
+                        if (is_string($p)) {
+                            $p = ['value' => [$p => 'literal'], 'type' => null];
+                        } else {
+                            $p['value'] = [$p['value']];
+                        }
+
+                        return new FunctionExpression('DATE', $p['value'], [$p['type']]);
+                    });
+                break;
+            case 'CURRENT_DATE':
+                $time = new FunctionExpression('LOCALTIMESTAMP', [' 0 ' => 'literal']);
+                $expression->setName('CAST')->setConjunction(' AS ')->add([$time, 'date' => 'literal']);
+                break;
+            case 'CURRENT_TIME':
+                $time = new FunctionExpression('LOCALTIMESTAMP', [' 0 ' => 'literal']);
+                $expression->setName('CAST')->setConjunction(' AS ')->add([$time, 'time' => 'literal']);
+                break;
+            case 'NOW':
+                $expression->setName('LOCALTIMESTAMP')->add([' 0 ' => 'literal']);
+                break;
+            case 'RAND':
+                $expression->setName('RANDOM');
+                break;
+            case 'DATE_ADD':
+                $expression
+                    ->setName('')
+                    ->setConjunction(' + INTERVAL')
+                    ->iterateParts(function ($p, $key) {
+                        if ($key === 1) {
+                            return sprintf("'%s'", $p);
+                        }
+
+                        return $p;
+                    });
+                break;
+            case 'DAYOFWEEK':
+                $expression
+                    ->setName('EXTRACT')
+                    ->setConjunction(' ')
+                    ->add(['DOW FROM' => 'literal'], [], true)
+                    ->add([') + (1' => 'literal']); // Postgres starts on index 0 but Sunday should be 1
+                break;
+            case 'JSON_VALUE':
+                $expression->setName('JSONB_PATH_QUERY')
+                    ->iterateParts(function ($p, $key) {
+                        if ($key === 0) {
+                            return sprintf('%s::jsonb', $p);
+                        }
+
+                        return $p;
+                    });
+                break;
+        }
+    }
+
+    /**
+     * Changes string expression into postgresql format.
+     *
+     * @param \Cake\Database\Expression\StringExpression $expression The string expression to transform.
+     * @return void
+     */
+    protected function _transformStringExpression(StringExpression $expression): void
+    {
+        // use trim() to work around expression being transformed multiple times
+        $expression->setCollation('"' . trim($expression->getCollation(), '"') . '"');
     }
 
     /**
      * {@inheritDoc}
+     *
+     * @return \Cake\Database\PostgresCompiler
      */
-    public function supportsDynamicConstraints()
+    public function newCompiler(): QueryCompiler
     {
-        return true;
+        return new PostgresCompiler();
     }
 }
diff --git a/src/Database/Driver/Sqlite.php b/src/Database/Driver/Sqlite.php
index 3857b889a22..56ed75f129e 100644
--- a/src/Database/Driver/Sqlite.php
+++ b/src/Database/Driver/Sqlite.php
@@ -1,4 +1,6 @@
 
      */
-    protected $_baseConfig = [
+    protected array $_baseConfig = [
         'persistent' => false,
         'username' => null,
         'password' => null,
         'database' => ':memory:',
         'encoding' => 'utf8',
         'mask' => 0644,
+        'cache' => null,
+        'mode' => null,
         'flags' => [],
         'init' => [],
     ];
 
     /**
-     * Establishes a connection to the database server
+     * Whether the connected server supports window functions.
+     *
+     * @var bool|null
+     */
+    protected ?bool $_supportsWindowFunctions = null;
+
+    /**
+     * String used to start a database identifier quoting to make it safe
+     *
+     * @var string
+     */
+    protected string $_startQuote = '"';
+
+    /**
+     * String used to end a database identifier quoting to make it safe
+     *
+     * @var string
+     */
+    protected string $_endQuote = '"';
+
+    /**
+     * Mapping of date parts.
      *
-     * @return bool true on success
+     * @var array
      */
-    public function connect()
+    protected array $_dateParts = [
+        'day' => 'd',
+        'hour' => 'H',
+        'month' => 'm',
+        'minute' => 'M',
+        'second' => 'S',
+        'week' => 'W',
+        'year' => 'Y',
+    ];
+
+    /**
+     * Mapping of feature to db server version for feature availability checks.
+     *
+     * @var array
+     */
+    protected array $featureVersions = [
+        'cte' => '3.8.3',
+        'string-agg' => '3.44.0',
+        'window' => '3.28.0',
+    ];
+
+    /**
+     * @inheritDoc
+     */
+    public function connect(): void
     {
-        if ($this->_connection) {
-            return true;
+        if ($this->pdo !== null) {
+            return;
         }
         $config = $this->_config;
         $config['flags'] += [
             PDO::ATTR_PERSISTENT => $config['persistent'],
             PDO::ATTR_EMULATE_PREPARES => false,
-            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
+            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
         ];
+        if (!is_string($config['database']) || $config['database'] === '') {
+            $name = $config['name'] ?? 'unknown';
+            throw new InvalidArgumentException(
+                "The `database` key for the `{$name}` SQLite connection needs to be a non-empty string.",
+            );
+        }
+
+        $chmodFile = false;
+        if ($config['database'] !== ':memory:' && $config['mode'] !== 'memory') {
+            $chmodFile = !file_exists($config['database']);
+        }
 
-        $databaseExists = file_exists($config['database']);
+        $params = [];
+        if ($config['cache']) {
+            $params[] = 'cache=' . $config['cache'];
+        }
+        if ($config['mode']) {
+            $params[] = 'mode=' . $config['mode'];
+        }
 
-        $dsn = "sqlite:{$config['database']}";
-        $this->_connect($dsn, $config);
+        if ($params) {
+            $dsn = 'sqlite:file:' . $config['database'] . '?' . implode('&', $params);
+        } else {
+            $dsn = 'sqlite:' . $config['database'];
+        }
 
-        if (!$databaseExists && $config['database'] != ':memory:') {
-            //@codingStandardsIgnoreStart
+        $this->pdo = $this->createPdo($dsn, $config);
+        if ($chmodFile) {
+            // phpcs:disable
             @chmod($config['database'], $config['mask']);
-            //@codingStandardsIgnoreEnd
+            // phpcs:enable
         }
 
         if (!empty($config['init'])) {
             foreach ((array)$config['init'] as $command) {
-                $this->connection()->exec($command);
+                $this->pdo->exec($command);
             }
         }
-
-        return true;
     }
 
     /**
@@ -87,35 +165,166 @@ public function connect()
      *
      * @return bool true if it is valid to use this driver
      */
-    public function enabled()
+    public function enabled(): bool
     {
-        return in_array('sqlite', PDO::getAvailableDrivers());
+        return in_array('sqlite', PDO::getAvailableDrivers(), true);
     }
 
     /**
-     * Prepares a sql statement to be executed
+     * Get the SQL for disabling foreign keys.
      *
-     * @param string|\Cake\Database\Query $query The query to prepare.
-     * @return \Cake\Database\StatementInterface
+     * @return string
      */
-    public function prepare($query)
+    public function disableForeignKeySQL(): string
     {
-        $this->connect();
-        $isObject = $query instanceof Query;
-        $statement = $this->_connection->prepare($isObject ? $query->sql() : $query);
-        $result = new SqliteStatement(new PDOStatement($statement, $this), $this);
-        if ($isObject && $query->isBufferedResultsEnabled() === false) {
-            $result->bufferResults(false);
-        }
+        return 'PRAGMA foreign_keys = OFF';
+    }
 
-        return $result;
+    /**
+     * @inheritDoc
+     */
+    public function enableForeignKeySQL(): string
+    {
+        return 'PRAGMA foreign_keys = ON';
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function supports(DriverFeatureEnum $feature): bool
+    {
+        return match ($feature) {
+            DriverFeatureEnum::DISABLE_CONSTRAINT_WITHOUT_TRANSACTION,
+            DriverFeatureEnum::SAVEPOINT,
+            DriverFeatureEnum::TRUNCATE_WITH_CONSTRAINTS => true,
+
+            DriverFeatureEnum::JSON => false,
+
+            DriverFeatureEnum::CTE,
+            DriverFeatureEnum::STRING_AGG,
+            DriverFeatureEnum::WINDOW => version_compare(
+                $this->version(),
+                $this->featureVersions[$feature->value],
+                '>=',
+            ),
+            DriverFeatureEnum::GROUP_CONCAT => true,
+            DriverFeatureEnum::INTERSECT => true,
+            DriverFeatureEnum::INTERSECT_ALL => false,
+            DriverFeatureEnum::EXCEPT => true,
+            DriverFeatureEnum::EXCEPT_ALL => false,
+            DriverFeatureEnum::SET_OPERATIONS_ORDER_BY => false,
+            DriverFeatureEnum::OPTIMIZER_HINT_COMMENT => false,
+            DriverFeatureEnum::CHECK_CONSTRAINTS => true,
+        };
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function schemaDialect(): SchemaDialect
+    {
+        return $this->_schemaDialect ?? ($this->_schemaDialect = new SqliteSchemaDialect($this));
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function supportsDynamicConstraints()
+    protected function _expressionTranslators(): array
     {
-        return false;
+        return [
+            StringAggExpression::class => '_transformStringAggExpression',
+            FunctionExpression::class => '_transformFunctionExpression',
+            TupleComparison::class => '_transformTupleComparison',
+        ];
+    }
+
+    /**
+     * Receives a StringAggExpression and changes it so that it conforms to this
+     * SQL dialect.
+     *
+     * @param \Cake\Database\Expression\StringAggExpression $expression The expression to convert.
+     * @return void
+     */
+    protected function _transformStringAggExpression(StringAggExpression $expression): void
+    {
+        $expression
+            ->setName($this->supports(DriverFeatureEnum::STRING_AGG) ? 'STRING_AGG' : 'GROUP_CONCAT')
+            ->setSyntax(StringAggExpression::SYNTAX_STANDARD);
+    }
+
+    /**
+     * Receives a FunctionExpression and changes it so that it conforms to this
+     * SQL dialect.
+     *
+     * @param \Cake\Database\Expression\FunctionExpression $expression The function expression to convert to TSQL.
+     * @return void
+     */
+    protected function _transformFunctionExpression(FunctionExpression $expression): void
+    {
+        switch ($expression->getName()) {
+            case 'CONCAT':
+                // CONCAT function is expressed as exp1 || exp2
+                $expression->setName('')->setConjunction(' ||');
+                break;
+            case 'DATEDIFF':
+                $expression
+                    ->setName('ROUND')
+                    ->setConjunction('-')
+                    ->iterateParts(function ($p) {
+                        return new FunctionExpression('JULIANDAY', [$p['value']], [$p['type']]);
+                    });
+                break;
+            case 'NOW':
+                $expression->setName('DATETIME')->add(["'now'" => 'literal']);
+                break;
+            case 'RAND':
+                $expression
+                    ->setName('ABS')
+                    ->add(['RANDOM() % 1' => 'literal'], [], true);
+                break;
+            case 'CURRENT_DATE':
+                $expression->setName('DATE')->add(["'now'" => 'literal']);
+                break;
+            case 'CURRENT_TIME':
+                $expression->setName('TIME')->add(["'now'" => 'literal']);
+                break;
+            case 'EXTRACT':
+                $expression
+                    ->setName('STRFTIME')
+                    ->setConjunction(' ,')
+                    ->iterateParts(function ($p, $key) {
+                        if ($key === 0) {
+                            $value = rtrim(strtolower($p), 's');
+                            if (isset($this->_dateParts[$value])) {
+                                $p = ['value' => '%' . $this->_dateParts[$value], 'type' => null];
+                            }
+                        }
+
+                        return $p;
+                    });
+                break;
+            case 'DATE_ADD':
+                $expression
+                    ->setName('DATE')
+                    ->setConjunction(',')
+                    ->iterateParts(function ($p, $key) {
+                        if ($key === 1) {
+                            return ['value' => $p, 'type' => null];
+                        }
+
+                        return $p;
+                    });
+                break;
+            case 'DAYOFWEEK':
+                $expression
+                    ->setName('STRFTIME')
+                    ->setConjunction(' ')
+                    ->add(["'%w', " => 'literal'], [], true)
+                    ->add([') + (1' => 'literal']); // Sqlite starts on index 0 but Sunday should be 1
+                break;
+            case 'JSON_VALUE':
+                $expression->setName('JSON_EXTRACT');
+                break;
+        }
     }
 }
diff --git a/src/Database/Driver/Sqlserver.php b/src/Database/Driver/Sqlserver.php
index c5e92bf8d6e..aa8c48f4b98 100644
--- a/src/Database/Driver/Sqlserver.php
+++ b/src/Database/Driver/Sqlserver.php
@@ -1,4 +1,6 @@
 
      */
-    protected $_baseConfig = [
+    protected array $_baseConfig = [
         'host' => 'localhost\SQLEXPRESS',
         'username' => '',
         'password' => '',
@@ -51,8 +82,26 @@ class Sqlserver extends Driver
         'failoverPartner' => null,
         'loginTimeout' => null,
         'multiSubnetFailover' => null,
+        'encrypt' => null,
+        'trustServerCertificate' => null,
+        'accessToken' => null,
+        'authentication' => null,
     ];
 
+    /**
+     * String used to start a database identifier quoting to make it safe
+     *
+     * @var string
+     */
+    protected string $_startQuote = '[';
+
+    /**
+     * String used to end a database identifier quoting to make it safe
+     *
+     * @var string
+     */
+    protected string $_endQuote = ']';
+
     /**
      * Establishes a connection to the database server.
      *
@@ -62,29 +111,31 @@ class Sqlserver extends Driver
      * information see: https://github.com/Microsoft/msphpsql/issues/65).
      *
      * @throws \InvalidArgumentException if an unsupported setting is in the driver config
-     * @return bool true on success
+     * @return void
      */
-    public function connect()
+    public function connect(): void
     {
-        if ($this->_connection) {
-            return true;
+        if ($this->pdo !== null) {
+            return;
         }
         $config = $this->_config;
 
         if (isset($config['persistent']) && $config['persistent']) {
-            throw new \InvalidArgumentException('Config setting "persistent" cannot be set to true, as the Sqlserver PDO driver does not support PDO::ATTR_PERSISTENT');
+            throw new InvalidArgumentException(
+                'Config setting "persistent" cannot be set to true, '
+                . 'as the Sqlserver PDO driver does not support PDO::ATTR_PERSISTENT',
+            );
         }
 
         $config['flags'] += [
-            PDO::ATTR_EMULATE_PREPARES => false,
-            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
+            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
         ];
 
         if (!empty($config['encoding'])) {
             $config['flags'][PDO::SQLSRV_ATTR_ENCODING] = $config['encoding'];
         }
         $port = '';
-        if (strlen($config['port'])) {
+        if ($config['port']) {
             $port = ',' . $config['port'];
         }
 
@@ -104,26 +155,35 @@ public function connect()
         if ($config['multiSubnetFailover'] !== null) {
             $dsn .= ";MultiSubnetFailover={$config['multiSubnetFailover']}";
         }
-        $this->_connect($dsn, $config);
+        if ($config['encrypt'] !== null) {
+            $dsn .= ";Encrypt={$config['encrypt']}";
+        }
+        if ($config['trustServerCertificate'] !== null) {
+            $dsn .= ";TrustServerCertificate={$config['trustServerCertificate']}";
+        }
+        if ($config['accessToken'] !== null) {
+            $dsn .= ";AccessToken={$config['accessToken']}";
+        }
+        if ($config['authentication'] !== null) {
+            $dsn .= ";Authentication={$config['authentication']}";
+        }
 
-        $connection = $this->connection();
+        $this->pdo = $this->createPdo($dsn, $config);
         if (!empty($config['init'])) {
             foreach ((array)$config['init'] as $command) {
-                $connection->exec($command);
+                $this->pdo->exec($command);
             }
         }
         if (!empty($config['settings']) && is_array($config['settings'])) {
             foreach ($config['settings'] as $key => $value) {
-                $connection->exec("SET {$key} {$value}");
+                $this->pdo->exec("SET {$key} {$value}");
             }
         }
         if (!empty($config['attributes']) && is_array($config['attributes'])) {
             foreach ($config['attributes'] as $key => $value) {
-                $connection->setAttribute($key, $value);
+                $this->pdo->setAttribute($key, $value);
             }
         }
-
-        return true;
     }
 
     /**
@@ -131,35 +191,391 @@ public function connect()
      *
      * @return bool true if it is valid to use this driver
      */
-    public function enabled()
+    public function enabled(): bool
     {
-        return in_array('sqlsrv', PDO::getAvailableDrivers());
+        return in_array('sqlsrv', PDO::getAvailableDrivers(), true);
     }
 
     /**
-     * Prepares a sql statement to be executed
-     *
-     * @param string|\Cake\Database\Query $query The query to prepare.
-     * @return \Cake\Database\StatementInterface
+     * @inheritDoc
      */
-    public function prepare($query)
+    public function prepare(Query|string $query): StatementInterface
     {
-        $this->connect();
-        $options = [PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL];
-        $isObject = $query instanceof Query;
-        if ($isObject && $query->isBufferedResultsEnabled() === false) {
-            $options = [];
+        $options = [
+            PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL,
+            PDO::SQLSRV_ATTR_CURSOR_SCROLL_TYPE => PDO::SQLSRV_CURSOR_BUFFERED,
+        ];
+
+        $sql = $query;
+        if ($query instanceof Query) {
+            $sql = $query->sql();
+            if (count($query->getValueBinder()->bindings()) > 2100) {
+                throw new InvalidArgumentException(
+                    'Exceeded maximum number of parameters (2100) for prepared statements in Sql Server. ' .
+                    'This is probably due to a very large WHERE IN () clause which generates a parameter ' .
+                    'for each value in the array. ' .
+                    'If using an Association, try changing the `strategy` from select to subquery.',
+                );
+            }
+
+            if ($query instanceof SelectQuery && !$query->isBufferedResultsEnabled()) {
+                $options = [];
+            }
         }
-        $statement = $this->_connection->prepare($isObject ? $query->sql() : $query, $options);
 
-        return new SqlserverStatement($statement, $this);
+        /** @var string $sql */
+        $statement = $this->getPdo()->prepare(
+            $sql,
+            $options,
+        );
+
+        /** @var \Cake\Database\StatementInterface */
+        return new (static::STATEMENT_CLASS)($statement, $this, $this->getResultSetDecorators($query));
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function savePointSQL($name): string
+    {
+        return 'SAVE TRANSACTION t' . $name;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function releaseSavePointSQL($name): string
+    {
+        // SQLServer has no release save point operation.
+        return '';
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function rollbackSavePointSQL($name): string
+    {
+        return 'ROLLBACK TRANSACTION t' . $name;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function disableForeignKeySQL(): string
+    {
+        return 'EXEC sp_MSforeachtable "ALTER TABLE ? NOCHECK CONSTRAINT all"';
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function enableForeignKeySQL(): string
+    {
+        return 'EXEC sp_MSforeachtable "ALTER TABLE ? WITH CHECK CHECK CONSTRAINT all"';
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function supports(DriverFeatureEnum $feature): bool
+    {
+        return match ($feature) {
+            DriverFeatureEnum::CTE,
+            DriverFeatureEnum::DISABLE_CONSTRAINT_WITHOUT_TRANSACTION,
+            DriverFeatureEnum::SAVEPOINT,
+            DriverFeatureEnum::TRUNCATE_WITH_CONSTRAINTS,
+            DriverFeatureEnum::WINDOW => true,
+            DriverFeatureEnum::STRING_AGG => version_compare($this->version(), '14', '>='),
+            DriverFeatureEnum::GROUP_CONCAT => false,
+            DriverFeatureEnum::INTERSECT => true,
+            DriverFeatureEnum::INTERSECT_ALL => false,
+            DriverFeatureEnum::EXCEPT => true,
+            DriverFeatureEnum::EXCEPT_ALL => false,
+            DriverFeatureEnum::JSON => false,
+            DriverFeatureEnum::SET_OPERATIONS_ORDER_BY => false,
+            DriverFeatureEnum::OPTIMIZER_HINT_COMMENT => false,
+            DriverFeatureEnum::CHECK_CONSTRAINTS => false,
+        };
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function schemaDialect(): SchemaDialect
+    {
+        return $this->_schemaDialect ??= new SqlserverSchemaDialect($this);
     }
 
     /**
      * {@inheritDoc}
+     *
+     * @return \Cake\Database\SqlserverCompiler
+     */
+    public function newCompiler(): QueryCompiler
+    {
+        return new SqlserverCompiler();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function _selectQueryTranslator(SelectQuery $query): SelectQuery
+    {
+        $limit = $query->clause('limit');
+        $offset = $query->clause('offset');
+
+        if ($limit && $offset === null) {
+            $query->modifier(['_auto_top_' => sprintf('TOP %d', $limit)]);
+        }
+
+        if ($offset !== null && !$query->clause('order')) {
+            $query->orderBy($query->expr()->add('(SELECT NULL)'));
+        }
+
+        if ($this->version() < 11 && $offset !== null) {
+            return $this->_pagingSubquery($query, $limit, $offset);
+        }
+
+        return $this->_transformDistinct($query);
+    }
+
+    /**
+     * Generate a paging subquery for older versions of SQLserver.
+     *
+     * Prior to SQLServer 2012 there was no equivalent to LIMIT OFFSET, so a subquery must
+     * be used.
+     *
+     * @param \Cake\Database\Query\SelectQuery $original The query to wrap in a subquery.
+     * @param int|null $limit The number of rows to fetch.
+     * @param int|null $offset The number of rows to offset.
+     * @return \Cake\Database\Query\SelectQuery Modified query object.
+     */
+    protected function _pagingSubquery(SelectQuery $original, ?int $limit, ?int $offset): SelectQuery
+    {
+        $field = '_cake_paging_._cake_page_rownum_';
+
+        /** @var \Cake\Database\Expression\OrderByExpression $originalOrder */
+        $originalOrder = $original->clause('order');
+        if ($originalOrder) {
+            // SQL server does not support column aliases in OVER clauses.  But
+            // the only practical way to specify the use of calculated columns
+            // is with their alias.  So substitute the select SQL in place of
+            // any column aliases for those entries in the order clause.
+            $select = $original->clause('select');
+            $order = new OrderByExpression();
+            $originalOrder
+                ->iterateParts(function ($direction, $orderBy) use ($select, $order) {
+                    $key = $orderBy;
+                    if (
+                        isset($select[$orderBy]) &&
+                        $select[$orderBy] instanceof ExpressionInterface
+                    ) {
+                        $order->add(new OrderClauseExpression($select[$orderBy], $direction));
+                    } else {
+                        $order->add([$key => $direction]);
+                    }
+
+                    // Leave original order clause unchanged.
+                    return $orderBy;
+                });
+        } else {
+            $order = new OrderByExpression('(SELECT NULL)');
+        }
+
+        $query = clone $original;
+        $query->select([
+                '_cake_page_rownum_' => new UnaryExpression('ROW_NUMBER() OVER', $order),
+            ])->limit(null)
+            ->offset(null)
+            ->orderBy([], true);
+
+        $outer = $query->getConnection()->selectQuery();
+        $outer->select('*')
+            ->from(['_cake_paging_' => $query]);
+
+        if ($offset) {
+            $outer->where(["{$field} > " . $offset]);
+        }
+        if ($limit) {
+            $value = (int)$offset + $limit;
+            $outer->where(["{$field} <= {$value}"]);
+        }
+
+        // Decorate the original query as that is what the
+        // end developer will be calling execute() on originally.
+        $original->decorateResults(function ($row) {
+            if (isset($row['_cake_page_rownum_'])) {
+                unset($row['_cake_page_rownum_']);
+            }
+
+            return $row;
+        });
+
+        return $outer;
+    }
+
+    /**
+     * @inheritDoc
      */
-    public function supportsDynamicConstraints()
+    protected function _transformDistinct(SelectQuery $query): SelectQuery
     {
-        return true;
+        if (!is_array($query->clause('distinct'))) {
+            return $query;
+        }
+
+        $original = $query;
+        $query = clone $original;
+
+        $distinct = $query->clause('distinct');
+        $query->distinct(false);
+
+        $order = new OrderByExpression($distinct);
+        $query
+            ->select(function (Query $q) use ($distinct, $order) {
+                $over = $q->expr('ROW_NUMBER() OVER')
+                    ->add('(PARTITION BY')
+                    ->add($q->expr()->add($distinct)->setConjunction(','))
+                    ->add($order)
+                    ->add(')')
+                    ->setConjunction(' ');
+
+                return [
+                    '_cake_distinct_pivot_' => $over,
+                ];
+            })
+            ->limit(null)
+            ->offset(null)
+            ->orderBy([], true);
+
+        $outer = new SelectQuery($query->getConnection());
+        $outer->select('*')
+            ->from(['_cake_distinct_' => $query])
+            ->where(['_cake_distinct_pivot_' => 1]);
+
+        // Decorate the original query as that is what the
+        // end developer will be calling execute() on originally.
+        $original->decorateResults(function ($row) {
+            if (isset($row['_cake_distinct_pivot_'])) {
+                unset($row['_cake_distinct_pivot_']);
+            }
+
+            return $row;
+        });
+
+        return $outer;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function _expressionTranslators(): array
+    {
+        return [
+            StringAggExpression::class => '_transformStringAggExpression',
+            FunctionExpression::class => '_transformFunctionExpression',
+            TupleComparison::class => '_transformTupleComparison',
+        ];
+    }
+
+    /**
+     * Receives a StringAggExpression and changes it so that it conforms to this
+     * SQL dialect.
+     *
+     * @param \Cake\Database\Expression\StringAggExpression $expression The expression to convert to TSQL.
+     * @return void
+     */
+    protected function _transformStringAggExpression(StringAggExpression $expression): void
+    {
+        $expression
+            ->setName('STRING_AGG')
+            ->setSyntax(StringAggExpression::SYNTAX_WITHIN_GROUP);
+    }
+
+    /**
+     * Receives a FunctionExpression and changes it so that it conforms to this
+     * SQL dialect.
+     *
+     * @param \Cake\Database\Expression\FunctionExpression $expression The function expression to convert to TSQL.
+     * @return void
+     */
+    protected function _transformFunctionExpression(FunctionExpression $expression): void
+    {
+        switch ($expression->getName()) {
+            case 'CONCAT':
+                // CONCAT function is expressed as exp1 + exp2
+                $expression->setName('')->setConjunction(' +');
+                break;
+            case 'DATEDIFF':
+                $hasDay = false;
+                $visitor = function ($value) use (&$hasDay) {
+                    if ($value === 'day') {
+                        $hasDay = true;
+                    }
+
+                    return $value;
+                };
+                $expression->iterateParts($visitor);
+
+                if (!$hasDay) {
+                    $expression->add(['day' => 'literal'], [], true);
+                }
+                break;
+            case 'CURRENT_DATE':
+                $time = new FunctionExpression('GETUTCDATE');
+                $expression->setName('CONVERT')->add(['date' => 'literal', $time]);
+                break;
+            case 'CURRENT_TIME':
+                $time = new FunctionExpression('GETUTCDATE');
+                $expression->setName('CONVERT')->add(['time' => 'literal', $time]);
+                break;
+            case 'NOW':
+                $expression->setName('GETUTCDATE');
+                break;
+            case 'EXTRACT':
+                $expression->setName('DATEPART')->setConjunction(' ,');
+                break;
+            case 'DATE_ADD':
+                $params = [];
+                $visitor = function ($p, $key) use (&$params) {
+                    if ($key === 0) {
+                        $params[2] = $p;
+                    } else {
+                        $valueUnit = explode(' ', $p);
+                        $params[0] = rtrim($valueUnit[1], 's');
+                        $params[1] = $valueUnit[0];
+                    }
+
+                    return $p;
+                };
+                $manipulator = function ($p, $key) use (&$params) {
+                    return $params[$key];
+                };
+
+                $expression
+                    ->setName('DATEADD')
+                    ->setConjunction(',')
+                    ->iterateParts($visitor)
+                    ->iterateParts($manipulator)
+                    ->add([$params[2] => 'literal']);
+                break;
+            case 'DAYOFWEEK':
+                $expression
+                    ->setName('DATEPART')
+                    ->setConjunction(' ')
+                    ->add(['weekday, ' => 'literal'], [], true);
+                break;
+            case 'SUBSTR':
+                $expression->setName('SUBSTRING');
+                if (count($expression) < 4) {
+                    $params = [];
+                    $expression
+                        ->iterateParts(function ($p) use (&$params) {
+                            return $params[] = $p;
+                        })
+                        ->add([new FunctionExpression('LEN', [$params[0]]), ['string']]);
+                }
+
+                break;
+        }
     }
 }
diff --git a/src/Database/Driver/TupleComparisonTranslatorTrait.php b/src/Database/Driver/TupleComparisonTranslatorTrait.php
new file mode 100644
index 00000000000..be091ece2c2
--- /dev/null
+++ b/src/Database/Driver/TupleComparisonTranslatorTrait.php
@@ -0,0 +1,116 @@
+getField();
+
+        if (!is_array($fields)) {
+            return;
+        }
+
+        $operator = strtoupper($expression->getOperator());
+        if (!in_array($operator, ['IN', '='], true)) {
+            throw new InvalidArgumentException(
+                sprintf(
+                    'Tuple comparison transform only supports the `IN` and `=` operators, `%s` given.',
+                    $operator,
+                ),
+            );
+        }
+
+        $value = $expression->getValue();
+        $true = new QueryExpression('1');
+
+        if ($value instanceof SelectQuery) {
+            /** @var array $selected */
+            $selected = array_values($value->clause('select'));
+            foreach ($fields as $i => $field) {
+                $value->andWhere([$field => new IdentifierExpression($selected[$i])]);
+            }
+            $value->select($true, true);
+            $expression->setField($true);
+            $expression->setOperator('=');
+
+            return;
+        }
+
+        $type = $expression->getType();
+        if ($type) {
+            /** @var array $typeMap */
+            $typeMap = array_combine($fields, $type) ?: [];
+        } else {
+            $typeMap = [];
+        }
+
+        $surrogate = $query->getConnection()
+            ->selectQuery()
+            ->select($true);
+
+        if (!is_array(current($value))) {
+            $value = [$value];
+        }
+
+        $conditions = ['OR' => []];
+        foreach ($value as $tuple) {
+            $item = [];
+            foreach (array_values($tuple) as $i => $value2) {
+                $item[] = [$fields[$i] => $value2];
+            }
+            $conditions['OR'][] = $item;
+        }
+        $surrogate->where($conditions, $typeMap);
+
+        $expression->setField($true);
+        $expression->setValue($surrogate);
+        $expression->setOperator('=');
+    }
+}
diff --git a/src/Database/DriverFeatureEnum.php b/src/Database/DriverFeatureEnum.php
new file mode 100644
index 00000000000..e329e6e4bac
--- /dev/null
+++ b/src/Database/DriverFeatureEnum.php
@@ -0,0 +1,95 @@
+, etc)
+     */
+    case OPTIMIZER_HINT_COMMENT = 'optimizer-hint-comment';
+
+    /**
+     * Support for CHECK constraints.
+     */
+    case CHECK_CONSTRAINTS = 'check-constraints';
+
+    /**
+     * String aggregation via STRING_AGG support.
+     */
+    case STRING_AGG = 'string-agg';
+
+    /**
+     * String aggregation via GROUP_CONCAT support.
+     */
+    case GROUP_CONCAT = 'group-concat';
+}
diff --git a/src/Database/Exception.php b/src/Database/Exception.php
deleted file mode 100644
index c61d87d7537..00000000000
--- a/src/Database/Exception.php
+++ /dev/null
@@ -1,25 +0,0 @@
-getMessage();
+
+        // Prefix with connection name if available
+        $connectionName = $this->getConnectionName();
+        if ($connectionName !== '') {
+            $message = "[{$connectionName}] " . $message;
+        }
+
+        $message .= "\nQuery: " . $this->getQueryString();
+
+        parent::__construct($message, (int)$previous->getCode(), $previous);
+    }
+
+    /**
+     * Get the connection name that caused this exception.
+     *
+     * @return string
+     */
+    public function getConnectionName(): string
+    {
+        if ($this->query instanceof LoggedQuery) {
+            return $this->query->getConnectionName();
+        }
+
+        return '';
+    }
+
+    /**
+     * Get the query string that caused this exception.
+     *
+     * @return string
+     */
+    public function getQueryString(): string
+    {
+        if ($this->query instanceof LoggedQuery) {
+            return (string)$this->query;
+        }
+
+        return $this->query;
+    }
+}
diff --git a/src/Database/Expression/AggregateExpression.php b/src/Database/Expression/AggregateExpression.php
new file mode 100644
index 00000000000..582229d2025
--- /dev/null
+++ b/src/Database/Expression/AggregateExpression.php
@@ -0,0 +1,263 @@
+ $types Associative array of type names used to bind values to query
+     * @return $this
+     * @see \Cake\Database\Query::where()
+     */
+    public function filter(ExpressionInterface|Closure|array|string $conditions, array $types = [])
+    {
+        $this->filter ??= new QueryExpression();
+
+        if ($conditions instanceof Closure) {
+            $conditions = $conditions(new QueryExpression());
+        }
+
+        $this->filter->add($conditions, $types);
+
+        return $this;
+    }
+
+    /**
+     * Adds an empty `OVER()` window expression or a named window expression.
+     *
+     * @param string|null $name Window name
+     * @return $this
+     */
+    public function over(?string $name = null)
+    {
+        $window = $this->getWindow();
+        if ($name) {
+            // Set name manually in case this was chained from FunctionsBuilder wrapper
+            $window->name($name);
+        }
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function partition(ExpressionInterface|Closure|array|string $partitions)
+    {
+        $this->getWindow()->partition($partitions);
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function order(ExpressionInterface|Closure|array|string $fields)
+    {
+        deprecationWarning(
+            '5.0.0',
+            'AggregateExpression::order() is deprecated. Use AggregateExpression::orderBy() instead.',
+        );
+
+        return $this->orderBy($fields);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function orderBy(ExpressionInterface|Closure|array|string $fields)
+    {
+        $this->getWindow()->orderBy($fields);
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function range(ExpressionInterface|string|int|null $start, ExpressionInterface|string|int|null $end = 0)
+    {
+        $this->getWindow()->range($start, $end);
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function rows(?int $start, ?int $end = 0)
+    {
+        $this->getWindow()->rows($start, $end);
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function groups(?int $start, ?int $end = 0)
+    {
+        $this->getWindow()->groups($start, $end);
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function frame(
+        string $type,
+        ExpressionInterface|string|int|null $startOffset,
+        string $startDirection,
+        ExpressionInterface|string|int|null $endOffset,
+        string $endDirection,
+    ) {
+        $this->getWindow()->frame($type, $startOffset, $startDirection, $endOffset, $endDirection);
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function excludeCurrent()
+    {
+        $this->getWindow()->excludeCurrent();
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function excludeGroup()
+    {
+        $this->getWindow()->excludeGroup();
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function excludeTies()
+    {
+        $this->getWindow()->excludeTies();
+
+        return $this;
+    }
+
+    /**
+     * Returns or creates WindowExpression for function.
+     *
+     * @return \Cake\Database\Expression\WindowExpression
+     */
+    protected function getWindow(): WindowExpression
+    {
+        return $this->window ??= new WindowExpression();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function sql(ValueBinder $binder): string
+    {
+        $sql = parent::sql($binder);
+        if ($this->filter !== null) {
+            $sql .= ' FILTER (WHERE ' . $this->filter->sql($binder) . ')';
+        }
+        if ($this->window !== null) {
+            if ($this->window->isNamedOnly()) {
+                $sql .= ' OVER ' . $this->window->sql($binder);
+            } else {
+                $sql .= ' OVER (' . $this->window->sql($binder) . ')';
+            }
+        }
+
+        return $sql;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function traverse(Closure $callback)
+    {
+        parent::traverse($callback);
+        if ($this->filter !== null) {
+            $callback($this->filter);
+            $this->filter->traverse($callback);
+        }
+        if ($this->window !== null) {
+            $callback($this->window);
+            $this->window->traverse($callback);
+        }
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function count(): int
+    {
+        $count = parent::count();
+        if ($this->window !== null) {
+            $count += 1;
+        }
+
+        return $count;
+    }
+
+    /**
+     * Clone this object and its subtree of expressions.
+     */
+    public function __clone()
+    {
+        parent::__clone();
+        if ($this->filter !== null) {
+            $this->filter = clone $this->filter;
+        }
+        if ($this->window !== null) {
+            $this->window = clone $this->window;
+        }
+    }
+}
diff --git a/src/Database/Expression/BetweenExpression.php b/src/Database/Expression/BetweenExpression.php
index b9e91f37c78..db66959c835 100644
--- a/src/Database/Expression/BetweenExpression.php
+++ b/src/Database/Expression/BetweenExpression.php
@@ -1,4 +1,6 @@
 _castToExpression($from, $type);
             $to = $this->_castToExpression($to, $type);
@@ -67,70 +82,70 @@ public function __construct($field, $from, $to, $type = null)
         $this->_from = $from;
         $this->_to = $to;
         $this->_type = $type;
+        $this->_not = $not;
     }
 
     /**
-     * Converts the expression to its string representation
-     *
-     * @param \Cake\Database\ValueBinder $generator Placeholder generator object
-     * @return string
+     * @inheritDoc
      */
-    public function sql(ValueBinder $generator)
+    public function sql(ValueBinder $binder): string
     {
         $parts = [
             'from' => $this->_from,
-            'to' => $this->_to
+            'to' => $this->_to,
         ];
 
         $field = $this->_field;
         if ($field instanceof ExpressionInterface) {
-            $field = $field->sql($generator);
+            $field = $field->sql($binder);
         }
 
         foreach ($parts as $name => $part) {
             if ($part instanceof ExpressionInterface) {
-                $parts[$name] = $part->sql($generator);
+                $parts[$name] = $part->sql($binder);
                 continue;
             }
-            $parts[$name] = $this->_bindValue($part, $generator, $this->_type);
+            $parts[$name] = $this->_bindValue($part, $binder, $this->_type);
         }
+        assert(is_string($field));
+
+        $operator = $this->_not ? 'NOT BETWEEN' : 'BETWEEN';
 
-        return sprintf('%s BETWEEN %s AND %s', $field, $parts['from'], $parts['to']);
+        return sprintf('%s %s %s AND %s', $field, $operator, $parts['from'], $parts['to']);
     }
 
     /**
-     * {@inheritDoc}
-     *
+     * @inheritDoc
      */
-    public function traverse(callable $callable)
+    public function traverse(Closure $callback)
     {
         foreach ([$this->_field, $this->_from, $this->_to] as $part) {
             if ($part instanceof ExpressionInterface) {
-                $callable($part);
+                $callback($part);
             }
         }
+
+        return $this;
     }
 
     /**
      * Registers a value in the placeholder generator and returns the generated placeholder
      *
      * @param mixed $value The value to bind
-     * @param \Cake\Database\ValueBinder $generator The value binder to use
-     * @param string $type The type of $value
+     * @param \Cake\Database\ValueBinder $binder The value binder to use
+     * @param string|null $type The type of $value
      * @return string generated placeholder
      */
-    protected function _bindValue($value, $generator, $type)
+    protected function _bindValue(mixed $value, ValueBinder $binder, ?string $type): string
     {
-        $placeholder = $generator->placeholder('c');
-        $generator->bind($placeholder, $value, $type);
+        $placeholder = $binder->placeholder('c');
+        $binder->bind($placeholder, $value, $type);
 
         return $placeholder;
     }
 
     /**
      * Do a deep clone of this expression.
-     *
-     * @return void
      */
     public function __clone()
     {
diff --git a/src/Database/Expression/CaseExpression.php b/src/Database/Expression/CaseExpression.php
deleted file mode 100644
index b826c183e1a..00000000000
--- a/src/Database/Expression/CaseExpression.php
+++ /dev/null
@@ -1,249 +0,0 @@
- :value"
-     *
-     * @var array
-     */
-    protected $_conditions = [];
-
-    /**
-     * Values that are associated with the conditions in the $_conditions array.
-     * Each value represents the 'true' value for the condition with the corresponding key.
-     *
-     * @var array
-     */
-    protected $_values = [];
-
-    /**
-     * The `ELSE` value for the case statement. If null then no `ELSE` will be included.
-     *
-     * @var string|\Cake\Database\ExpressionInterface|array|null
-     */
-    protected $_elseValue;
-
-    /**
-     * Constructs the case expression
-     *
-     * @param array|\Cake\Database\ExpressionInterface $conditions The conditions to test. Must be a ExpressionInterface
-     * instance, or an array of ExpressionInterface instances.
-     * @param array|\Cake\Database\ExpressionInterface $values associative array of values to be associated with the conditions
-     * passed in $conditions. If there are more $values than $conditions, the last $value is used as the `ELSE` value
-     * @param array $types associative array of types to be associated with the values
-     * passed in $values
-     */
-    public function __construct($conditions = [], $values = [], $types = [])
-    {
-        if (!empty($conditions)) {
-            $this->add($conditions, $values, $types);
-        }
-
-        if (is_array($conditions) && is_array($values) && count($values) > count($conditions)) {
-            end($values);
-            $key = key($values);
-            $this->elseValue($values[$key], isset($types[$key]) ? $types[$key] : null);
-        }
-    }
-
-    /**
-     * Adds one or more conditions and their respective true values to the case object.
-     * Conditions must be a one dimensional array or a QueryExpression.
-     * The trueValues must be a similar structure, but may contain a string value.
-     *
-     * @param array|\Cake\Database\ExpressionInterface $conditions Must be a ExpressionInterface instance, or an array of ExpressionInterface instances.
-     * @param array|\Cake\Database\ExpressionInterface $values associative array of values of each condition
-     * @param array $types associative array of types to be associated with the values
-     *
-     * @return $this
-     */
-    public function add($conditions = [], $values = [], $types = [])
-    {
-        if (!is_array($conditions)) {
-            $conditions = [$conditions];
-        }
-        if (!is_array($values)) {
-            $values = [$values];
-        }
-        if (!is_array($types)) {
-            $types = [$types];
-        }
-
-        $this->_addExpressions($conditions, $values, $types);
-
-        return $this;
-    }
-
-    /**
-     * Iterates over the passed in conditions and ensures that there is a matching true value for each.
-     * If no matching true value, then it is defaulted to '1'.
-     *
-     * @param array|\Cake\Database\ExpressionInterface $conditions Must be a ExpressionInterface instance, or an array of ExpressionInterface instances.
-     * @param array|\Cake\Database\ExpressionInterface $values associative array of values of each condition
-     * @param array $types associative array of types to be associated with the values
-     *
-     * @return void
-     */
-    protected function _addExpressions($conditions, $values, $types)
-    {
-        $rawValues = array_values($values);
-        $keyValues = array_keys($values);
-
-        foreach ($conditions as $k => $c) {
-            $numericKey = is_numeric($k);
-
-            if ($numericKey && empty($c)) {
-                continue;
-            }
-
-            if (!$c instanceof ExpressionInterface) {
-                continue;
-            }
-
-            $this->_conditions[] = $c;
-            $value = isset($rawValues[$k]) ? $rawValues[$k] : 1;
-
-            if ($value === 'literal') {
-                $value = $keyValues[$k];
-                $this->_values[] = $value;
-                continue;
-            }
-
-            if ($value === 'identifier') {
-                $value = new IdentifierExpression($keyValues[$k]);
-                $this->_values[] = $value;
-                continue;
-            }
-
-            $type = isset($types[$k]) ? $types[$k] : null;
-
-            if ($type !== null && !$value instanceof ExpressionInterface) {
-                $value = $this->_castToExpression($value, $type);
-            }
-
-            if ($value instanceof ExpressionInterface) {
-                $this->_values[] = $value;
-                continue;
-            }
-
-            $this->_values[] = ['value' => $value, 'type' => $type];
-        }
-    }
-
-    /**
-     * Sets the default value
-     *
-     * @param \Cake\Database\ExpressionInterface|string|array|null $value Value to set
-     * @param string|null $type Type of value
-     *
-     * @return void
-     */
-    public function elseValue($value = null, $type = null)
-    {
-        if (is_array($value)) {
-            end($value);
-            $value = key($value);
-        }
-
-        if ($value !== null && !$value instanceof ExpressionInterface) {
-            $value = $this->_castToExpression($value, $type);
-        }
-
-        if (!$value instanceof ExpressionInterface) {
-            $value = ['value' => $value, 'type' => $type];
-        }
-
-        $this->_elseValue = $value;
-    }
-
-    /**
-     * Compiles the relevant parts into sql
-     *
-     * @param array|string|\Cake\Database\ExpressionInterface $part The part to compile
-     * @param \Cake\Database\ValueBinder $generator Sql generator
-     *
-     * @return string
-     */
-    protected function _compile($part, ValueBinder $generator)
-    {
-        if ($part instanceof ExpressionInterface) {
-            $part = $part->sql($generator);
-        } elseif (is_array($part)) {
-            $placeholder = $generator->placeholder('param');
-            $generator->bind($placeholder, $part['value'], $part['type']);
-            $part = $placeholder;
-        }
-
-        return $part;
-    }
-
-    /**
-     * Converts the Node into a SQL string fragment.
-     *
-     * @param \Cake\Database\ValueBinder $generator Placeholder generator object
-     *
-     * @return string
-     */
-    public function sql(ValueBinder $generator)
-    {
-        $parts = [];
-        $parts[] = 'CASE';
-        foreach ($this->_conditions as $k => $part) {
-            $value = $this->_values[$k];
-            $parts[] = 'WHEN ' . $this->_compile($part, $generator) . ' THEN ' . $this->_compile($value, $generator);
-        }
-        if ($this->_elseValue !== null) {
-            $parts[] = 'ELSE';
-            $parts[] = $this->_compile($this->_elseValue, $generator);
-        }
-        $parts[] = 'END';
-
-        return implode(' ', $parts);
-    }
-
-    /**
-     * {@inheritDoc}
-     *
-     */
-    public function traverse(callable $visitor)
-    {
-        foreach (['_conditions', '_values'] as $part) {
-            foreach ($this->{$part} as $c) {
-                if ($c instanceof ExpressionInterface) {
-                    $visitor($c);
-                    $c->traverse($visitor);
-                }
-            }
-        }
-        if ($this->_elseValue instanceof ExpressionInterface) {
-            $visitor($this->_elseValue);
-            $this->_elseValue->traverse($visitor);
-        }
-    }
-}
diff --git a/src/Database/Expression/CaseExpressionTrait.php b/src/Database/Expression/CaseExpressionTrait.php
new file mode 100644
index 00000000000..a2c2425bfae
--- /dev/null
+++ b/src/Database/Expression/CaseExpressionTrait.php
@@ -0,0 +1,103 @@
+_typeMap !== null &&
+            $value instanceof IdentifierExpression
+        ) {
+            $type = $this->_typeMap->type($value->getIdentifier());
+        } elseif ($value instanceof TypedResultInterface) {
+            $type = $value->getReturnType();
+        }
+
+        return $type;
+    }
+
+    /**
+     * Compiles a nullable value to SQL.
+     *
+     * @param \Cake\Database\ValueBinder $binder The value binder to use.
+     * @param \Cake\Database\ExpressionInterface|object|scalar|null $value The value to compile.
+     * @param string|null $type The value type.
+     * @return string
+     */
+    protected function compileNullableValue(ValueBinder $binder, mixed $value, ?string $type = null): string
+    {
+        if (
+            $type !== null &&
+            !($value instanceof ExpressionInterface)
+        ) {
+            $value = $this->_castToExpression($value, $type);
+        }
+
+        if ($value === null) {
+            $value = 'NULL';
+        } elseif ($value instanceof Query) {
+            $value = sprintf('(%s)', $value->sql($binder));
+        } elseif ($value instanceof ExpressionInterface) {
+            $value = $value->sql($binder);
+        } else {
+            $placeholder = $binder->placeholder('c');
+            $binder->bind($placeholder, $value, $type);
+            $value = $placeholder;
+        }
+
+        return $value;
+    }
+}
diff --git a/src/Database/Expression/CaseStatementExpression.php b/src/Database/Expression/CaseStatementExpression.php
new file mode 100644
index 00000000000..bf657b80981
--- /dev/null
+++ b/src/Database/Expression/CaseStatementExpression.php
@@ -0,0 +1,592 @@
+
+     */
+    protected array $validClauseNames = [
+        'value',
+        'when',
+        'else',
+    ];
+
+    /**
+     * Whether this is a simple case expression.
+     *
+     * @var bool
+     */
+    protected bool $isSimpleVariant = false;
+
+    /**
+     * The case value.
+     *
+     * @var \Cake\Database\ExpressionInterface|object|scalar|null
+     */
+    protected mixed $value = null;
+
+    /**
+     * The case value type.
+     *
+     * @var string|null
+     */
+    protected ?string $valueType = null;
+
+    /**
+     * The `WHEN ... THEN ...` expressions.
+     *
+     * @var array<\Cake\Database\Expression\WhenThenExpression>
+     */
+    protected array $when = [];
+
+    /**
+     * Buffer that holds values and types for use with `then()`.
+     *
+     * @var array|null
+     */
+    protected ?array $whenBuffer = null;
+
+    /**
+     * The else part result value.
+     *
+     * @var \Cake\Database\ExpressionInterface|object|scalar|null
+     */
+    protected mixed $else = null;
+
+    /**
+     * The else part result type.
+     *
+     * @var string|null
+     */
+    protected ?string $elseType = null;
+
+    /**
+     * The return type.
+     *
+     * @var string|null
+     */
+    protected ?string $returnType = null;
+
+    /**
+     * Constructor.
+     *
+     * When a value is set, the syntax generated is
+     * `CASE case_value WHEN when_value ... END` (simple case),
+     * where the `when_value`'s are compared against the
+     * `case_value`.
+     *
+     * When no value is set, the syntax generated is
+     * `CASE WHEN when_conditions ... END` (searched case),
+     * where the conditions hold the comparisons.
+     *
+     * Note that `null` is a valid case value, and thus should
+     * only be passed if you actually want to create the simple
+     * case expression variant!
+     *
+     * @param \Cake\Database\ExpressionInterface|object|scalar|null $value The case value.
+     * @param string|null $type The case value type. If no type is provided, the type will be tried to be inferred
+     *  from the value.
+     */
+    public function __construct(mixed $value = null, ?string $type = null)
+    {
+        if (func_num_args() > 0) {
+            if (
+                $value !== null &&
+                !is_scalar($value) &&
+                !(is_object($value) && !($value instanceof Closure))
+            ) {
+                throw new InvalidArgumentException(sprintf(
+                    'The `$value` argument must be either `null`, a scalar value, an object, ' .
+                    'or an instance of `\%s`, `%s` given.',
+                    ExpressionInterface::class,
+                    get_debug_type($value),
+                ));
+            }
+
+            $this->value = $value;
+
+            if (
+                $value !== null &&
+                $type === null &&
+                !($value instanceof ExpressionInterface)
+            ) {
+                $type = $this->inferType($value);
+            }
+            $this->valueType = $type;
+
+            $this->isSimpleVariant = true;
+        }
+    }
+
+    /**
+     * Sets the `WHEN` value for a `WHEN ... THEN ...` expression, or a
+     * self-contained expression that holds both the value for `WHEN`
+     * and the value for `THEN`.
+     *
+     * ### Order based syntax
+     *
+     * When passing a value other than a self-contained
+     * `\Cake\Database\Expression\WhenThenExpression`,
+     * instance, the `WHEN ... THEN ...` statement must be closed off with
+     * a call to `then()` before invoking `when()` again or `else()`:
+     *
+     * ```
+     * $queryExpression
+     *     ->case($query->identifier('Table.column'))
+     *     ->when(true)
+     *     ->then('Yes')
+     *     ->when(false)
+     *     ->then('No')
+     *     ->else('Maybe');
+     * ```
+     *
+     * ### Self-contained expressions
+     *
+     * When passing an instance of `\Cake\Database\Expression\WhenThenExpression`,
+     * being it directly, or via a callable, then there is no need to close
+     * using `then()` on this object, instead the statement will be closed
+     * on the `\Cake\Database\Expression\WhenThenExpression`
+     * object using
+     * `\Cake\Database\Expression\WhenThenExpression::then()`.
+     *
+     * Callables will receive an instance of `\Cake\Database\Expression\WhenThenExpression`,
+     * and must return one, being it the same object, or a custom one:
+     *
+     * ```
+     * $queryExpression
+     *     ->case()
+     *     ->when(function (\Cake\Database\Expression\WhenThenExpression $whenThen) {
+     *         return $whenThen
+     *             ->when(['Table.column' => true])
+     *             ->then('Yes');
+     *     })
+     *     ->when(function (\Cake\Database\Expression\WhenThenExpression $whenThen) {
+     *         return $whenThen
+     *             ->when(['Table.column' => false])
+     *             ->then('No');
+     *     })
+     *     ->else('Maybe');
+     * ```
+     *
+     * ### Type handling
+     *
+     * The types provided via the `$type` argument will be merged with the
+     * type map set for this expression. When using callables for `$when`,
+     * the `\Cake\Database\Expression\WhenThenExpression`
+     * instance received by the callables will inherit that type map, however
+     * the types passed here will _not_ be merged in case of using callables,
+     * instead the types must be passed in
+     * `\Cake\Database\Expression\WhenThenExpression::when()`:
+     *
+     * ```
+     * $queryExpression
+     *     ->case()
+     *     ->when(function (\Cake\Database\Expression\WhenThenExpression $whenThen) {
+     *         return $whenThen
+     *             ->when(['unmapped_column' => true], ['unmapped_column' => 'bool'])
+     *             ->then('Yes');
+     *     })
+     *     ->when(function (\Cake\Database\Expression\WhenThenExpression $whenThen) {
+     *         return $whenThen
+     *             ->when(['unmapped_column' => false], ['unmapped_column' => 'bool'])
+     *             ->then('No');
+     *     })
+     *     ->else('Maybe');
+     * ```
+     *
+     * ### User data safety
+     *
+     * When passing user data, be aware that allowing a user defined array
+     * to be passed, is a potential SQL injection vulnerability, as it
+     * allows for raw SQL to slip in!
+     *
+     * The following is _unsafe_ usage that must be avoided:
+     *
+     * ```
+     * $case
+     *      ->when($userData)
+     * ```
+     *
+     * A safe variant for the above would be to define a single type for
+     * the value:
+     *
+     * ```
+     * $case
+     *      ->when($userData, 'integer')
+     * ```
+     *
+     * This way an exception would be triggered when an array is passed for
+     * the value, thus preventing raw SQL from slipping in, and all other
+     * types of values would be forced to be bound as an integer.
+     *
+     * Another way to safely pass user data is when using a conditions
+     * array, and passing user data only on the value side of the array
+     * entries, which will cause them to be bound:
+     *
+     * ```
+     * $case
+     *      ->when([
+     *          'Table.column' => $userData,
+     *      ])
+     * ```
+     *
+     * Lastly, data can also be bound manually:
+     *
+     * ```
+     * $query
+     *      ->select([
+     *          'val' => $query->expr()
+     *              ->case()
+     *              ->when($query->expr(':userData'))
+     *              ->then(123)
+     *      ])
+     *      ->bind(':userData', $userData, 'integer')
+     * ```
+     *
+     * @param \Cake\Database\ExpressionInterface|\Closure|object|array|scalar $when The `WHEN` value. When using an
+     *  array of conditions, it must be compatible with `\Cake\Database\Query::where()`. Note that this argument is
+     *  _not_ completely safe for use with user data, as a user supplied array would allow for raw SQL to slip in! If
+     *  you plan to use user data, either pass a single type for the `$type` argument (which forces the `$when` value to
+     *  be a non-array, and then always binds the data), use a conditions array where the user data is only passed on
+     *  the value side of the array entries, or custom bindings!
+     * @param array|string|null $type The when value type. Either an associative array when using array style
+     *  conditions, or else a string. If no type is provided, the type will be tried to be inferred from the value.
+     * @return $this
+     * @throws \LogicException In case this a closing `then()` call is required before calling this method.
+     * @throws \LogicException In case the callable doesn't return an instance of
+     *  `\Cake\Database\Expression\WhenThenExpression`.
+     */
+    public function when(mixed $when, array|string|null $type = null)
+    {
+        if ($this->whenBuffer !== null) {
+            throw new LogicException('Cannot call `when()` between `when()` and `then()`.');
+        }
+
+        if ($when instanceof Closure) {
+            $when = $when(new WhenThenExpression($this->getTypeMap()));
+            if (!($when instanceof WhenThenExpression)) {
+                throw new LogicException(sprintf(
+                    '`when()` callables must return an instance of `\%s`, `%s` given.',
+                    WhenThenExpression::class,
+                    get_debug_type($when),
+                ));
+            }
+        }
+
+        if ($when instanceof WhenThenExpression) {
+            $this->when[] = $when;
+        } else {
+            $this->whenBuffer = ['when' => $when, 'type' => $type];
+        }
+
+        return $this;
+    }
+
+    /**
+     * Sets the `THEN` result value for the last `WHEN ... THEN ...`
+     * statement that was opened using `when()`.
+     *
+     * ### Order based syntax
+     *
+     * This method can only be invoked in case `when()` was previously
+     * used with a value other than a closure or an instance of
+     * `\Cake\Database\Expression\WhenThenExpression`:
+     *
+     * ```
+     * $case
+     *     ->when(['Table.column' => true])
+     *     ->then('Yes')
+     *     ->when(['Table.column' => false])
+     *     ->then('No')
+     *     ->else('Maybe');
+     * ```
+     *
+     * The following would all fail with an exception:
+     *
+     * ```
+     * $case
+     *     ->when(['Table.column' => true])
+     *     ->when(['Table.column' => false])
+     *     // ...
+     * ```
+     *
+     * ```
+     * $case
+     *     ->when(['Table.column' => true])
+     *     ->else('Maybe')
+     *     // ...
+     * ```
+     *
+     * ```
+     * $case
+     *     ->then('Yes')
+     *     // ...
+     * ```
+     *
+     * ```
+     * $case
+     *     ->when(['Table.column' => true])
+     *     ->then('Yes')
+     *     ->then('No')
+     *     // ...
+     * ```
+     *
+     * @param \Cake\Database\ExpressionInterface|object|scalar|null $result The result value.
+     * @param string|null $type The result type. If no type is provided, the type will be tried to be inferred from the
+     *  value.
+     * @return $this
+     * @throws \LogicException In case `when()` wasn't previously called with a value other than a closure or an
+     *  instance of `\Cake\Database\Expression\WhenThenExpression`.
+     */
+    public function then(mixed $result, ?string $type = null)
+    {
+        if ($this->whenBuffer === null) {
+            throw new LogicException('Cannot call `then()` before `when()`.');
+        }
+
+        $whenThen = (new WhenThenExpression($this->getTypeMap()))
+            ->when($this->whenBuffer['when'], $this->whenBuffer['type'])
+            ->then($result, $type);
+
+        $this->whenBuffer = null;
+
+        $this->when[] = $whenThen;
+
+        return $this;
+    }
+
+    /**
+     * Sets the `ELSE` result value.
+     *
+     * @param \Cake\Database\ExpressionInterface|object|scalar|null $result The result value.
+     * @param string|null $type The result type. If no type is provided, the type will be tried to be inferred from the
+     *  value.
+     * @return $this
+     * @throws \LogicException In case a closing `then()` call is required before calling this method.
+     * @throws \InvalidArgumentException In case the `$result` argument is neither a scalar value, nor an object, an
+     *  instance of `\Cake\Database\ExpressionInterface`, or `null`.
+     */
+    public function else(mixed $result, ?string $type = null)
+    {
+        if ($this->whenBuffer !== null) {
+            throw new LogicException('Cannot call `else()` between `when()` and `then()`.');
+        }
+
+        if (
+            $result !== null &&
+            !is_scalar($result) &&
+            !(is_object($result) && !($result instanceof Closure))
+        ) {
+            throw new InvalidArgumentException(sprintf(
+                'The `$result` argument must be either `null`, a scalar value, an object, ' .
+                'or an instance of `\%s`, `%s` given.',
+                ExpressionInterface::class,
+                get_debug_type($result),
+            ));
+        }
+
+        $type ??= $this->inferType($result);
+
+        $this->else = $result;
+        $this->elseType = $type;
+
+        return $this;
+    }
+
+    /**
+     * Returns the abstract type that this expression will return.
+     *
+     * If no type has been explicitly set via `setReturnType()`, this
+     * method will try to obtain the type from the result types of the
+     * `then()` and `else() `calls. All types must be identical in order
+     * for this to work, otherwise the type will default to `string`.
+     *
+     * @return string
+     * @see CaseStatementExpression::then()
+     */
+    public function getReturnType(): string
+    {
+        if ($this->returnType !== null) {
+            return $this->returnType;
+        }
+
+        $types = [];
+        foreach ($this->when as $when) {
+            $type = $when->getResultType();
+            if ($type !== null) {
+                $types[] = $type;
+            }
+        }
+
+        if ($this->elseType !== null) {
+            $types[] = $this->elseType;
+        }
+
+        $types = array_unique($types);
+        if (count($types) === 1) {
+            return $types[0];
+        }
+
+        return 'string';
+    }
+
+    /**
+     * Sets the abstract type that this expression will return.
+     *
+     * If no type is being explicitly set via this method, then the
+     * `getReturnType()` method will try to infer the type from the
+     * result types of the `then()` and `else() `calls.
+     *
+     * @param string $type The type name to use.
+     * @return $this
+     */
+    public function setReturnType(string $type)
+    {
+        $this->returnType = $type;
+
+        return $this;
+    }
+
+    /**
+     * Returns the available data for the given clause.
+     *
+     * ### Available clauses
+     *
+     * The following clause names are available:
+     *
+     * * `value`: The case value for a `CASE case_value WHEN ...` expression.
+     * * `when`: An array of `WHEN ... THEN ...` expressions.
+     * * `else`: The `ELSE` result value.
+     *
+     * @param string $clause The name of the clause to obtain.
+     * @return \Cake\Database\ExpressionInterface|object|array<\Cake\Database\Expression\WhenThenExpression>|scalar|null
+     * @throws \InvalidArgumentException In case the given clause name is invalid.
+     */
+    public function clause(string $clause): mixed
+    {
+        if (!in_array($clause, $this->validClauseNames, true)) {
+            throw new InvalidArgumentException(
+                sprintf(
+                    'The `$clause` argument must be one of `%s`, the given value `%s` is invalid.',
+                    implode('`, `', $this->validClauseNames),
+                    $clause,
+                ),
+            );
+        }
+
+        return $this->{$clause};
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function sql(ValueBinder $binder): string
+    {
+        if ($this->whenBuffer !== null) {
+            throw new LogicException('Case expression has incomplete when clause. Missing `then()` after `when()`.');
+        }
+
+        if (!$this->when) {
+            throw new LogicException('Case expression must have at least one when statement.');
+        }
+
+        $value = '';
+        if ($this->isSimpleVariant) {
+            $value = $this->compileNullableValue($binder, $this->value, $this->valueType) . ' ';
+        }
+
+        $whenThenExpressions = [];
+        foreach ($this->when as $whenThen) {
+            $whenThenExpressions[] = $whenThen->sql($binder);
+        }
+        $whenThen = implode(' ', $whenThenExpressions);
+
+        $else = $this->compileNullableValue($binder, $this->else, $this->elseType);
+
+        return "CASE {$value}{$whenThen} ELSE {$else} END";
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function traverse(Closure $callback)
+    {
+        if ($this->whenBuffer !== null) {
+            throw new LogicException('Case expression has incomplete when clause. Missing `then()` after `when()`.');
+        }
+
+        if ($this->value instanceof ExpressionInterface) {
+            $callback($this->value);
+            $this->value->traverse($callback);
+        }
+
+        foreach ($this->when as $when) {
+            $callback($when);
+            $when->traverse($callback);
+        }
+
+        if ($this->else instanceof ExpressionInterface) {
+            $callback($this->else);
+            $this->else->traverse($callback);
+        }
+
+        return $this;
+    }
+
+    /**
+     * Clones the inner expression objects.
+     */
+    public function __clone()
+    {
+        if ($this->whenBuffer !== null) {
+            throw new LogicException('Case expression has incomplete when clause. Missing `then()` after `when()`.');
+        }
+
+        if ($this->value instanceof ExpressionInterface) {
+            $this->value = clone $this->value;
+        }
+
+        foreach ($this->when as $key => $when) {
+            $this->when[$key] = clone $this->when[$key];
+        }
+
+        if ($this->else instanceof ExpressionInterface) {
+            $this->else = clone $this->else;
+        }
+    }
+}
diff --git a/src/Database/Expression/CommonTableExpression.php b/src/Database/Expression/CommonTableExpression.php
new file mode 100644
index 00000000000..bc63b312bbb
--- /dev/null
+++ b/src/Database/Expression/CommonTableExpression.php
@@ -0,0 +1,238 @@
+
+     */
+    protected array $fields = [];
+
+    /**
+     * The CTE query definition.
+     *
+     * @var \Cake\Database\ExpressionInterface|null
+     */
+    protected ?ExpressionInterface $query = null;
+
+    /**
+     * Whether the CTE is materialized or not materialized.
+     *
+     * @var string|null
+     */
+    protected ?string $materialized = null;
+
+    /**
+     * Whether the CTE is recursive.
+     *
+     * @var bool
+     */
+    protected bool $recursive = false;
+
+    /**
+     * Constructor.
+     *
+     * @param string $name The CTE name.
+     * @param \Cake\Database\ExpressionInterface|\Closure|null $query CTE query
+     */
+    public function __construct(string $name = '', ExpressionInterface|Closure|null $query = null)
+    {
+        $this->name = new IdentifierExpression($name);
+        if ($query) {
+            $this->query($query);
+        }
+    }
+
+    /**
+     * Sets the name of this CTE.
+     *
+     * This is the named you used to reference the expression
+     * in select, insert, etc queries.
+     *
+     * @param string $name The CTE name.
+     * @return $this
+     */
+    public function name(string $name)
+    {
+        $this->name = new IdentifierExpression($name);
+
+        return $this;
+    }
+
+    /**
+     * Sets the query for this CTE.
+     *
+     * @param \Cake\Database\ExpressionInterface|\Closure $query CTE query
+     * @return $this
+     */
+    public function query(ExpressionInterface|Closure $query)
+    {
+        if ($query instanceof Closure) {
+            $query = $query();
+            if (!($query instanceof ExpressionInterface)) {
+                throw new DatabaseException(
+                    'You must return an `ExpressionInterface` from a Closure passed to `query()`.',
+                );
+            }
+        }
+        $this->query = $query;
+
+        return $this;
+    }
+
+    /**
+     * Adds one or more fields (arguments) to the CTE.
+     *
+     * @param \Cake\Database\Expression\IdentifierExpression|array|array<\Cake\Database\Expression\IdentifierExpression>|string $fields Field names
+     * @return $this
+     */
+    public function field(IdentifierExpression|array|string $fields)
+    {
+        $fields = (array)$fields;
+        /** @var array $fields */
+        foreach ($fields as &$field) {
+            if (!($field instanceof IdentifierExpression)) {
+                $field = new IdentifierExpression($field);
+            }
+        }
+        /** @var array<\Cake\Database\Expression\IdentifierExpression> $mergedFields */
+        $mergedFields = array_merge($this->fields, $fields);
+        $this->fields = $mergedFields;
+
+        return $this;
+    }
+
+    /**
+     * Sets this CTE as materialized.
+     *
+     * @return $this
+     */
+    public function materialized()
+    {
+        $this->materialized = 'MATERIALIZED';
+
+        return $this;
+    }
+
+    /**
+     * Sets this CTE as not materialized.
+     *
+     * @return $this
+     */
+    public function notMaterialized()
+    {
+        $this->materialized = 'NOT MATERIALIZED';
+
+        return $this;
+    }
+
+    /**
+     * Gets whether this CTE is recursive.
+     *
+     * @return bool
+     */
+    public function isRecursive(): bool
+    {
+        return $this->recursive;
+    }
+
+    /**
+     * Sets this CTE as recursive.
+     *
+     * @return $this
+     */
+    public function recursive()
+    {
+        $this->recursive = true;
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function sql(ValueBinder $binder): string
+    {
+        $fields = '';
+        if ($this->fields) {
+            $expressions = array_map(fn(IdentifierExpression $e) => $e->sql($binder), $this->fields);
+            $fields = sprintf('(%s)', implode(', ', $expressions));
+        }
+
+        $suffix = $this->materialized ? $this->materialized . ' ' : '';
+
+        return sprintf(
+            '%s%s AS %s(%s)',
+            $this->name->sql($binder),
+            $fields,
+            $suffix,
+            $this->query ? $this->query->sql($binder) : '',
+        );
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function traverse(Closure $callback)
+    {
+        $callback($this->name);
+        foreach ($this->fields as $field) {
+            $callback($field);
+            $field->traverse($callback);
+        }
+
+        if ($this->query) {
+            $callback($this->query);
+            $this->query->traverse($callback);
+        }
+
+        return $this;
+    }
+
+    /**
+     * Clones the inner expression objects.
+     */
+    public function __clone()
+    {
+        $this->name = clone $this->name;
+        if ($this->query) {
+            $this->query = clone $this->query;
+        }
+
+        foreach ($this->fields as $key => $field) {
+            $this->fields[$key] = clone $field;
+        }
+    }
+}
diff --git a/src/Database/Expression/Comparison.php b/src/Database/Expression/Comparison.php
deleted file mode 100644
index 25147e39f07..00000000000
--- a/src/Database/Expression/Comparison.php
+++ /dev/null
@@ -1,313 +0,0 @@
-_type = $type;
-        }
-
-        $this->setField($field);
-        $this->setValue($value);
-        $this->_operator = $operator;
-    }
-
-    /**
-     * Sets the value
-     *
-     * @param mixed $value The value to compare
-     * @return void
-     */
-    public function setValue($value)
-    {
-        $hasType = isset($this->_type) && is_string($this->_type);
-        $isMultiple = $hasType && strpos($this->_type, '[]') !== false;
-
-        if ($hasType) {
-            $value = $this->_castToExpression($value, $this->_type);
-        }
-
-        if ($isMultiple) {
-            list($value, $this->_valueExpressions) = $this->_collectExpressions($value);
-        }
-
-        $this->_isMultiple = $isMultiple;
-        $this->_value = $value;
-    }
-
-    /**
-     * Returns the value used for comparison
-     *
-     * @return mixed
-     */
-    public function getValue()
-    {
-        return $this->_value;
-    }
-
-    /**
-     * Sets the operator to use for the comparison
-     *
-     * @param string $operator The operator to be used for the comparison.
-     * @return void
-     */
-    public function setOperator($operator)
-    {
-        $this->_operator = $operator;
-    }
-
-    /**
-     * Returns the operator used for comparison
-     *
-     * @return string
-     */
-    public function getOperator()
-    {
-        return $this->_operator;
-    }
-
-    /**
-     * Convert the expression into a SQL fragment.
-     *
-     * @param \Cake\Database\ValueBinder $generator Placeholder generator object
-     * @return string
-     */
-    public function sql(ValueBinder $generator)
-    {
-        $field = $this->_field;
-
-        if ($field instanceof ExpressionInterface) {
-            $field = $field->sql($generator);
-        }
-
-        if ($this->_value instanceof ExpressionInterface) {
-            $template = '%s %s (%s)';
-            $value = $this->_value->sql($generator);
-        } else {
-            list($template, $value) = $this->_stringExpression($generator);
-        }
-
-        return sprintf($template, $field, $this->_operator, $value);
-    }
-
-    /**
-     * {@inheritDoc}
-     *
-     */
-    public function traverse(callable $callable)
-    {
-        if ($this->_field instanceof ExpressionInterface) {
-            $callable($this->_field);
-            $this->_field->traverse($callable);
-        }
-
-        if ($this->_value instanceof ExpressionInterface) {
-            $callable($this->_value);
-            $this->_value->traverse($callable);
-        }
-
-        foreach ($this->_valueExpressions as $v) {
-            $callable($v);
-            $v->traverse($callable);
-        }
-    }
-
-    /**
-     * Create a deep clone.
-     *
-     * Clones the field and value if they are expression objects.
-     *
-     * @return void
-     */
-    public function __clone()
-    {
-        foreach (['_value', '_field'] as $prop) {
-            if ($prop instanceof ExpressionInterface) {
-                $this->{$prop} = clone $this->{$prop};
-            }
-        }
-    }
-
-    /**
-     * Returns a template and a placeholder for the value after registering it
-     * with the placeholder $generator
-     *
-     * @param \Cake\Database\ValueBinder $generator The value binder to use.
-     * @return array First position containing the template and the second a placeholder
-     */
-    protected function _stringExpression($generator)
-    {
-        $template = '%s ';
-
-        if ($this->_field instanceof ExpressionInterface) {
-            $template = '(%s) ';
-        }
-
-        if ($this->_isMultiple) {
-            $template .= '%s (%s)';
-            $type = str_replace('[]', '', $this->_type);
-            $value = $this->_flattenValue($this->_value, $generator, $type);
-
-            // To avoid SQL errors when comparing a field to a list of empty values,
-            // better just throw an exception here
-            if ($value === '') {
-                $field = $this->_field instanceof ExpressionInterface ? $this->_field->sql($generator) : $this->_field;
-                throw new DatabaseException(
-                    "Impossible to generate condition with empty list of values for field ($field)"
-                );
-            }
-        } else {
-            $template .= '%s %s';
-            $value = $this->_bindValue($this->_value, $generator, $this->_type);
-        }
-
-        return [$template, $value];
-    }
-
-    /**
-     * Registers a value in the placeholder generator and returns the generated placeholder
-     *
-     * @param mixed $value The value to bind
-     * @param \Cake\Database\ValueBinder $generator The value binder to use
-     * @param string $type The type of $value
-     * @return string generated placeholder
-     */
-    protected function _bindValue($value, $generator, $type)
-    {
-        $placeholder = $generator->placeholder('c');
-        $generator->bind($placeholder, $value, $type);
-
-        return $placeholder;
-    }
-
-    /**
-     * Converts a traversable value into a set of placeholders generated by
-     * $generator and separated by `,`
-     *
-     * @param array|\Traversable $value the value to flatten
-     * @param \Cake\Database\ValueBinder $generator The value binder to use
-     * @param string|array|null $type the type to cast values to
-     * @return string
-     */
-    protected function _flattenValue($value, $generator, $type = 'string')
-    {
-        $parts = [];
-        foreach ($this->_valueExpressions as $k => $v) {
-            $parts[$k] = $v->sql($generator);
-            unset($value[$k]);
-        }
-
-        if (!empty($value)) {
-            $parts += $generator->generateManyNamed($value, $type);
-        }
-
-        return implode(',', $parts);
-    }
-
-    /**
-     * Returns an array with the original $values in the first position
-     * and all ExpressionInterface objects that could be found in the second
-     * position.
-     *
-     * @param array|\Traversable $values The rows to insert
-     * @return array
-     */
-    protected function _collectExpressions($values)
-    {
-        if ($values instanceof ExpressionInterface) {
-            return [$values, []];
-        }
-
-        $expressions = $result = [];
-        $isArray = is_array($values);
-
-        if ($isArray) {
-            $result = $values;
-        }
-
-        foreach ($values as $k => $v) {
-            if ($v instanceof ExpressionInterface) {
-                $expressions[$k] = $v;
-            }
-
-            if ($isArray) {
-                $result[$k] = $v;
-            }
-        }
-
-        return [$result, $expressions];
-    }
-}
diff --git a/src/Database/Expression/ComparisonExpression.php b/src/Database/Expression/ComparisonExpression.php
new file mode 100644
index 00000000000..e3da2dfef66
--- /dev/null
+++ b/src/Database/Expression/ComparisonExpression.php
@@ -0,0 +1,318 @@
+
+     */
+    protected array $_valueExpressions = [];
+
+    /**
+     * Constructor
+     *
+     * @param \Cake\Database\ExpressionInterface|string $field the field name to compare to a value
+     * @param mixed $value The value to be used in comparison
+     * @param string|null $type the type name used to cast the value
+     * @param string $operator the operator used for comparing field and value
+     */
+    public function __construct(
+        ExpressionInterface|string $field,
+        mixed $value,
+        ?string $type = null,
+        string $operator = '=',
+    ) {
+        $this->_type = $type;
+        $this->setField($field);
+        $this->setValue($value);
+        $this->_operator = $operator;
+    }
+
+    /**
+     * Sets the value
+     *
+     * @param mixed $value The value to compare
+     * @return void
+     */
+    public function setValue(mixed $value): void
+    {
+        $value = $this->_castToExpression($value, $this->_type);
+
+        $isMultiple = $this->_type && str_contains($this->_type, '[]');
+        if ($isMultiple) {
+            [$value, $this->_valueExpressions] = $this->_collectExpressions($value);
+        }
+
+        $this->_isMultiple = $isMultiple;
+        $this->_value = $value;
+    }
+
+    /**
+     * Returns the value used for comparison
+     *
+     * @return mixed
+     */
+    public function getValue(): mixed
+    {
+        return $this->_value;
+    }
+
+    /**
+     * Sets the operator to use for the comparison
+     *
+     * @param string $operator The operator to be used for the comparison.
+     * @return void
+     */
+    public function setOperator(string $operator): void
+    {
+        $this->_operator = $operator;
+    }
+
+    /**
+     * Returns the operator used for comparison
+     *
+     * @return string
+     */
+    public function getOperator(): string
+    {
+        return $this->_operator;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function sql(ValueBinder $binder): string
+    {
+        $field = $this->_field;
+
+        if ($field instanceof ExpressionInterface) {
+            $field = $field->sql($binder);
+        }
+
+        if ($this->_value instanceof IdentifierExpression) {
+            $template = '%s %s %s';
+            $value = $this->_value->sql($binder);
+        } elseif ($this->_value instanceof ExpressionInterface) {
+            $template = '%s %s (%s)';
+            $value = $this->_value->sql($binder);
+        } else {
+            [$template, $value] = $this->_stringExpression($binder);
+        }
+        assert(is_string($field));
+
+        return sprintf($template, $field, $this->_operator, $value);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function traverse(Closure $callback)
+    {
+        if ($this->_field instanceof ExpressionInterface) {
+            $callback($this->_field);
+            $this->_field->traverse($callback);
+        }
+
+        if ($this->_value instanceof ExpressionInterface) {
+            $callback($this->_value);
+            $this->_value->traverse($callback);
+        }
+
+        foreach ($this->_valueExpressions as $v) {
+            $callback($v);
+            $v->traverse($callback);
+        }
+
+        return $this;
+    }
+
+    /**
+     * Create a deep clone.
+     *
+     * Clones the field and value if they are expression objects.
+     */
+    public function __clone()
+    {
+        foreach (['_value', '_field'] as $prop) {
+            if ($this->{$prop} instanceof ExpressionInterface) {
+                $this->{$prop} = clone $this->{$prop};
+            }
+        }
+    }
+
+    /**
+     * Returns a template and a placeholder for the value after registering it
+     * with the placeholder $binder
+     *
+     * @param \Cake\Database\ValueBinder $binder The value binder to use.
+     * @return array First position containing the template and the second a placeholder
+     */
+    protected function _stringExpression(ValueBinder $binder): array
+    {
+        $template = '%s ';
+
+        if ($this->_field instanceof ExpressionInterface && !$this->_field instanceof IdentifierExpression) {
+            $template = '(%s) ';
+        }
+
+        if ($this->_isMultiple) {
+            $template .= '%s (%s)';
+            $type = $this->_type;
+            if ($type !== null) {
+                $type = str_replace('[]', '', $type);
+            }
+            $value = $this->_flattenValue($this->_value, $binder, $type);
+
+            // To avoid SQL errors when comparing a field to a list of empty values,
+            // better just throw an exception here
+            if ($value === '') {
+                $field = $this->_field instanceof ExpressionInterface ? $this->_field->sql($binder) : $this->_field;
+                /** @var string $field */
+                throw new DatabaseException(
+                    "Impossible to generate condition with empty list of values for field ({$field})",
+                );
+            }
+        } else {
+            $template .= '%s %s';
+            $value = $this->_bindValue($this->_value, $binder, $this->_type);
+        }
+
+        return [$template, $value];
+    }
+
+    /**
+     * Registers a value in the placeholder generator and returns the generated placeholder
+     *
+     * @param mixed $value The value to bind
+     * @param \Cake\Database\ValueBinder $binder The value binder to use
+     * @param string|null $type The type of $value
+     * @return string generated placeholder
+     */
+    protected function _bindValue(mixed $value, ValueBinder $binder, ?string $type = null): string
+    {
+        $placeholder = $binder->placeholder('c');
+        $binder->bind($placeholder, $value, $type);
+
+        return $placeholder;
+    }
+
+    /**
+     * Converts a traversable value into a set of placeholders generated by
+     * $binder and separated by `,`
+     *
+     * @param iterable $value the value to flatten
+     * @param \Cake\Database\ValueBinder $binder The value binder to use
+     * @param string|null $type the type to cast values to
+     * @return string
+     */
+    protected function _flattenValue(iterable $value, ValueBinder $binder, ?string $type = null): string
+    {
+        $parts = [];
+        if (is_array($value)) {
+            foreach ($this->_valueExpressions as $k => $v) {
+                $parts[$k] = $v->sql($binder);
+                unset($value[$k]);
+            }
+        }
+
+        if ($value) {
+            $parts += $binder->generateManyNamed($value, $type);
+        }
+
+        return implode(',', $parts);
+    }
+
+    /**
+     * Returns an array with the original $values in the first position
+     * and all ExpressionInterface objects that could be found in the second
+     * position.
+     *
+     * @param \Cake\Database\ExpressionInterface|iterable $values The rows to insert
+     * @return array
+     */
+    protected function _collectExpressions(ExpressionInterface|iterable $values): array
+    {
+        if ($values instanceof ExpressionInterface) {
+            return [$values, []];
+        }
+        $expressions = [];
+        $result = [];
+        $isArray = is_array($values);
+
+        if ($isArray) {
+            $result = (array)$values;
+        }
+
+        foreach ($values as $k => $v) {
+            if ($v instanceof ExpressionInterface) {
+                $expressions[$k] = $v;
+            }
+
+            if ($isArray) {
+                $result[$k] = $v;
+            }
+        }
+
+        return [$result, $expressions];
+    }
+}
diff --git a/src/Database/Expression/DistinctComparisonExpression.php b/src/Database/Expression/DistinctComparisonExpression.php
new file mode 100644
index 00000000000..541d668cf4c
--- /dev/null
+++ b/src/Database/Expression/DistinctComparisonExpression.php
@@ -0,0 +1,74 @@
+isNot = $not;
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function sql(ValueBinder $binder): string
+    {
+        $field = $this->_field;
+
+        if ($field instanceof ExpressionInterface) {
+            $field = $field->sql($binder);
+        }
+
+        if ($this->_value instanceof IdentifierExpression) {
+            $template = '%s %s %s';
+            $value = $this->_value->sql($binder);
+        } elseif ($this->_value instanceof ExpressionInterface) {
+            $template = '%s %s (%s)';
+            $value = $this->_value->sql($binder);
+        } else {
+            [$template, $value] = $this->_stringExpression($binder);
+        }
+
+        /** @var string $field */
+        $sql = sprintf($template, $field, $this->_operator, $value);
+
+        return $this->isNot ? "NOT ({$sql})" : $sql;
+    }
+}
diff --git a/src/Database/Expression/FieldInterface.php b/src/Database/Expression/FieldInterface.php
index 36f753d1491..ae657689415 100644
--- a/src/Database/Expression/FieldInterface.php
+++ b/src/Database/Expression/FieldInterface.php
@@ -1,4 +1,6 @@
 _field = $field;
     }
@@ -41,9 +44,9 @@ public function setField($field)
     /**
      * Returns the field name
      *
-     * @return string|\Cake\Database\ExpressionInterface
+     * @return \Cake\Database\ExpressionInterface|array|string
      */
-    public function getField()
+    public function getField(): ExpressionInterface|array|string
     {
         return $this->_field;
     }
diff --git a/src/Database/Expression/FunctionExpression.php b/src/Database/Expression/FunctionExpression.php
index 2b2eeddaffb..27d1f3e93b3 100644
--- a/src/Database/Expression/FunctionExpression.php
+++ b/src/Database/Expression/FunctionExpression.php
@@ -1,4 +1,6 @@
 |array $types Associative array of types to be associated with the
      * passed arguments
      * @param string $returnType The return type of this expression
      */
-    public function __construct($name, $params = [], $types = [], $returnType = 'string')
+    public function __construct(string $name, array $params = [], array $types = [], string $returnType = 'string')
     {
         $this->_name = $name;
         $this->_returnType = $returnType;
@@ -77,7 +79,7 @@ public function __construct($name, $params = [], $types = [], $returnType = 'str
      * @param string $name The name of the function
      * @return $this
      */
-    public function setName($name)
+    public function setName(string $name)
     {
         $this->_name = $name;
 
@@ -89,44 +91,28 @@ public function setName($name)
      *
      * @return string
      */
-    public function getName()
+    public function getName(): string
     {
         return $this->_name;
     }
 
-    /**
-     * Sets the name of the SQL function to be invoke in this expression,
-     * if no value is passed it will return current name
-     *
-     * @deprecated 3.4.0 Use setName()/getName() instead.
-     * @param string|null $name The name of the function
-     * @return string|$this
-     */
-    public function name($name = null)
-    {
-        if ($name !== null) {
-            return $this->setName($name);
-        }
-
-        return $this->getName();
-    }
-
     /**
      * Adds one or more arguments for the function call.
      *
-     * @param array $params list of arguments to be passed to the function
+     * @param \Cake\Database\ExpressionInterface|array|string $conditions list of arguments to be passed to the function
      * If associative the key would be used as argument when value is 'literal'
-     * @param array $types associative array of types to be associated with the
+     * @param array $types Associative array of types to be associated with the
      * passed arguments
      * @param bool $prepend Whether to prepend or append to the list of arguments
      * @see \Cake\Database\Expression\FunctionExpression::__construct() for more details.
      * @return $this
      */
-    public function add($params, $types = [], $prepend = false)
+    public function add(ExpressionInterface|array|string $conditions, array $types = [], bool $prepend = false)
     {
         $put = $prepend ? 'array_unshift' : 'array_push';
         $typeMap = $this->getTypeMap()->setTypes($types);
-        foreach ($params as $k => $p) {
+        /** @var array $conditions */
+        foreach ($conditions as $k => $p) {
             if ($p === 'literal') {
                 $put($this->_conditions, $k);
                 continue;
@@ -155,23 +141,19 @@ public function add($params, $types = [], $prepend = false)
     }
 
     /**
-     * Returns the string representation of this object so that it can be used in a
-     * SQL query. Note that values condition values are not included in the string,
-     * in their place placeholders are put and can be replaced by the quoted values
-     * accordingly.
-     *
-     * @param \Cake\Database\ValueBinder $generator Placeholder generator object
-     * @return string
+     * @inheritDoc
      */
-    public function sql(ValueBinder $generator)
+    public function sql(ValueBinder $binder): string
     {
         $parts = [];
         foreach ($this->_conditions as $condition) {
-            if ($condition instanceof ExpressionInterface) {
-                $condition = sprintf('%s', $condition->sql($generator));
+            if ($condition instanceof Query) {
+                $condition = sprintf('(%s)', $condition->sql($binder));
+            } elseif ($condition instanceof ExpressionInterface) {
+                $condition = $condition->sql($binder);
             } elseif (is_array($condition)) {
-                $p = $generator->placeholder('param');
-                $generator->bind($p, $condition['value'], $condition['type']);
+                $p = $binder->placeholder('param');
+                $binder->bind($p, $condition['value'], $condition['type']);
                 $condition = $p;
             }
             $parts[] = $condition;
@@ -179,7 +161,7 @@ public function sql(ValueBinder $generator)
 
         return $this->_name . sprintf('(%s)', implode(
             $this->_conjunction . ' ',
-            $parts
+            $parts,
         ));
     }
 
@@ -189,7 +171,7 @@ public function sql(ValueBinder $generator)
      *
      * @return int
      */
-    public function count()
+    public function count(): int
     {
         return 1 + count($this->_conditions);
     }
diff --git a/src/Database/Expression/IdentifierExpression.php b/src/Database/Expression/IdentifierExpression.php
index 7daed9b8ee3..af4c77ebdbf 100644
--- a/src/Database/Expression/IdentifierExpression.php
+++ b/src/Database/Expression/IdentifierExpression.php
@@ -1,4 +1,6 @@
 _identifier = $identifier;
+        $this->collation = $collation;
     }
 
     /**
@@ -46,7 +60,7 @@ public function __construct($identifier)
      * @param string $identifier The identifier
      * @return void
      */
-    public function setIdentifier($identifier)
+    public function setIdentifier(string $identifier): void
     {
         $this->_identifier = $identifier;
     }
@@ -56,30 +70,50 @@ public function setIdentifier($identifier)
      *
      * @return string
      */
-    public function getIdentifier()
+    public function getIdentifier(): string
     {
         return $this->_identifier;
     }
 
     /**
-     * Converts the expression to its string representation
+     * Sets the collation.
      *
-     * @param \Cake\Database\ValueBinder $generator Placeholder generator object
-     * @return string
+     * @param string $collation Identifier collation
+     * @return void
      */
-    public function sql(ValueBinder $generator)
+    public function setCollation(string $collation): void
     {
-        return $this->_identifier;
+        $this->collation = $collation;
     }
 
     /**
-     * This method is a no-op, this is a leaf type of expression,
-     * hence there is nothing to traverse
+     * Returns the collation.
      *
-     * @param callable $callable The callable to traverse with.
-     * @return void
+     * @return string|null
+     */
+    public function getCollation(): ?string
+    {
+        return $this->collation;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function sql(ValueBinder $binder): string
+    {
+        $sql = $this->_identifier;
+        if ($this->collation) {
+            $sql .= ' COLLATE ' . $this->collation;
+        }
+
+        return $sql;
+    }
+
+    /**
+     * @inheritDoc
      */
-    public function traverse(callable $callable)
+    public function traverse(Closure $callback)
     {
+        return $this;
     }
 }
diff --git a/src/Database/Expression/OrderByExpression.php b/src/Database/Expression/OrderByExpression.php
index ad9582148b7..dda76914bc8 100644
--- a/src/Database/Expression/OrderByExpression.php
+++ b/src/Database/Expression/OrderByExpression.php
@@ -1,4 +1,6 @@
  $types The types for each column.
      * @param string $conjunction The glue used to join conditions together.
      */
-    public function __construct($conditions = [], $types = [], $conjunction = '')
-    {
+    public function __construct(
+        ExpressionInterface|array|string $conditions = [],
+        TypeMap|array $types = [],
+        string $conjunction = '',
+    ) {
         parent::__construct($conditions, $types, $conjunction);
     }
 
     /**
-     * Convert the expression into a SQL fragment.
-     *
-     * @param \Cake\Database\ValueBinder $generator Placeholder generator object
-     * @return string
+     * @inheritDoc
      */
-    public function sql(ValueBinder $generator)
+    public function sql(ValueBinder $binder): string
     {
         $order = [];
         foreach ($this->_conditions as $k => $direction) {
             if ($direction instanceof ExpressionInterface) {
-                $direction = $direction->sql($generator);
+                $direction = $direction->sql($binder);
             }
             $order[] = is_numeric($k) ? $direction : sprintf('%s %s', $k, $direction);
         }
@@ -60,12 +63,30 @@ public function sql(ValueBinder $generator)
      *
      * New order by expressions are merged to existing ones
      *
-     * @param array $orders list of order by expressions
+     * @param array $conditions list of order by expressions
      * @param array $types list of types associated on fields referenced in $conditions
      * @return void
      */
-    protected function _addConditions(array $orders, array $types)
+    protected function _addConditions(array $conditions, array $types): void
     {
-        $this->_conditions = array_merge($this->_conditions, $orders);
+        foreach ($conditions as $key => $val) {
+            if (
+                is_string($key) &&
+                is_string($val) &&
+                !in_array(strtoupper($val), ['ASC', 'DESC'], true)
+            ) {
+                throw new InvalidArgumentException(
+                    sprintf(
+                        "Passing extra expressions by associative array (`'%s' => '%s'`) " .
+                        'is not allowed to avoid potential SQL injection. ' .
+                        'Use QueryExpression or numeric array instead.',
+                        $key,
+                        $val,
+                    ),
+                );
+            }
+        }
+
+        $this->_conditions = array_merge($this->_conditions, $conditions);
     }
 }
diff --git a/src/Database/Expression/OrderClauseExpression.php b/src/Database/Expression/OrderClauseExpression.php
index 11f04de035d..280548eee6f 100644
--- a/src/Database/Expression/OrderClauseExpression.php
+++ b/src/Database/Expression/OrderClauseExpression.php
@@ -1,4 +1,6 @@
 _field = $field;
         $this->_direction = strtolower($direction) === 'asc' ? 'ASC' : 'DESC';
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function sql(ValueBinder $generator)
+    public function sql(ValueBinder $binder): string
     {
         $field = $this->_field;
-        if ($field instanceof ExpressionInterface) {
-            $field = $field->sql($generator);
+        if ($field instanceof Query) {
+            $field = sprintf('(%s)', $field->sql($binder));
+        } elseif ($field instanceof ExpressionInterface) {
+            $field = $field->sql($binder);
         }
+        assert(is_string($field));
 
         return sprintf('%s %s', $field, $this->_direction);
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function traverse(callable $visitor)
+    public function traverse(Closure $callback)
     {
         if ($this->_field instanceof ExpressionInterface) {
-            $visitor($this->_field);
-            $this->_field->traverse($visitor);
+            $callback($this->_field);
+            $this->_field->traverse($callback);
         }
+
+        return $this;
     }
 
     /**
      * Create a deep clone of the order clause.
-     *
-     * @return void
      */
     public function __clone()
     {
diff --git a/src/Database/Expression/QueryExpression.php b/src/Database/Expression/QueryExpression.php
index cf8606edfc2..c5cdeb1f294 100644
--- a/src/Database/Expression/QueryExpression.php
+++ b/src/Database/Expression/QueryExpression.php
@@ -1,4 +1,6 @@
 setTypeMap($types);
         $this->setConjunction(strtoupper($conjunction));
-        if (!empty($conditions)) {
+        if ($conditions) {
             $this->add($conditions, $this->getTypeMap()->getTypes());
         }
     }
@@ -77,7 +83,7 @@ public function __construct($conditions = [], $types = [], $conjunction = 'AND')
      * @param string $conjunction Value to be used for joining conditions
      * @return $this
      */
-    public function setConjunction($conjunction)
+    public function setConjunction(string $conjunction)
     {
         $this->_conjunction = strtoupper($conjunction);
 
@@ -89,42 +95,11 @@ public function setConjunction($conjunction)
      *
      * @return string
      */
-    public function getConjunction()
+    public function getConjunction(): string
     {
         return $this->_conjunction;
     }
 
-    /**
-     * Changes the conjunction for the conditions at this level of the expression tree.
-     * If called with no arguments it will return the currently configured value.
-     *
-     * @deprecated 3.4.0 Use setConjunction()/getConjunction() instead.
-     * @param string|null $conjunction value to be used for joining conditions. If null it
-     * will not set any value, but return the currently stored one
-     * @return string|$this
-     */
-    public function tieWith($conjunction = null)
-    {
-        if ($conjunction !== null) {
-            return $this->setConjunction($conjunction);
-        }
-
-        return $this->getConjunction();
-    }
-
-    /**
-     * Backwards compatible wrapper for tieWith()
-     *
-     * @param string|null $conjunction value to be used for joining conditions. If null it
-     * will not set any value, but return the currently stored one
-     * @return string|$this
-     * @deprecated 3.2.0 Use tieWith() instead
-     */
-    public function type($conjunction = null)
-    {
-        return $this->tieWith($conjunction);
-    }
-
     /**
      * Adds one or more conditions to this expression object. Conditions can be
      * expressed in a one dimensional array, that will cause all conditions to
@@ -136,24 +111,18 @@ public function type($conjunction = null)
      * then it will cause the placeholder to be re-written dynamically so if the
      * value is an array, it will create as many placeholders as values are in it.
      *
-     * @param string|array|\Cake\Database\ExpressionInterface $conditions single or multiple conditions to
+     * @param \Cake\Database\ExpressionInterface|array|string $conditions single or multiple conditions to
      * be added. When using an array and the key is 'OR' or 'AND' a new expression
      * object will be created with that conjunction and internal array value passed
      * as conditions.
-     * @param array $types associative array of fields pointing to the type of the
+     * @param array $types Associative array of fields pointing to the type of the
      * values that are being passed. Used for correctly binding values to statements.
      * @see \Cake\Database\Query::where() for examples on conditions
      * @return $this
      */
-    public function add($conditions, $types = [])
+    public function add(ExpressionInterface|array|string $conditions, array $types = [])
     {
-        if (is_string($conditions)) {
-            $this->_conditions[] = $conditions;
-
-            return $this;
-        }
-
-        if ($conditions instanceof ExpressionInterface) {
+        if (is_string($conditions) || $conditions instanceof ExpressionInterface) {
             $this->_conditions[] = $conditions;
 
             return $this;
@@ -167,117 +136,105 @@ public function add($conditions, $types = [])
     /**
      * Adds a new condition to the expression object in the form "field = value".
      *
-     * @param string|\Cake\Database\ExpressionInterface $field Database field to be compared against value
+     * @param \Cake\Database\ExpressionInterface|string $field Database field to be compared against value
      * @param mixed $value The value to be bound to $field for comparison
      * @param string|null $type the type name for $value as configured using the Type map.
      * If it is suffixed with "[]" and the value is an array then multiple placeholders
      * will be created, one per each value in the array.
      * @return $this
      */
-    public function eq($field, $value, $type = null)
+    public function eq(ExpressionInterface|string $field, mixed $value, ?string $type = null)
     {
-        if ($type === null) {
-            $type = $this->_calculateType($field);
-        }
+        $type ??= $this->_calculateType($field);
 
-        return $this->add(new Comparison($field, $value, $type, '='));
+        return $this->add(new ComparisonExpression($field, $value, $type, '='));
     }
 
     /**
      * Adds a new condition to the expression object in the form "field != value".
      *
-     * @param string|\Cake\Database\ExpressionInterface $field Database field to be compared against value
+     * @param \Cake\Database\ExpressionInterface|string $field Database field to be compared against value
      * @param mixed $value The value to be bound to $field for comparison
      * @param string|null $type the type name for $value as configured using the Type map.
      * If it is suffixed with "[]" and the value is an array then multiple placeholders
      * will be created, one per each value in the array.
      * @return $this
      */
-    public function notEq($field, $value, $type = null)
+    public function notEq(ExpressionInterface|string $field, mixed $value, ?string $type = null)
     {
-        if ($type === null) {
-            $type = $this->_calculateType($field);
-        }
+        $type ??= $this->_calculateType($field);
 
-        return $this->add(new Comparison($field, $value, $type, '!='));
+        return $this->add(new ComparisonExpression($field, $value, $type, '!='));
     }
 
     /**
      * Adds a new condition to the expression object in the form "field > value".
      *
-     * @param string|\Cake\Database\ExpressionInterface $field Database field to be compared against value
+     * @param \Cake\Database\ExpressionInterface|string $field Database field to be compared against value
      * @param mixed $value The value to be bound to $field for comparison
      * @param string|null $type the type name for $value as configured using the Type map.
      * @return $this
      */
-    public function gt($field, $value, $type = null)
+    public function gt(ExpressionInterface|string $field, mixed $value, ?string $type = null)
     {
-        if ($type === null) {
-            $type = $this->_calculateType($field);
-        }
+        $type ??= $this->_calculateType($field);
 
-        return $this->add(new Comparison($field, $value, $type, '>'));
+        return $this->add(new ComparisonExpression($field, $value, $type, '>'));
     }
 
     /**
      * Adds a new condition to the expression object in the form "field < value".
      *
-     * @param string|\Cake\Database\ExpressionInterface $field Database field to be compared against value
+     * @param \Cake\Database\ExpressionInterface|string $field Database field to be compared against value
      * @param mixed $value The value to be bound to $field for comparison
      * @param string|null $type the type name for $value as configured using the Type map.
      * @return $this
      */
-    public function lt($field, $value, $type = null)
+    public function lt(ExpressionInterface|string $field, mixed $value, ?string $type = null)
     {
-        if ($type === null) {
-            $type = $this->_calculateType($field);
-        }
+        $type ??= $this->_calculateType($field);
 
-        return $this->add(new Comparison($field, $value, $type, '<'));
+        return $this->add(new ComparisonExpression($field, $value, $type, '<'));
     }
 
     /**
      * Adds a new condition to the expression object in the form "field >= value".
      *
-     * @param string|\Cake\Database\ExpressionInterface $field Database field to be compared against value
+     * @param \Cake\Database\ExpressionInterface|string $field Database field to be compared against value
      * @param mixed $value The value to be bound to $field for comparison
      * @param string|null $type the type name for $value as configured using the Type map.
      * @return $this
      */
-    public function gte($field, $value, $type = null)
+    public function gte(ExpressionInterface|string $field, mixed $value, ?string $type = null)
     {
-        if ($type === null) {
-            $type = $this->_calculateType($field);
-        }
+        $type ??= $this->_calculateType($field);
 
-        return $this->add(new Comparison($field, $value, $type, '>='));
+        return $this->add(new ComparisonExpression($field, $value, $type, '>='));
     }
 
     /**
      * Adds a new condition to the expression object in the form "field <= value".
      *
-     * @param string|\Cake\Database\ExpressionInterface $field Database field to be compared against value
+     * @param \Cake\Database\ExpressionInterface|string $field Database field to be compared against value
      * @param mixed $value The value to be bound to $field for comparison
      * @param string|null $type the type name for $value as configured using the Type map.
      * @return $this
      */
-    public function lte($field, $value, $type = null)
+    public function lte(ExpressionInterface|string $field, mixed $value, ?string $type = null)
     {
-        if ($type === null) {
-            $type = $this->_calculateType($field);
-        }
+        $type ??= $this->_calculateType($field);
 
-        return $this->add(new Comparison($field, $value, $type, '<='));
+        return $this->add(new ComparisonExpression($field, $value, $type, '<='));
     }
 
     /**
      * Adds a new condition to the expression object in the form "field IS NULL".
      *
-     * @param string|\Cake\Database\ExpressionInterface $field database field to be
+     * @param \Cake\Database\ExpressionInterface|string $field database field to be
      * tested for null
      * @return $this
      */
-    public function isNull($field)
+    public function isNull(ExpressionInterface|string $field)
     {
         if (!($field instanceof ExpressionInterface)) {
             $field = new IdentifierExpression($field);
@@ -289,11 +246,11 @@ public function isNull($field)
     /**
      * Adds a new condition to the expression object in the form "field IS NOT NULL".
      *
-     * @param string|\Cake\Database\ExpressionInterface $field database field to be
+     * @param \Cake\Database\ExpressionInterface|string $field database field to be
      * tested for not null
      * @return $this
      */
-    public function isNotNull($field)
+    public function isNotNull(ExpressionInterface|string $field)
     {
         if (!($field instanceof ExpressionInterface)) {
             $field = new IdentifierExpression($field);
@@ -302,152 +259,254 @@ public function isNotNull($field)
         return $this->add(new UnaryExpression('IS NOT NULL', $field, UnaryExpression::POSTFIX));
     }
 
+    /**
+     * Adds a new condition to the expression object in the form "field IS DISTINCT FROM value".
+     *
+     * @param \Cake\Database\ExpressionInterface|string $field Database field to be compared against value
+     * @param mixed $value The value to be bound to $field for comparison
+     * @param string|null $type the type name for $value as configured using the Type map.
+     * @return $this
+     */
+    public function isDistinctFrom(ExpressionInterface|string $field, mixed $value, ?string $type = null)
+    {
+        $type ??= $this->_calculateType($field);
+
+        return $this->add(new DistinctComparisonExpression($field, $value, $type, 'IS DISTINCT FROM'));
+    }
+
+    /**
+     * Adds a new condition to the expression object in the form "field IS NOT DISTINCT FROM value".
+     *
+     * @param \Cake\Database\ExpressionInterface|string $field Database field to be compared against value
+     * @param mixed $value The value to be bound to $field for comparison
+     * @param string|null $type the type name for $value as configured using the Type map.
+     * @return $this
+     */
+    public function isNotDistinctFrom(ExpressionInterface|string $field, mixed $value, ?string $type = null)
+    {
+        $type ??= $this->_calculateType($field);
+
+        return $this->add(new DistinctComparisonExpression($field, $value, $type, 'IS NOT DISTINCT FROM'));
+    }
+
     /**
      * Adds a new condition to the expression object in the form "field LIKE value".
      *
-     * @param string|\Cake\Database\ExpressionInterface $field Database field to be compared against value
+     * @param \Cake\Database\ExpressionInterface|string $field Database field to be compared against value
      * @param mixed $value The value to be bound to $field for comparison
      * @param string|null $type the type name for $value as configured using the Type map.
      * @return $this
      */
-    public function like($field, $value, $type = null)
+    public function like(ExpressionInterface|string $field, mixed $value, ?string $type = null)
     {
-        if ($type === null) {
-            $type = $this->_calculateType($field);
-        }
+        $type ??= $this->_calculateType($field);
 
-        return $this->add(new Comparison($field, $value, $type, 'LIKE'));
+        return $this->add(new ComparisonExpression($field, $value, $type, 'LIKE'));
     }
 
     /**
      * Adds a new condition to the expression object in the form "field NOT LIKE value".
      *
-     * @param string|\Cake\Database\ExpressionInterface $field Database field to be compared against value
+     * @param \Cake\Database\ExpressionInterface|string $field Database field to be compared against value
      * @param mixed $value The value to be bound to $field for comparison
      * @param string|null $type the type name for $value as configured using the Type map.
      * @return $this
      */
-    public function notLike($field, $value, $type = null)
+    public function notLike(ExpressionInterface|string $field, mixed $value, ?string $type = null)
     {
-        if ($type === null) {
-            $type = $this->_calculateType($field);
-        }
+        $type ??= $this->_calculateType($field);
 
-        return $this->add(new Comparison($field, $value, $type, 'NOT LIKE'));
+        return $this->add(new ComparisonExpression($field, $value, $type, 'NOT LIKE'));
     }
 
     /**
      * Adds a new condition to the expression object in the form
      * "field IN (value1, value2)".
      *
-     * @param string|\Cake\Database\ExpressionInterface $field Database field to be compared against value
-     * @param string|array $values the value to be bound to $field for comparison
+     * @param \Cake\Database\ExpressionInterface|string $field Database field to be compared against value
+     * @param \Cake\Database\ExpressionInterface|array|string $values the value to be bound to $field for comparison
      * @param string|null $type the type name for $value as configured using the Type map.
      * @return $this
      */
-    public function in($field, $values, $type = null)
-    {
-        if ($type === null) {
-            $type = $this->_calculateType($field);
-        }
+    public function in(
+        ExpressionInterface|string $field,
+        ExpressionInterface|array|string $values,
+        ?string $type = null,
+    ) {
+        $type ??= $this->_calculateType($field);
         $type = $type ?: 'string';
         $type .= '[]';
         $values = $values instanceof ExpressionInterface ? $values : (array)$values;
 
-        return $this->add(new Comparison($field, $values, $type, 'IN'));
+        return $this->add(new ComparisonExpression($field, $values, $type, 'IN'));
     }
 
     /**
-     * Adds a new case expression to the expression object
+     * Returns a new case expression object.
      *
-     * @param array|\Cake\Database\ExpressionInterface $conditions The conditions to test. Must be a ExpressionInterface
-     * instance, or an array of ExpressionInterface instances.
-     * @param array|\Cake\Database\ExpressionInterface $values associative array of values to be associated with the conditions
-     * passed in $conditions. If there are more $values than $conditions, the last $value is used as the `ELSE` value
-     * @param array $types associative array of types to be associated with the values
-     * passed in $values
-     * @return $this
+     * When a value is set, the syntax generated is
+     * `CASE case_value WHEN when_value ... END` (simple case),
+     * where the `when_value`'s are compared against the
+     * `case_value`.
+     *
+     * When no value is set, the syntax generated is
+     * `CASE WHEN when_conditions ... END` (searched case),
+     * where the conditions hold the comparisons.
+     *
+     * Note that `null` is a valid case value, and thus should
+     * only be passed if you actually want to create the simple
+     * case expression variant!
+     *
+     * @param \Cake\Database\ExpressionInterface|object|scalar|null $value The case value.
+     * @param string|null $type The case value type. If no type is provided, the type will be tried to be inferred
+     *  from the value.
+     * @return \Cake\Database\Expression\CaseStatementExpression
      */
-    public function addCase($conditions, $values = [], $types = [])
+    public function case(mixed $value = null, ?string $type = null): CaseStatementExpression
     {
-        return $this->add(new CaseExpression($conditions, $values, $types));
+        if (func_num_args() > 0) {
+            $expression = new CaseStatementExpression($value, $type);
+        } else {
+            $expression = new CaseStatementExpression();
+        }
+
+        return $expression->setTypeMap($this->getTypeMap());
     }
 
     /**
      * Adds a new condition to the expression object in the form
      * "field NOT IN (value1, value2)".
      *
-     * @param string|\Cake\Database\ExpressionInterface $field Database field to be compared against value
-     * @param array $values the value to be bound to $field for comparison
+     * @param \Cake\Database\ExpressionInterface|string $field Database field to be compared against value
+     * @param \Cake\Database\ExpressionInterface|array|string $values the value to be bound to $field for comparison
      * @param string|null $type the type name for $value as configured using the Type map.
      * @return $this
      */
-    public function notIn($field, $values, $type = null)
-    {
-        if ($type === null) {
-            $type = $this->_calculateType($field);
-        }
+    public function notIn(
+        ExpressionInterface|string $field,
+        ExpressionInterface|array|string $values,
+        ?string $type = null,
+    ) {
+        $type ??= $this->_calculateType($field);
         $type = $type ?: 'string';
         $type .= '[]';
         $values = $values instanceof ExpressionInterface ? $values : (array)$values;
 
-        return $this->add(new Comparison($field, $values, $type, 'NOT IN'));
+        return $this->add(new ComparisonExpression($field, $values, $type, 'NOT IN'));
+    }
+
+    /**
+     * Adds a new condition to the expression object in the form
+     * "(field IN (value1, value2) OR field IS NULL".
+     *
+     * @param \Cake\Database\ExpressionInterface|string $field Database field to be compared against value
+     * @param \Cake\Database\ExpressionInterface|array|string $values the value to be bound to $field for comparison
+     * @param string|null $type the type name for $value as configured using the Type map.
+     * @return $this
+     */
+    public function inOrNull(
+        ExpressionInterface|string $field,
+        ExpressionInterface|array|string $values,
+        ?string $type = null,
+    ) {
+        $or = new static([], $this->getTypeMap(), 'OR');
+        $or
+            ->in($field, $values, $type)
+            ->isNull($field);
+
+        return $this->add($or);
+    }
+
+    /**
+     * Adds a new condition to the expression object in the form
+     * "(field NOT IN (value1, value2) OR field IS NULL".
+     *
+     * @param \Cake\Database\ExpressionInterface|string $field Database field to be compared against value
+     * @param \Cake\Database\ExpressionInterface|array|string $values the value to be bound to $field for comparison
+     * @param string|null $type the type name for $value as configured using the Type map.
+     * @return $this
+     */
+    public function notInOrNull(
+        ExpressionInterface|string $field,
+        ExpressionInterface|array|string $values,
+        ?string $type = null,
+    ) {
+        $or = new static([], $this->getTypeMap(), 'OR');
+        $or
+            ->notIn($field, $values, $type)
+            ->isNull($field);
+
+        return $this->add($or);
     }
 
     /**
      * Adds a new condition to the expression object in the form "EXISTS (...)".
      *
-     * @param \Cake\Database\ExpressionInterface $query the inner query
+     * @param \Cake\Database\ExpressionInterface $expression the inner query
      * @return $this
      */
-    public function exists(ExpressionInterface $query)
+    public function exists(ExpressionInterface $expression)
     {
-        return $this->add(new UnaryExpression('EXISTS', $query, UnaryExpression::PREFIX));
+        return $this->add(new UnaryExpression('EXISTS', $expression, UnaryExpression::PREFIX));
     }
 
     /**
      * Adds a new condition to the expression object in the form "NOT EXISTS (...)".
      *
-     * @param \Cake\Database\ExpressionInterface $query the inner query
+     * @param \Cake\Database\ExpressionInterface $expression the inner query
      * @return $this
      */
-    public function notExists(ExpressionInterface $query)
+    public function notExists(ExpressionInterface $expression)
     {
-        return $this->add(new UnaryExpression('NOT EXISTS', $query, UnaryExpression::PREFIX));
+        return $this->add(new UnaryExpression('NOT EXISTS', $expression, UnaryExpression::PREFIX));
     }
 
     /**
      * Adds a new condition to the expression object in the form
      * "field BETWEEN from AND to".
      *
-     * @param string|\Cake\Database\ExpressionInterface $field The field name to compare for values in between the range.
+     * @param \Cake\Database\ExpressionInterface|string $field The field name to compare for values in between the range.
      * @param mixed $from The initial value of the range.
      * @param mixed $to The ending value in the comparison range.
      * @param string|null $type the type name for $value as configured using the Type map.
      * @return $this
      */
-    public function between($field, $from, $to, $type = null)
+    public function between(ExpressionInterface|string $field, mixed $from, mixed $to, ?string $type = null)
     {
-        if ($type === null) {
-            $type = $this->_calculateType($field);
-        }
+        $type ??= $this->_calculateType($field);
 
         return $this->add(new BetweenExpression($field, $from, $to, $type));
     }
 
-// @codingStandardsIgnoreStart
+    /**
+     * Adds a new condition to the expression object in the form
+     * "field NOT BETWEEN from AND to".
+     *
+     * @param \Cake\Database\ExpressionInterface|string $field The field name to compare for values outside the range.
+     * @param mixed $from The initial value of the range.
+     * @param mixed $to The ending value in the comparison range.
+     * @param string|null $type the type name for $value as configured using the Type map.
+     * @return $this
+     */
+    public function notBetween(ExpressionInterface|string $field, mixed $from, mixed $to, ?string $type = null)
+    {
+        $type ??= $this->_calculateType($field);
+
+        return $this->add(new BetweenExpression($field, $from, $to, $type, true));
+    }
+
     /**
      * Returns a new QueryExpression object containing all the conditions passed
      * and set up the conjunction to be "AND"
      *
-     * @param string|array|\Cake\Database\ExpressionInterface $conditions to be joined with AND
-     * @param array $types associative array of fields pointing to the type of the
+     * @param \Cake\Database\ExpressionInterface|\Closure|array|string $conditions to be joined with AND
+     * @param array $types Associative array of fields pointing to the type of the
      * values that are being passed. Used for correctly binding values to statements.
-     * @return \Cake\Database\Expression\QueryExpression
+     * @return static
      */
-    public function and_($conditions, $types = [])
+    public function and(ExpressionInterface|Closure|array|string $conditions, array $types = []): static
     {
-        if ($this->isCallable($conditions)) {
+        if ($conditions instanceof Closure) {
             return $conditions(new static([], $this->getTypeMap()->setTypes($types)));
         }
 
@@ -458,20 +517,19 @@ public function and_($conditions, $types = [])
      * Returns a new QueryExpression object containing all the conditions passed
      * and set up the conjunction to be "OR"
      *
-     * @param string|array|\Cake\Database\ExpressionInterface $conditions to be joined with OR
-     * @param array $types associative array of fields pointing to the type of the
+     * @param \Cake\Database\ExpressionInterface|\Closure|array|string $conditions to be joined with OR
+     * @param array $types Associative array of fields pointing to the type of the
      * values that are being passed. Used for correctly binding values to statements.
-     * @return \Cake\Database\Expression\QueryExpression
+     * @return static
      */
-    public function or_($conditions, $types = [])
+    public function or(ExpressionInterface|Closure|array|string $conditions, array $types = []): static
     {
-        if ($this->isCallable($conditions)) {
+        if ($conditions instanceof Closure) {
             return $conditions(new static([], $this->getTypeMap()->setTypes($types), 'OR'));
         }
 
         return new static($conditions, $this->getTypeMap()->setTypes($types), 'OR');
     }
-// @codingStandardsIgnoreEnd
 
     /**
      * Adds a new set of conditions to this level of the tree and negates
@@ -479,12 +537,12 @@ public function or_($conditions, $types = [])
      * "NOT ( (condition1) AND (conditions2) )" conjunction depends on the one
      * currently configured for this object.
      *
-     * @param string|array|\Cake\Database\ExpressionInterface $conditions to be added and negated
-     * @param array $types associative array of fields pointing to the type of the
+     * @param \Cake\Database\ExpressionInterface|\Closure|array|string $conditions to be added and negated
+     * @param array $types Associative array of fields pointing to the type of the
      * values that are being passed. Used for correctly binding values to statements.
      * @return $this
      */
-    public function not($conditions, $types = [])
+    public function not(ExpressionInterface|Closure|array|string $conditions, array $types = [])
     {
         return $this->add(['NOT' => $conditions], $types);
     }
@@ -496,7 +554,7 @@ public function not($conditions, $types = [])
      *
      * @return int
      */
-    public function count()
+    public function count(): int
     {
         return count($this->_conditions);
     }
@@ -504,13 +562,13 @@ public function count()
     /**
      * Builds equal condition or assignment with identifier wrapping.
      *
-     * @param string $left Left join condition field name.
-     * @param string $right Right join condition field name.
+     * @param string $leftField Left join condition field name.
+     * @param string $rightField Right join condition field name.
      * @return $this
      */
-    public function equalFields($left, $right)
+    public function equalFields(string $leftField, string $rightField)
     {
-        $wrapIdentifier = function ($field) {
+        $wrapIdentifier = function ($field): ExpressionInterface {
             if ($field instanceof ExpressionInterface) {
                 return $field;
             }
@@ -518,67 +576,55 @@ public function equalFields($left, $right)
             return new IdentifierExpression($field);
         };
 
-        return $this->eq($wrapIdentifier($left), $wrapIdentifier($right));
+        return $this->eq($wrapIdentifier($leftField), $wrapIdentifier($rightField));
     }
 
     /**
-     * Returns the string representation of this object so that it can be used in a
-     * SQL query. Note that values condition values are not included in the string,
-     * in their place placeholders are put and can be replaced by the quoted values
-     * accordingly.
-     *
-     * @param \Cake\Database\ValueBinder $generator Placeholder generator object
-     * @return string
+     * @inheritDoc
      */
-    public function sql(ValueBinder $generator)
+    public function sql(ValueBinder $binder): string
     {
         $len = $this->count();
         if ($len === 0) {
             return '';
         }
         $conjunction = $this->_conjunction;
-        $template = ($len === 1) ? '%s' : '(%s)';
+        $template = $len === 1 ? '%s' : '(%s)';
         $parts = [];
         foreach ($this->_conditions as $part) {
             if ($part instanceof Query) {
-                $part = '(' . $part->sql($generator) . ')';
+                $part = '(' . $part->sql($binder) . ')';
             } elseif ($part instanceof ExpressionInterface) {
-                $part = $part->sql($generator);
+                $part = $part->sql($binder);
             }
-            if (strlen($part)) {
+            if ($part !== '') {
                 $parts[] = $part;
             }
         }
 
-        return sprintf($template, implode(" $conjunction ", $parts));
+        return sprintf($template, implode(" {$conjunction} ", $parts));
     }
 
     /**
-     * Traverses the tree structure of this query expression by executing a callback
-     * function for each of the conditions that are included in this object.
-     * Useful for compiling the final expression, or doing
-     * introspection in the structure.
-     *
-     * Callback function receives as only argument an instance of a QueryExpression
-     *
-     * @param callable $callable The callable to apply to all sub-expressions.
-     * @return void
+     * @inheritDoc
      */
-    public function traverse(callable $callable)
+    public function traverse(Closure $callback)
     {
         foreach ($this->_conditions as $c) {
             if ($c instanceof ExpressionInterface) {
-                $callable($c);
-                $c->traverse($callable);
+                $callback($c);
+                $c->traverse($callback);
             }
         }
+
+        return $this;
     }
 
     /**
-     * Executes a callable function for each of the parts that form this expression.
+     * Executes a callback for each of the parts that form this expression.
      *
-     * The callable function is required to return a value with which the currently
-     * visited part will be replaced. If the callable function returns null then
+     * The callback is required to return a value with which the currently
+     * visited part will be replaced. If the callback returns null then
      * the part will be discarded completely from this expression.
      *
      * The callback function will receive each of the conditions as first param and
@@ -586,15 +632,15 @@ public function traverse(callable $callable)
      * passed by reference, this will enable you to change the key under which the
      * modified part is stored.
      *
-     * @param callable $callable The callable to apply to each part.
+     * @param \Closure $callback The callback to run for each part
      * @return $this
      */
-    public function iterateParts(callable $callable)
+    public function iterateParts(Closure $callback)
     {
         $parts = [];
         foreach ($this->_conditions as $k => $c) {
-            $key =& $k;
-            $part = $callable($c, $key);
+            $key = &$k;
+            $part = $callback($c, $key);
             if ($part !== null) {
                 $parts[$key] = $part;
             }
@@ -604,51 +650,13 @@ public function iterateParts(callable $callable)
         return $this;
     }
 
-    /**
-     * Helps calling the `and()` and `or()` methods transparently.
-     *
-     * @param string $method The method name.
-     * @param array $args The arguments to pass to the method.
-     * @return \Cake\Database\Expression\QueryExpression
-     * @throws \BadMethodCallException
-     */
-    public function __call($method, $args)
-    {
-        if (in_array($method, ['and', 'or'])) {
-            return call_user_func_array([$this, $method . '_'], $args);
-        }
-        throw new BadMethodCallException(sprintf('Method %s does not exist', $method));
-    }
-
-    /**
-     * Check whether or not a callable is acceptable.
-     *
-     * We don't accept ['class', 'method'] style callbacks,
-     * as they often contain user input and arrays of strings
-     * are easy to sneak in.
-     *
-     * @param callable $c The callable to check.
-     * @return bool Valid callable.
-     */
-    public function isCallable($c)
-    {
-        if (is_string($c)) {
-            return false;
-        }
-        if (is_object($c) && is_callable($c)) {
-            return true;
-        }
-
-        return is_array($c) && isset($c[0]) && is_object($c[0]) && is_callable($c);
-    }
-
     /**
      * Returns true if this expression contains any other nested
      * ExpressionInterface objects
      *
      * @return bool
      */
-    public function hasNestedExpression()
+    public function hasNestedExpression(): bool
     {
         foreach ($this->_conditions as $c) {
             if ($c instanceof ExpressionInterface) {
@@ -666,10 +674,10 @@ public function hasNestedExpression()
      * representation is wrapped around an adequate instance or of this class.
      *
      * @param array $conditions list of conditions to be stored in this object
-     * @param array $types list of types associated on fields referenced in $conditions
+     * @param array $types list of types associated on fields referenced in $conditions
      * @return void
      */
-    protected function _addConditions(array $conditions, array $types)
+    protected function _addConditions(array $conditions, array $types): void
     {
         $operators = ['and', 'or', 'xor'];
 
@@ -678,36 +686,45 @@ protected function _addConditions(array $conditions, array $types)
         foreach ($conditions as $k => $c) {
             $numericKey = is_numeric($k);
 
+            if ($c instanceof Closure) {
+                $expr = new static([], $typeMap);
+                $c = $c($expr, $this);
+            }
+
             if ($numericKey && empty($c)) {
                 continue;
             }
 
-            if ($this->isCallable($c)) {
-                $expr = new static([], $typeMap);
-                $c = $c($expr, $this);
+            $isArray = is_array($c);
+            $isOperator = false;
+            $isNot = false;
+            if (!$numericKey) {
+                $normalizedKey = strtolower($k);
+                $isOperator = in_array($normalizedKey, $operators, true);
+                $isNot = $normalizedKey === 'not';
             }
 
-            if ($numericKey && is_string($c)) {
-                $this->_conditions[] = $c;
+            if (($isOperator || $isNot) && ($isArray || $c instanceof Countable) && count($c) === 0) {
                 continue;
             }
 
-            if ($numericKey && is_array($c) || in_array(strtolower($k), $operators)) {
-                $this->_conditions[] = new static($c, $typeMap, $numericKey ? 'AND' : $k);
+            if ($numericKey && $c instanceof ExpressionInterface) {
+                $this->_conditions[] = $c;
                 continue;
             }
 
-            if (strtolower($k) === 'not') {
-                $this->_conditions[] = new UnaryExpression('NOT', new static($c, $typeMap));
+            if ($numericKey && is_string($c)) {
+                $this->_conditions[] = $c;
                 continue;
             }
 
-            if ($c instanceof self && count($c) === 0) {
+            if ($numericKey && $isArray || $isOperator) {
+                $this->_conditions[] = new static($c, $typeMap, $numericKey ? 'AND' : $k);
                 continue;
             }
 
-            if ($numericKey && $c instanceof ExpressionInterface) {
-                $this->_conditions[] = $c;
+            if ($isNot) {
+                $this->_conditions[] = new UnaryExpression('NOT', new static($c, $typeMap));
                 continue;
             }
 
@@ -724,28 +741,50 @@ protected function _addConditions(array $conditions, array $types)
      * generating the placeholders and replacing the values by them, while storing
      * the value elsewhere for future binding.
      *
-     * @param string $field The value from with the actual field and operator will
+     * @param string $condition The value from which the actual field and operator will
      * be extracted.
      * @param mixed $value The value to be bound to a placeholder for the field
-     * @return string|\Cake\Database\ExpressionInterface
+     * @return \Cake\Database\ExpressionInterface|string
+     * @throws \InvalidArgumentException If operator is invalid or missing on NULL usage.
      */
-    protected function _parseCondition($field, $value)
+    protected function _parseCondition(string $condition, mixed $value): ExpressionInterface|string
     {
+        $expression = trim($condition);
         $operator = '=';
-        $expression = $field;
-        $parts = explode(' ', trim($field), 2);
 
-        if (count($parts) > 1) {
-            list($expression, $operator) = $parts;
+        $spaces = substr_count($expression, ' ');
+        // Handle expression values that contain multiple spaces, such as
+        // operators with a space in them like `field IS NOT` and
+        // `field NOT LIKE`, or combinations with function expressions
+        // like `CONCAT(first_name, ' ', last_name) IN`.
+        if ($spaces > 1) {
+            $parts = explode(' ', $expression);
+            if (preg_match('/is not distinct from$/i', $expression)) {
+                $operator = implode(' ', array_slice($parts, -4));
+                $parts = array_slice($parts, 0, -4);
+            } elseif (preg_match('/is distinct from$/i', $expression)) {
+                $operator = implode(' ', array_slice($parts, -3));
+                $parts = array_slice($parts, 0, -3);
+            } elseif (preg_match('/(is not|not \w+)$/i', $expression)) {
+                $operator = implode(' ', array_slice($parts, -2));
+                $parts = array_slice($parts, 0, -2);
+            } else {
+                $operator = array_pop($parts);
+            }
+            $expression = implode(' ', $parts);
+        } elseif ($spaces === 1) {
+            $parts = explode(' ', $expression, 2);
+            [$expression, $operator] = $parts;
         }
+        $operator = strtoupper(trim($operator));
 
         $type = $this->getTypeMap()->type($expression);
-        $operator = strtolower(trim($operator));
-
-        $typeMultiple = strpos($type, '[]') !== false;
-        if (in_array($operator, ['in', 'not in']) || $typeMultiple) {
+        $typeMultiple = (is_string($type) && str_contains($type, '[]'));
+        if (in_array($operator, ['IN', 'NOT IN'], true) || $typeMultiple) {
             $type = $type ?: 'string';
-            $type .= $typeMultiple ? null : '[]';
+            if (!$typeMultiple) {
+                $type .= '[]';
+            }
             $operator = $operator === '=' ? 'IN' : $operator;
             $operator = $operator === '!=' ? 'NOT IN' : $operator;
             $typeMultiple = true;
@@ -755,53 +794,68 @@ protected function _parseCondition($field, $value)
             $value = $value instanceof ExpressionInterface ? $value : (array)$value;
         }
 
-        if ($operator === 'is' && $value === null) {
+        if ($operator === 'IS' && $value === null) {
             return new UnaryExpression(
                 'IS NULL',
                 new IdentifierExpression($expression),
-                UnaryExpression::POSTFIX
+                UnaryExpression::POSTFIX,
             );
         }
 
-        if ($operator === 'is not' && $value === null) {
+        if ($operator === 'IS NOT' && $value === null) {
             return new UnaryExpression(
                 'IS NOT NULL',
                 new IdentifierExpression($expression),
-                UnaryExpression::POSTFIX
+                UnaryExpression::POSTFIX,
             );
         }
 
-        if ($operator === 'is' && $value !== null) {
+        if ($operator === 'IS' && $value !== null) {
             $operator = '=';
         }
 
-        if ($operator === 'is not' && $value !== null) {
+        if ($operator === 'IS NOT' && $value !== null) {
             $operator = '!=';
         }
 
-        return new Comparison($expression, $value, $type, $operator);
+        if (in_array($operator, ['IS DISTINCT FROM', 'IS NOT DISTINCT FROM'], true)) {
+            return new DistinctComparisonExpression($expression, $value, $type, $operator);
+        }
+
+        if (
+            $value === null &&
+            $this->_conjunction !== ','
+        ) {
+            throw new InvalidArgumentException(
+                sprintf(
+                    'Expression `%s` has invalid `null` value.'
+                    . ' If `null` is a valid value, operator (IS, IS NOT, IS DISTINCT FROM, IS NOT DISTINCT FROM) is missing.',
+                    $expression,
+                ),
+            );
+        }
+
+        return new ComparisonExpression($expression, $value, $type, $operator);
     }
 
     /**
      * Returns the type name for the passed field if it was stored in the typeMap
      *
-     * @param string|\Cake\Database\Expression\IdentifierExpression $field The field name to get a type for.
+     * @param \Cake\Database\ExpressionInterface|string $field The field name to get a type for.
      * @return string|null The computed type or null, if the type is unknown.
      */
-    protected function _calculateType($field)
+    protected function _calculateType(ExpressionInterface|string $field): ?string
     {
         $field = $field instanceof IdentifierExpression ? $field->getIdentifier() : $field;
-        if (is_string($field)) {
-            return $this->getTypeMap()->type($field);
+        if (!is_string($field)) {
+            return null;
         }
 
-        return null;
+        return $this->getTypeMap()->type($field);
     }
 
     /**
      * Clone this object and its subtree of expressions.
-     *
-     * @return void
      */
     public function __clone()
     {
diff --git a/src/Database/Expression/StringAggExpression.php b/src/Database/Expression/StringAggExpression.php
new file mode 100644
index 00000000000..f842be6b37c
--- /dev/null
+++ b/src/Database/Expression/StringAggExpression.php
@@ -0,0 +1,205 @@
+|array $types Types for function arguments.
+     * @param \Cake\Database\ExpressionInterface|array|string|null $orderBy Aggregate-local ordering.
+     */
+    public function __construct(array $params = [], array $types = [], ExpressionInterface|array|string|null $orderBy = null)
+    {
+        parent::__construct('STRING_AGG', $params, $types);
+        if ($orderBy !== null) {
+            $this->setAggregateOrderBy($orderBy);
+        }
+    }
+
+    /**
+     * Sets aggregate-local ordering.
+     *
+     * @param \Cake\Database\ExpressionInterface|array|string $fields The sort columns.
+     * @return $this
+     */
+    public function setAggregateOrderBy(ExpressionInterface|array|string $fields)
+    {
+        $this->aggregateOrderBy ??= new OrderByExpression();
+        $this->aggregateOrderBy->add($fields);
+
+        return $this;
+    }
+
+    /**
+     * Sets the SQL syntax variant.
+     *
+     * @param string $syntax The syntax variant.
+     * @return $this
+     */
+    public function setSyntax(string $syntax)
+    {
+        $allowed = [
+            static::SYNTAX_STANDARD,
+            static::SYNTAX_WITHIN_GROUP,
+            static::SYNTAX_GROUP_CONCAT,
+        ];
+        if (!in_array($syntax, $allowed, true)) {
+            throw new InvalidArgumentException(sprintf('Unsupported string aggregation syntax `%s`.', $syntax));
+        }
+
+        $this->syntax = $syntax;
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function sql(ValueBinder $binder): string
+    {
+        $parts = array_map(fn($part) => $this->stringifyPart($part, $binder), $this->_conditions);
+        [$value, $separator] = $parts + [null, null];
+
+        $sql = match ($this->syntax) {
+            static::SYNTAX_GROUP_CONCAT => $this->_name . sprintf(
+                '(%s%s SEPARATOR %s)',
+                $value,
+                $this->aggregateOrderBy ? ' ' . $this->aggregateOrderBy->sql($binder) : '',
+                $separator,
+            ),
+            static::SYNTAX_WITHIN_GROUP => $this->_name . sprintf(
+                '(%s, %s)%s',
+                $value,
+                $separator,
+                $this->aggregateOrderBy ? ' WITHIN GROUP (' . $this->aggregateOrderBy->sql($binder) . ')' : '',
+            ),
+            default => $this->_name . sprintf(
+                '(%s, %s%s)',
+                $value,
+                $separator,
+                $this->aggregateOrderBy ? ' ' . $this->aggregateOrderBy->sql($binder) : '',
+            ),
+        };
+
+        if ($this->filter !== null) {
+            $sql .= ' FILTER (WHERE ' . $this->filter->sql($binder) . ')';
+        }
+        if ($this->window !== null) {
+            if ($this->window->isNamedOnly()) {
+                $sql .= ' OVER ' . $this->window->sql($binder);
+            } else {
+                $sql .= ' OVER (' . $this->window->sql($binder) . ')';
+            }
+        }
+
+        return $sql;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function traverse(Closure $callback)
+    {
+        parent::traverse($callback);
+        if ($this->aggregateOrderBy !== null) {
+            $callback($this->aggregateOrderBy);
+            $this->aggregateOrderBy->traverse($callback);
+        }
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function count(): int
+    {
+        $count = parent::count();
+        if ($this->aggregateOrderBy !== null) {
+            $count += 1;
+        }
+
+        return $count;
+    }
+
+    /**
+     * Clone this object and its subtree of expressions.
+     */
+    public function __clone()
+    {
+        parent::__clone();
+        if ($this->aggregateOrderBy !== null) {
+            $this->aggregateOrderBy = clone $this->aggregateOrderBy;
+        }
+    }
+
+    /**
+     * Converts a function argument into SQL.
+     *
+     * @param mixed $part Function argument.
+     * @param \Cake\Database\ValueBinder $binder Value binder.
+     * @return string
+     */
+    protected function stringifyPart(mixed $part, ValueBinder $binder): string
+    {
+        if ($part instanceof Query) {
+            return sprintf('(%s)', $part->sql($binder));
+        }
+        if ($part instanceof ExpressionInterface) {
+            return $part->sql($binder);
+        }
+        if (is_array($part)) {
+            $placeholder = $binder->placeholder('param');
+            $binder->bind($placeholder, $part['value'], $part['type']);
+
+            return $placeholder;
+        }
+
+        return (string)$part;
+    }
+}
diff --git a/src/Database/Expression/StringExpression.php b/src/Database/Expression/StringExpression.php
new file mode 100644
index 00000000000..4291d5b3bb1
--- /dev/null
+++ b/src/Database/Expression/StringExpression.php
@@ -0,0 +1,87 @@
+string = $string;
+        $this->collation = $collation;
+    }
+
+    /**
+     * Sets the string collation.
+     *
+     * @param string $collation String collation
+     * @return void
+     */
+    public function setCollation(string $collation): void
+    {
+        $this->collation = $collation;
+    }
+
+    /**
+     * Returns the string collation.
+     *
+     * @return string
+     */
+    public function getCollation(): string
+    {
+        return $this->collation;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function sql(ValueBinder $binder): string
+    {
+        $placeholder = $binder->placeholder('c');
+        $binder->bind($placeholder, $this->string, 'string');
+
+        return $placeholder . ' COLLATE ' . $this->collation;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function traverse(Closure $callback)
+    {
+        return $this;
+    }
+}
diff --git a/src/Database/Expression/TupleComparison.php b/src/Database/Expression/TupleComparison.php
index 40ea6648f98..8859b282e1d 100644
--- a/src/Database/Expression/TupleComparison.php
+++ b/src/Database/Expression/TupleComparison.php
@@ -1,4 +1,6 @@
 
+     */
+    protected array $types;
 
     /**
      * Constructor
      *
-     * @param string|array|\Cake\Database\ExpressionInterface $fields the fields to use to form a tuple
-     * @param array|\Cake\Database\ExpressionInterface $values the values to use to form a tuple
-     * @param array $types the types names to use for casting each of the values, only
+     * @param \Cake\Database\ExpressionInterface|array|string $fields the fields to use to form a tuple
+     * @param \Cake\Database\ExpressionInterface|array $values the values to use to form a tuple
+     * @param array $types the types names to use for casting each of the values, only
      * one type per position in the value array in needed
      * @param string $conjunction the operator used for comparing field and value
      */
-    public function __construct($fields, $values, $types = [], $conjunction = '=')
+    public function __construct(
+        ExpressionInterface|array|string $fields,
+        ExpressionInterface|array $values,
+        array $types = [],
+        string $conjunction = '=',
+    ) {
+        $this->types = $types;
+        $this->setField($fields);
+        $this->_operator = $conjunction;
+        $this->setValue($values);
+    }
+
+    /**
+     * Returns the type to be used for casting the value to a database representation
+     *
+     * @return array
+     */
+    public function getType(): array
     {
-        parent::__construct($fields, $values, $types, $conjunction);
-        $this->_type = (array)$types;
+        return $this->types;
     }
 
     /**
-     * Convert the expression into a SQL fragment.
+     * Sets the value
      *
-     * @param \Cake\Database\ValueBinder $generator Placeholder generator object
-     * @return string
+     * @param mixed $value The value to compare
+     * @return void
      */
-    public function sql(ValueBinder $generator)
+    public function setValue(mixed $value): void
+    {
+        if ($this->isMulti()) {
+            if (is_array($value) && !is_array(current($value))) {
+                throw new InvalidArgumentException(
+                    'Multi-tuple comparisons require a multi-tuple value, single-tuple given.',
+                );
+            }
+        } elseif (is_array($value) && is_array(current($value))) {
+            throw new InvalidArgumentException(
+                'Single-tuple comparisons require a single-tuple value, multi-tuple given.',
+            );
+        }
+
+        $this->_value = $value;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function sql(ValueBinder $binder): string
     {
         $template = '(%s) %s (%s)';
         $fields = [];
@@ -56,10 +102,10 @@ public function sql(ValueBinder $generator)
         }
 
         foreach ($originalFields as $field) {
-            $fields[] = $field instanceof ExpressionInterface ? $field->sql($generator) : $field;
+            $fields[] = $field instanceof ExpressionInterface ? $field->sql($binder) : $field;
         }
 
-        $values = $this->_stringifyValues($generator);
+        $values = $this->_stringifyValues($binder);
 
         $field = implode(', ', $fields);
 
@@ -70,97 +116,90 @@ public function sql(ValueBinder $generator)
      * Returns a string with the values as placeholders in a string to be used
      * for the SQL version of this expression
      *
-     * @param \Cake\Database\ValueBinder $generator The value binder to convert expressions with.
+     * @param \Cake\Database\ValueBinder $binder The value binder to convert expressions with.
      * @return string
      */
-    protected function _stringifyValues($generator)
+    protected function _stringifyValues(ValueBinder $binder): string
     {
         $values = [];
         $parts = $this->getValue();
 
         if ($parts instanceof ExpressionInterface) {
-            return $parts->sql($generator);
+            return $parts->sql($binder);
         }
 
         foreach ($parts as $i => $value) {
             if ($value instanceof ExpressionInterface) {
-                $values[] = $value->sql($generator);
+                $values[] = $value->sql($binder);
                 continue;
             }
 
-            $type = $this->_type;
-            $multiType = is_array($type);
-            $isMulti = $this->isMulti();
-            $type = $multiType ? $type : str_replace('[]', '', $type);
-            $type = $type ?: null;
+            $type = $this->types;
+            $isMultiOperation = $this->isMulti();
+            if (!$type) {
+                $type = null;
+            }
 
-            if ($isMulti) {
+            if ($isMultiOperation) {
                 $bound = [];
                 foreach ($value as $k => $val) {
-                    $valType = $multiType ? $type[$k] : $type;
-                    $bound[] = $this->_bindValue($generator, $val, $valType);
+                    $valType = $type && isset($type[$k]) ? $type[$k] : $type;
+                    assert($valType === null || is_scalar($valType));
+                    $bound[] = $this->_bindValue($val, $binder, $valType);
                 }
 
                 $values[] = sprintf('(%s)', implode(',', $bound));
                 continue;
             }
 
-            $valType = $multiType && isset($type[$i]) ? $type[$i] : $type;
-            $values[] = $this->_bindValue($generator, $value, $valType);
+            $valType = $type && isset($type[$i]) ? $type[$i] : $type;
+            assert($valType === null || is_scalar($valType));
+            $values[] = $this->_bindValue($value, $binder, $valType);
         }
 
         return implode(', ', $values);
     }
 
     /**
-     * Registers a value in the placeholder generator and returns the generated
-     * placeholder
-     *
-     * @param \Cake\Database\ValueBinder $generator The value binder
-     * @param mixed $value The value to bind
-     * @param string $type The type to use
-     * @return string generated placeholder
+     * @inheritDoc
      */
-    protected function _bindValue($generator, $value, $type)
+    protected function _bindValue(mixed $value, ValueBinder $binder, ?string $type = null): string
     {
-        $placeholder = $generator->placeholder('tuple');
-        $generator->bind($placeholder, $value, $type);
+        $placeholder = $binder->placeholder('tuple');
+        $binder->bind($placeholder, $value, $type);
 
         return $placeholder;
     }
 
     /**
-     * Traverses the tree of expressions stored in this object, visiting first
-     * expressions in the left hand side and then the rest.
-     *
-     * Callback function receives as its only argument an instance of an ExpressionInterface
-     *
-     * @param callable $callable The callable to apply to sub-expressions
-     * @return void
+     * @inheritDoc
      */
-    public function traverse(callable $callable)
+    public function traverse(Closure $callback)
     {
-        foreach ($this->getField() as $field) {
-            $this->_traverseValue($field, $callable);
+        $fields = (array)$this->getField();
+        foreach ($fields as $field) {
+            $this->_traverseValue($field, $callback);
         }
 
         $value = $this->getValue();
         if ($value instanceof ExpressionInterface) {
-            $callable($value);
-            $value->traverse($callable);
+            $callback($value);
+            $value->traverse($callback);
 
-            return;
+            return $this;
         }
 
-        foreach ($value as $i => $val) {
+        foreach ($value as $val) {
             if ($this->isMulti()) {
                 foreach ($val as $v) {
-                    $this->_traverseValue($v, $callable);
+                    $this->_traverseValue($v, $callback);
                 }
             } else {
-                $this->_traverseValue($val, $callable);
+                $this->_traverseValue($val, $callback);
             }
         }
+
+        return $this;
     }
 
     /**
@@ -168,14 +207,14 @@ public function traverse(callable $callable)
      * it is an ExpressionInterface
      *
      * @param mixed $value The value to traverse
-     * @param callable $callable The callable to use when traversing
+     * @param \Closure $callback The callback to use when traversing
      * @return void
      */
-    protected function _traverseValue($value, $callable)
+    protected function _traverseValue(mixed $value, Closure $callback): void
     {
         if ($value instanceof ExpressionInterface) {
-            $callable($value);
-            $value->traverse($callable);
+            $callback($value);
+            $value->traverse($callback);
         }
     }
 
@@ -185,8 +224,8 @@ protected function _traverseValue($value, $callable)
      *
      * @return bool
      */
-    public function isMulti()
+    public function isMulti(): bool
     {
-        return in_array(strtolower($this->_operator), ['in', 'not in']);
+        return in_array(strtolower($this->_operator), ['in', 'not in'], true);
     }
 }
diff --git a/src/Database/Expression/UnaryExpression.php b/src/Database/Expression/UnaryExpression.php
index f58fd675ce3..9675b313832 100644
--- a/src/Database/Expression/UnaryExpression.php
+++ b/src/Database/Expression/UnaryExpression.php
@@ -1,4 +1,6 @@
 _operator = $operator;
         $this->_value = $value;
-        $this->_mode = $mode;
+        $this->position = $position;
     }
 
     /**
-     * Converts the expression to its string representation
-     *
-     * @param \Cake\Database\ValueBinder $generator Placeholder generator object
-     * @return string
+     * @inheritDoc
      */
-    public function sql(ValueBinder $generator)
+    public function sql(ValueBinder $binder): string
     {
         $operand = $this->_value;
         if ($operand instanceof ExpressionInterface) {
-            $operand = $operand->sql($generator);
+            $operand = $operand->sql($binder);
         }
 
-        if ($this->_mode === self::POSTFIX) {
+        if ($this->position === self::POSTFIX) {
             return '(' . $operand . ') ' . $this->_operator;
         }
 
@@ -91,21 +92,20 @@ public function sql(ValueBinder $generator)
     }
 
     /**
-     * {@inheritDoc}
-     *
+     * @inheritDoc
      */
-    public function traverse(callable $callable)
+    public function traverse(Closure $callback)
     {
         if ($this->_value instanceof ExpressionInterface) {
-            $callable($this->_value);
-            $this->_value->traverse($callable);
+            $callback($this->_value);
+            $this->_value->traverse($callback);
         }
+
+        return $this;
     }
 
     /**
      * Perform a deep clone of the inner expression.
-     *
-     * @return void
      */
     public function __clone()
     {
diff --git a/src/Database/Expression/ValuesExpression.php b/src/Database/Expression/ValuesExpression.php
index 90781e5dbbe..7a15c12a142 100644
--- a/src/Database/Expression/ValuesExpression.php
+++ b/src/Database/Expression/ValuesExpression.php
@@ -1,4 +1,6 @@
  type names
      */
-    public function __construct(array $columns, $typeMap)
+    public function __construct(array $columns, TypeMap $typeMap)
     {
         $this->_columns = $columns;
         $this->setTypeMap($typeMap);
@@ -77,38 +80,45 @@ public function __construct(array $columns, $typeMap)
     /**
      * Add a row of data to be inserted.
      *
-     * @param array|\Cake\Database\Query $data Array of data to append into the insert, or
+     * @param \Cake\Database\Query|array $values Array of data to append into the insert, or
      *   a query for doing INSERT INTO .. SELECT style commands
      * @return void
-     * @throws \Cake\Database\Exception When mixing array + Query data types.
+     * @throws \Cake\Database\Exception\DatabaseException When mixing array and Query data types.
      */
-    public function add($data)
+    public function add(Query|array $values): void
     {
-        if ((count($this->_values) && $data instanceof Query) ||
-            ($this->_query && is_array($data))
+        if (
+            (
+                count($this->_values) &&
+                $values instanceof Query
+            ) ||
+            (
+                $this->_query &&
+                is_array($values)
+            )
         ) {
-            throw new Exception(
-                'You cannot mix subqueries and array data in inserts.'
+            throw new DatabaseException(
+                'You cannot mix subqueries and array values in inserts.',
             );
         }
-        if ($data instanceof Query) {
-            $this->setQuery($data);
+        if ($values instanceof Query) {
+            $this->setQuery($values);
 
             return;
         }
-        $this->_values[] = $data;
+        $this->_values[] = $values;
         $this->_castedExpressions = false;
     }
 
     /**
      * Sets the columns to be inserted.
      *
-     * @param array $cols Array with columns to be inserted.
+     * @param array $columns Array with columns to be inserted.
      * @return $this
      */
-    public function setColumns($cols)
+    public function setColumns(array $columns)
     {
-        $this->_columns = $cols;
+        $this->_columns = $columns;
         $this->_castedExpressions = false;
 
         return $this;
@@ -119,28 +129,11 @@ public function setColumns($cols)
      *
      * @return array
      */
-    public function getColumns()
+    public function getColumns(): array
     {
         return $this->_columns;
     }
 
-    /**
-     * Sets the columns to be inserted. If no params are passed, then it returns
-     * the currently stored columns.
-     *
-     * @deprecated 3.4.0 Use setColumns()/getColumns() instead.
-     * @param array|null $cols Array with columns to be inserted.
-     * @return array|$this
-     */
-    public function columns($cols = null)
-    {
-        if ($cols !== null) {
-            return $this->setColumns($cols);
-        }
-
-        return $this->getColumns();
-    }
-
     /**
      * Get the bare column names.
      *
@@ -149,7 +142,7 @@ public function columns($cols = null)
      *
      * @return array
      */
-    protected function _columnNames()
+    protected function _columnNames(): array
     {
         $columns = [];
         foreach ($this->_columns as $col) {
@@ -168,7 +161,7 @@ protected function _columnNames()
      * @param array $values Array with values to be inserted.
      * @return $this
      */
-    public function setValues($values)
+    public function setValues(array $values)
     {
         $this->_values = $values;
         $this->_castedExpressions = false;
@@ -181,7 +174,7 @@ public function setValues($values)
      *
      * @return array
      */
-    public function getValues()
+    public function getValues(): array
     {
         if (!$this->_castedExpressions) {
             $this->_processExpressions();
@@ -190,23 +183,6 @@ public function getValues()
         return $this->_values;
     }
 
-    /**
-     * Sets the values to be inserted. If no params are passed, then it returns
-     * the currently stored values
-     *
-     * @deprecated 3.4.0 Use setValues()/getValues() instead.
-     * @param array|null $values Array with values to be inserted.
-     * @return array|$this
-     */
-    public function values($values = null)
-    {
-        if ($values !== null) {
-            return $this->setValues($values);
-        }
-
-        return $this->getValues();
-    }
-
     /**
      * Sets the query object to be used as the values expression to be evaluated
      * to insert records in the table.
@@ -227,38 +203,17 @@ public function setQuery(Query $query)
      *
      * @return \Cake\Database\Query|null
      */
-    public function getQuery()
+    public function getQuery(): ?Query
     {
         return $this->_query;
     }
 
     /**
-     * Sets the query object to be used as the values expression to be evaluated
-     * to insert records in the table. If no params are passed, then it returns
-     * the currently stored query
-     *
-     * @deprecated 3.4.0 Use setQuery()/getQuery() instead.
-     * @param \Cake\Database\Query|null $query The query to set
-     * @return \Cake\Database\Query|null|$this
-     */
-    public function query(Query $query = null)
-    {
-        if ($query !== null) {
-            return $this->setQuery($query);
-        }
-
-        return $this->getQuery();
-    }
-
-    /**
-     * Convert the values into a SQL string with placeholders.
-     *
-     * @param \Cake\Database\ValueBinder $generator Placeholder generator object
-     * @return string
+     * @inheritDoc
      */
-    public function sql(ValueBinder $generator)
+    public function sql(ValueBinder $binder): string
     {
-        if (empty($this->_values) && empty($this->_query)) {
+        if (!$this->_values && $this->_query === null) {
             return '';
         }
 
@@ -284,38 +239,33 @@ public function sql(ValueBinder $generator)
                 $value = $row[$column];
 
                 if ($value instanceof ExpressionInterface) {
-                    $rowPlaceholders[] = '(' . $value->sql($generator) . ')';
+                    $rowPlaceholders[] = '(' . $value->sql($binder) . ')';
                     continue;
                 }
 
-                $placeholder = $generator->placeholder('c');
+                $placeholder = $binder->placeholder('c');
                 $rowPlaceholders[] = $placeholder;
-                $generator->bind($placeholder, $value, $types[$column]);
+                $binder->bind($placeholder, $value, $types[$column]);
             }
 
             $placeholders[] = implode(', ', $rowPlaceholders);
         }
 
-        if ($this->getQuery()) {
-            return ' ' . $this->getQuery()->sql($generator);
+        $query = $this->getQuery();
+        if ($query) {
+            return ' ' . $query->sql($binder);
         }
 
         return sprintf(' VALUES (%s)', implode('), (', $placeholders));
     }
 
     /**
-     * Traverse the values expression.
-     *
-     * This method will also traverse any queries that are to be used in the INSERT
-     * values.
-     *
-     * @param callable $visitor The visitor to traverse the expression with.
-     * @return void
+     * @inheritDoc
      */
-    public function traverse(callable $visitor)
+    public function traverse(Closure $callback)
     {
         if ($this->_query) {
-            return;
+            return $this;
         }
 
         if (!$this->_castedExpressions) {
@@ -324,18 +274,20 @@ public function traverse(callable $visitor)
 
         foreach ($this->_values as $v) {
             if ($v instanceof ExpressionInterface) {
-                $v->traverse($visitor);
+                $v->traverse($callback);
             }
             if (!is_array($v)) {
                 continue;
             }
-            foreach ($v as $column => $field) {
+            foreach ($v as $field) {
                 if ($field instanceof ExpressionInterface) {
-                    $visitor($field);
-                    $field->traverse($visitor);
+                    $callback($field);
+                    $field->traverse($callback);
                 }
             }
         }
+
+        return $this;
     }
 
     /**
@@ -343,14 +295,14 @@ public function traverse(callable $visitor)
      *
      * @return void
      */
-    protected function _processExpressions()
+    protected function _processExpressions(): void
     {
         $types = [];
         $typeMap = $this->getTypeMap();
 
         $columns = $this->_columnNames();
         foreach ($columns as $c) {
-            if (!is_scalar($c)) {
+            if (!is_string($c) && !is_int($c)) {
                 continue;
             }
             $types[$c] = $typeMap->type($c);
@@ -358,13 +310,13 @@ protected function _processExpressions()
 
         $types = $this->_requiresToExpressionCasting($types);
 
-        if (empty($types)) {
+        if (!$types) {
             return;
         }
 
         foreach ($this->_values as $row => $values) {
             foreach ($types as $col => $type) {
-                /* @var \Cake\Database\Type\ExpressionTypeInterface $type */
+                /** @var \Cake\Database\Type\ExpressionTypeInterface $type */
                 $this->_values[$row][$col] = $type->toExpression($values[$col]);
             }
         }
diff --git a/src/Database/Expression/WhenThenExpression.php b/src/Database/Expression/WhenThenExpression.php
new file mode 100644
index 00000000000..cc6d51f88d0
--- /dev/null
+++ b/src/Database/Expression/WhenThenExpression.php
@@ -0,0 +1,318 @@
+
+     */
+    protected array $validClauseNames = [
+        'when',
+        'then',
+    ];
+
+    /**
+     * The type map to use when using an array of conditions for the
+     * `WHEN` value.
+     *
+     * @var \Cake\Database\TypeMap
+     */
+    protected TypeMap $_typeMap;
+
+    /**
+     * Then `WHEN` value.
+     *
+     * @var \Cake\Database\ExpressionInterface|object|scalar|null
+     */
+    protected mixed $when = null;
+
+    /**
+     * The `WHEN` value type.
+     *
+     * @var array|string|null
+     */
+    protected array|string|null $whenType = null;
+
+    /**
+     * The `THEN` value.
+     *
+     * @var \Cake\Database\ExpressionInterface|object|scalar|null
+     */
+    protected mixed $then = null;
+
+    /**
+     * Whether the `THEN` value has been defined, eg whether `then()`
+     * has been invoked.
+     *
+     * @var bool
+     */
+    protected bool $hasThenBeenDefined = false;
+
+    /**
+     * The `THEN` result type.
+     *
+     * @var string|null
+     */
+    protected ?string $thenType = null;
+
+    /**
+     * Constructor.
+     *
+     * @param \Cake\Database\TypeMap|null $typeMap The type map to use when using an array of conditions for the `WHEN`
+     *  value.
+     */
+    public function __construct(?TypeMap $typeMap = null)
+    {
+        $this->_typeMap = $typeMap ?? new TypeMap();
+    }
+
+    /**
+     * Sets the `WHEN` value.
+     *
+     * @param object|array|string|float|int|bool $when The `WHEN` value. When using an array of
+     *  conditions, it must be compatible with `\Cake\Database\Query::where()`. Note that this argument is _not_
+     *  completely safe for use with user data, as a user supplied array would allow for raw SQL to slip in! If you
+     *  plan to use user data, either pass a single type for the `$type` argument (which forces the `$when` value to be
+     *  a non-array, and then always binds the data), use a conditions array where the user data is only passed on the
+     *  value side of the array entries, or custom bindings!
+     * @param array|string|null $type The when value type. Either an associative array when using array style
+     *  conditions, or else a string. If no type is provided, the type will be tried to be inferred from the value.
+     * @return $this
+     * @throws \InvalidArgumentException In case the `$when` argument is an empty array.
+     * @throws \InvalidArgumentException In case the `$when` argument is an array, and the `$type` argument is neither
+     * an array, nor null.
+     * @throws \InvalidArgumentException In case the `$when` argument is a non-array value, and the `$type` argument is
+     * neither a string, nor null.
+     * @see CaseStatementExpression::when() for a more detailed usage explanation.
+     */
+    public function when(object|array|string|float|int|bool $when, array|string|null $type = null)
+    {
+        if (is_array($when)) {
+            if (!$when) {
+                throw new InvalidArgumentException('The `$when` argument must be a non-empty array');
+            }
+
+            if (
+                $type !== null &&
+                !is_array($type)
+            ) {
+                throw new InvalidArgumentException(sprintf(
+                    'When using an array for the `$when` argument, the `$type` argument must be an ' .
+                    'array too, `%s` given.',
+                    get_debug_type($type),
+                ));
+            }
+
+            // avoid dirtying the type map for possible consecutive `when()` calls
+            $typeMap = clone $this->_typeMap;
+            if (
+                is_array($type) &&
+                $type !== []
+            ) {
+                $typeMap = $typeMap->setTypes($type);
+            }
+
+            $when = new QueryExpression($when, $typeMap);
+        } else {
+            if (
+                $type !== null &&
+                !is_string($type)
+            ) {
+                throw new InvalidArgumentException(sprintf(
+                    'When using a non-array value for the `$when` argument, the `$type` argument must ' .
+                    'be a string, `%s` given.',
+                    get_debug_type($type),
+                ));
+            }
+
+            if (
+                $type === null &&
+                !($when instanceof ExpressionInterface)
+            ) {
+                $type = $this->inferType($when);
+            }
+        }
+
+        $this->when = $when;
+        $this->whenType = $type;
+
+        return $this;
+    }
+
+    /**
+     * Sets the `THEN` result value.
+     *
+     * @param \Cake\Database\ExpressionInterface|object|scalar|null $result The result value.
+     * @param string|null $type The result type. If no type is provided, the type will be inferred from the given
+     *  result value.
+     * @return $this
+     */
+    public function then(mixed $result, ?string $type = null)
+    {
+        if (
+            $result !== null &&
+            !is_scalar($result) &&
+            !(is_object($result) && !($result instanceof Closure))
+        ) {
+            throw new InvalidArgumentException(sprintf(
+                'The `$result` argument must be either `null`, a scalar value, an object, ' .
+                'or an instance of `\%s`, `%s` given.',
+                ExpressionInterface::class,
+                get_debug_type($result),
+            ));
+        }
+
+        $this->then = $result;
+
+        $this->thenType = $type ?? $this->inferType($result);
+
+        $this->hasThenBeenDefined = true;
+
+        return $this;
+    }
+
+    /**
+     * Returns the expression's result value type.
+     *
+     * @return string|null
+     * @see WhenThenExpression::then()
+     */
+    public function getResultType(): ?string
+    {
+        return $this->thenType;
+    }
+
+    /**
+     * Returns the available data for the given clause.
+     *
+     * ### Available clauses
+     *
+     * The following clause names are available:
+     *
+     * * `when`: The `WHEN` value.
+     * * `then`: The `THEN` result value.
+     *
+     * @param string $clause The name of the clause to obtain.
+     * @return \Cake\Database\ExpressionInterface|object|scalar|null
+     * @throws \InvalidArgumentException In case the given clause name is invalid.
+     */
+    public function clause(string $clause): mixed
+    {
+        if (!in_array($clause, $this->validClauseNames, true)) {
+            throw new InvalidArgumentException(
+                sprintf(
+                    'The `$clause` argument must be one of `%s`, the given value `%s` is invalid.',
+                    implode('`, `', $this->validClauseNames),
+                    $clause,
+                ),
+            );
+        }
+
+        return $this->{$clause};
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function sql(ValueBinder $binder): string
+    {
+        if ($this->when === null) {
+            throw new LogicException('Case expression has incomplete when clause. Missing `when()`.');
+        }
+
+        if (!$this->hasThenBeenDefined) {
+            throw new LogicException('Case expression has incomplete when clause. Missing `then()` after `when()`.');
+        }
+
+        $when = $this->when;
+        if (
+            is_string($this->whenType) &&
+            !($when instanceof ExpressionInterface)
+        ) {
+            $when = $this->_castToExpression($when, $this->whenType);
+        }
+        if ($when instanceof Query) {
+            $when = sprintf('(%s)', $when->sql($binder));
+        } elseif ($when instanceof ExpressionInterface) {
+            $when = $when->sql($binder);
+        } else {
+            $placeholder = $binder->placeholder('c');
+            if (is_string($this->whenType)) {
+                $whenType = $this->whenType;
+            } else {
+                $whenType = null;
+            }
+            $binder->bind($placeholder, $when, $whenType);
+            $when = $placeholder;
+        }
+
+        $then = $this->compileNullableValue($binder, $this->then, $this->thenType);
+
+        return "WHEN {$when} THEN {$then}";
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function traverse(Closure $callback)
+    {
+        if ($this->when instanceof ExpressionInterface) {
+            $callback($this->when);
+            $this->when->traverse($callback);
+        }
+
+        if ($this->then instanceof ExpressionInterface) {
+            $callback($this->then);
+            $this->then->traverse($callback);
+        }
+
+        return $this;
+    }
+
+    /**
+     * Clones the inner expression objects.
+     */
+    public function __clone()
+    {
+        if ($this->when instanceof ExpressionInterface) {
+            $this->when = clone $this->when;
+        }
+
+        if ($this->then instanceof ExpressionInterface) {
+            $this->then = clone $this->then;
+        }
+    }
+}
diff --git a/src/Database/Expression/WindowExpression.php b/src/Database/Expression/WindowExpression.php
new file mode 100644
index 00000000000..327afb22f01
--- /dev/null
+++ b/src/Database/Expression/WindowExpression.php
@@ -0,0 +1,348 @@
+
+     */
+    protected array $partitions = [];
+
+    /**
+     * @var \Cake\Database\Expression\OrderByExpression|null
+     */
+    protected ?OrderByExpression $order = null;
+
+    /**
+     * @var array|null
+     */
+    protected ?array $frame = null;
+
+    /**
+     * @var string|null
+     */
+    protected ?string $exclusion = null;
+
+    /**
+     * @param string $name Window name
+     */
+    public function __construct(string $name = '')
+    {
+        $this->name = new IdentifierExpression($name);
+    }
+
+    /**
+     * Return whether is only a named window expression.
+     *
+     * These window expressions only specify a named window and do not
+     * specify their own partitions, frame or order.
+     *
+     * @return bool
+     */
+    public function isNamedOnly(): bool
+    {
+        return $this->name->getIdentifier() && (!$this->partitions && !$this->frame && !$this->order);
+    }
+
+    /**
+     * Sets the window name.
+     *
+     * @param string $name Window name
+     * @return $this
+     */
+    public function name(string $name)
+    {
+        $this->name = new IdentifierExpression($name);
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function partition(ExpressionInterface|Closure|array|string $partitions)
+    {
+        if (!$partitions) {
+            return $this;
+        }
+
+        if ($partitions instanceof Closure) {
+            $partitions = $partitions(new QueryExpression([], [], ''));
+        }
+
+        if (!is_array($partitions)) {
+            $partitions = [$partitions];
+        }
+
+        foreach ($partitions as &$partition) {
+            if (is_string($partition)) {
+                $partition = new IdentifierExpression($partition);
+            }
+        }
+
+        $this->partitions = array_merge($this->partitions, $partitions);
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function order(ExpressionInterface|Closure|array|string $fields)
+    {
+        deprecationWarning(
+            '5.0.0',
+            'WindowExpression::order() is deprecated. Use WindowExpression::orderBy() instead.',
+        );
+
+        return $this->orderBy($fields);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function orderBy(ExpressionInterface|Closure|array|string $fields)
+    {
+        if (!$fields) {
+            return $this;
+        }
+
+        $this->order ??= new OrderByExpression();
+
+        if ($fields instanceof Closure) {
+            $fields = $fields(new QueryExpression([], [], ''));
+        }
+
+        $this->order->add($fields);
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function range(ExpressionInterface|string|int|null $start, ExpressionInterface|string|int|null $end = 0)
+    {
+        return $this->frame(self::RANGE, $start, self::PRECEDING, $end, self::FOLLOWING);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function rows(?int $start, ?int $end = 0)
+    {
+        return $this->frame(self::ROWS, $start, self::PRECEDING, $end, self::FOLLOWING);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function groups(?int $start, ?int $end = 0)
+    {
+        return $this->frame(self::GROUPS, $start, self::PRECEDING, $end, self::FOLLOWING);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function frame(
+        string $type,
+        ExpressionInterface|string|int|null $startOffset,
+        string $startDirection,
+        ExpressionInterface|string|int|null $endOffset,
+        string $endDirection,
+    ) {
+        $this->frame = [
+            'type' => $type,
+            'start' => [
+                'offset' => $startOffset,
+                'direction' => $startDirection,
+            ],
+            'end' => [
+                'offset' => $endOffset,
+                'direction' => $endDirection,
+            ],
+        ];
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function excludeCurrent()
+    {
+        $this->exclusion = 'CURRENT ROW';
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function excludeGroup()
+    {
+        $this->exclusion = 'GROUP';
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function excludeTies()
+    {
+        $this->exclusion = 'TIES';
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function sql(ValueBinder $binder): string
+    {
+        $clauses = [];
+        if ($this->name->getIdentifier()) {
+            $clauses[] = $this->name->sql($binder);
+        }
+
+        if ($this->partitions) {
+            $expressions = [];
+            foreach ($this->partitions as $partition) {
+                $expressions[] = $partition->sql($binder);
+            }
+
+            $clauses[] = 'PARTITION BY ' . implode(', ', $expressions);
+        }
+
+        if ($this->order) {
+            $clauses[] = $this->order->sql($binder);
+        }
+
+        if ($this->frame) {
+            $start = $this->buildOffsetSql(
+                $binder,
+                $this->frame['start']['offset'],
+                $this->frame['start']['direction'],
+            );
+            $end = $this->buildOffsetSql(
+                $binder,
+                $this->frame['end']['offset'],
+                $this->frame['end']['direction'],
+            );
+
+            $frameSql = sprintf('%s BETWEEN %s AND %s', $this->frame['type'], $start, $end);
+
+            if ($this->exclusion !== null) {
+                $frameSql .= ' EXCLUDE ' . $this->exclusion;
+            }
+
+            $clauses[] = $frameSql;
+        }
+
+        return implode(' ', $clauses);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function traverse(Closure $callback)
+    {
+        $callback($this->name);
+        foreach ($this->partitions as $partition) {
+            $callback($partition);
+            $partition->traverse($callback);
+        }
+
+        if ($this->order) {
+            $callback($this->order);
+            $this->order->traverse($callback);
+        }
+
+        if ($this->frame !== null) {
+            $offset = $this->frame['start']['offset'];
+            if ($offset instanceof ExpressionInterface) {
+                $callback($offset);
+                $offset->traverse($callback);
+            }
+            $offset = $this->frame['end']['offset'] ?? null;
+            if ($offset instanceof ExpressionInterface) {
+                $callback($offset);
+                $offset->traverse($callback);
+            }
+        }
+
+        return $this;
+    }
+
+    /**
+     * Builds frame offset sql.
+     *
+     * @param \Cake\Database\ValueBinder $binder Value binder
+     * @param \Cake\Database\ExpressionInterface|string|int|null $offset Frame offset
+     * @param string $direction Frame offset direction
+     * @return string
+     */
+    protected function buildOffsetSql(
+        ValueBinder $binder,
+        ExpressionInterface|string|int|null $offset,
+        string $direction,
+    ): string {
+        if ($offset === 0) {
+            return 'CURRENT ROW';
+        }
+
+        if ($offset instanceof ExpressionInterface) {
+            $offset = $offset->sql($binder);
+        }
+
+        return sprintf(
+            '%s %s',
+            $offset ?? 'UNBOUNDED',
+            $direction,
+        );
+    }
+
+    /**
+     * Clone this object and its subtree of expressions.
+     */
+    public function __clone()
+    {
+        $this->name = clone $this->name;
+        foreach ($this->partitions as $i => $partition) {
+            $this->partitions[$i] = clone $partition;
+        }
+        if ($this->order !== null) {
+            $this->order = clone $this->order;
+        }
+    }
+}
diff --git a/src/Database/Expression/WindowInterface.php b/src/Database/Expression/WindowInterface.php
new file mode 100644
index 00000000000..31016cc0c31
--- /dev/null
+++ b/src/Database/Expression/WindowInterface.php
@@ -0,0 +1,172 @@
+|string $partitions Partition expressions
+     * @return $this
+     */
+    public function partition(ExpressionInterface|Closure|array|string $partitions);
+
+    /**
+     * Adds one or more order by clauses to the window.
+     *
+     * @param \Cake\Database\ExpressionInterface|\Closure|array<\Cake\Database\ExpressionInterface|string>|string $fields Order expressions
+     * @return $this
+     * @deprecated 5.0.0 Use orderBy() instead.
+     */
+    public function order(ExpressionInterface|Closure|array|string $fields);
+
+    /**
+     * Adds one or more order by clauses to the window.
+     *
+     * @param \Cake\Database\ExpressionInterface|\Closure|array<\Cake\Database\ExpressionInterface|string>|string $fields Order expressions
+     * @return $this
+     */
+    public function orderBy(ExpressionInterface|Closure|array|string $fields);
+
+    /**
+     * Adds a simple range frame to the window.
+     *
+     * `$start`:
+     *  - `0` - 'CURRENT ROW'
+     *  - `null` - 'UNBOUNDED PRECEDING'
+     *  - offset - 'offset PRECEDING'
+     *
+     * `$end`:
+     *  - `0` - 'CURRENT ROW'
+     *  - `null` - 'UNBOUNDED FOLLOWING'
+     *  - offset - 'offset FOLLOWING'
+     *
+     * If you need to use 'FOLLOWING' with frame start or
+     * 'PRECEDING' with frame end, use `frame()` instead.
+     *
+     * @param \Cake\Database\ExpressionInterface|string|int|null $start Frame start
+     * @param \Cake\Database\ExpressionInterface|string|int|null $end Frame end
+     *  If not passed in, only frame start SQL will be generated.
+     * @return $this
+     */
+    public function range(ExpressionInterface|string|int|null $start, ExpressionInterface|string|int|null $end = 0);
+
+    /**
+     * Adds a simple rows frame to the window.
+     *
+     * See `range()` for details.
+     *
+     * @param int|null $start Frame start
+     * @param int|null $end Frame end
+     *  If not passed in, only frame start SQL will be generated.
+     * @return $this
+     */
+    public function rows(?int $start, ?int $end = 0);
+
+    /**
+     * Adds a simple groups frame to the window.
+     *
+     * See `range()` for details.
+     *
+     * @param int|null $start Frame start
+     * @param int|null $end Frame end
+     *  If not passed in, only frame start SQL will be generated.
+     * @return $this
+     */
+    public function groups(?int $start, ?int $end = 0);
+
+    /**
+     * Adds a frame to the window.
+     *
+     * Use the `range()`, `rows()` or `groups()` helpers if you need simple
+     * 'BETWEEN offset PRECEDING and offset FOLLOWING' frames.
+     *
+     * You can specify any direction for both frame start and frame end.
+     *
+     * With both `$startOffset` and `$endOffset`:
+     *  - `0` - 'CURRENT ROW'
+     *  - `null` - 'UNBOUNDED'
+     *
+     * @param self::RANGE|self::ROWS|self::GROUPS $type Frame type
+     * @param \Cake\Database\ExpressionInterface|string|int|null $startOffset Frame start offset
+     * @param self::PRECEDING|self::FOLLOWING $startDirection Frame start direction
+     * @param \Cake\Database\ExpressionInterface|string|int|null $endOffset Frame end offset
+     * @param self::PRECEDING|self::FOLLOWING $endDirection Frame end direction
+     * @return $this
+     * @throws \InvalidArgumentException WHen offsets are negative.
+     */
+    public function frame(
+        string $type,
+        ExpressionInterface|string|int|null $startOffset,
+        string $startDirection,
+        ExpressionInterface|string|int|null $endOffset,
+        string $endDirection,
+    );
+
+    /**
+     * Adds current row frame exclusion.
+     *
+     * @return $this
+     */
+    public function excludeCurrent();
+
+    /**
+     * Adds group frame exclusion.
+     *
+     * @return $this
+     */
+    public function excludeGroup();
+
+    /**
+     * Adds ties frame exclusion.
+     *
+     * @return $this
+     */
+    public function excludeTies();
+}
diff --git a/src/Database/ExpressionInterface.php b/src/Database/ExpressionInterface.php
index 57b6667db7d..faee9cd3ad4 100644
--- a/src/Database/ExpressionInterface.php
+++ b/src/Database/ExpressionInterface.php
@@ -1,4 +1,6 @@
 _driver = $driver;
-        $map = $typeMap->toArray();
-        $types = Type::buildAll();
-        $result = [];
+        $this->driver = $driver;
 
-        foreach ($types as $k => $type) {
-            if ($type instanceof OptionalConvertInterface && !$type->requiresToPhpCast()) {
-                unset($types[$k]);
+        $types = TypeFactory::buildAll();
+        foreach ($typeMap->toArray() as $field => $typeName) {
+            $type = $types[$typeName] ?? null;
+            if (!$type || ($type instanceof OptionalConvertInterface && !$type->requiresToPhpCast())) {
+                continue;
             }
-        }
 
-        foreach ($map as $field => $type) {
-            if (isset($types[$type])) {
-                $result[$field] = $types[$type];
-            }
+            $this->conversions[$typeName] ??= [
+                'type' => $type,
+                'hasBatch' => $type instanceof BatchCastingInterface,
+                'fields' => [],
+            ];
+            $this->conversions[$typeName]['fields'][] = $field;
         }
-        $this->_typeMap = $result;
     }
 
     /**
      * Converts each of the fields in the array that are present in the type map
      * using the corresponding Type class.
      *
-     * @param array $row The array with the fields to be casted
-     * @return array
+     * @param mixed $row The array with the fields to be casted
+     * @return mixed
      */
-    public function __invoke($row)
+    public function __invoke(mixed $row): mixed
     {
-        foreach ($this->_typeMap as $field => $type) {
-            $row[$field] = $type->toPHP($row[$field], $this->_driver);
+        if (!is_array($row)) {
+            return $row;
+        }
+
+        foreach ($this->conversions as $conversion) {
+            /** @var \Cake\Database\TypeInterface $type */
+            $type = $conversion['type'];
+            if ($conversion['hasBatch']) {
+                /** @var \Cake\Database\Type\BatchCastingInterface $type */
+                $row = $type->manyToPHP($row, $conversion['fields'], $this->driver);
+                continue;
+            }
+
+            foreach ($conversion['fields'] as $field) {
+                $row[$field] = $type->toPHP($row[$field], $this->driver);
+            }
         }
 
         return $row;
diff --git a/src/Database/FunctionsBuilder.php b/src/Database/FunctionsBuilder.php
index 0bab11d24a2..16e084c131f 100644
--- a/src/Database/FunctionsBuilder.php
+++ b/src/Database/FunctionsBuilder.php
@@ -1,4 +1,6 @@
  'literal'];
+        $returnType = 'float';
+        if (current($types) === 'integer') {
+            $returnType = 'integer';
         }
 
-        return $this->_build($name, $expression, $types, $return);
+        return $this->aggregate('SUM', $this->toLiteralParam($expression), $types, $returnType);
     }
 
     /**
-     * Returns a FunctionExpression representing a call to SQL SUM function.
+     * Returns a AggregateExpression representing a call to SQL AVG function.
      *
-     * @param mixed $expression the function argument
+     * @param \Cake\Database\ExpressionInterface|string $expression the expression for the avg() function.
      * @param array $types list of types to bind to the arguments
-     * @return \Cake\Database\Expression\FunctionExpression
+     * @return \Cake\Database\Expression\AggregateExpression
      */
-    public function sum($expression, $types = [])
+    public function avg(ExpressionInterface|string $expression, array $types = []): AggregateExpression
     {
-        $returnType = 'float';
-        if (current($types) === 'integer') {
-            $returnType = 'integer';
-        }
-
-        return $this->_literalArgumentFunction('SUM', $expression, $types, $returnType);
+        return $this->aggregate('AVG', $this->toLiteralParam($expression), $types, 'float');
     }
 
     /**
-     * Returns a FunctionExpression representing a call to SQL AVG function.
+     * Returns a AggregateExpression representing a call to SQL MAX function.
      *
-     * @param mixed $expression the function argument
+     * @param \Cake\Database\ExpressionInterface|string $expression the expression for the max() function
      * @param array $types list of types to bind to the arguments
-     * @return \Cake\Database\Expression\FunctionExpression
+     * @return \Cake\Database\Expression\AggregateExpression
      */
-    public function avg($expression, $types = [])
+    public function max(ExpressionInterface|string $expression, array $types = []): AggregateExpression
     {
-        return $this->_literalArgumentFunction('AVG', $expression, $types, 'float');
+        return $this->aggregate('MAX', $this->toLiteralParam($expression), $types, current($types) ?: 'float');
     }
 
     /**
-     * Returns a FunctionExpression representing a call to SQL MAX function.
+     * Returns a AggregateExpression representing a call to SQL MIN function.
      *
-     * @param mixed $expression the function argument
+     * @param \Cake\Database\ExpressionInterface|string $expression the expression for the min() function.
      * @param array $types list of types to bind to the arguments
-     * @return \Cake\Database\Expression\FunctionExpression
+     * @return \Cake\Database\Expression\AggregateExpression
      */
-    public function max($expression, $types = [])
+    public function min(ExpressionInterface|string $expression, array $types = []): AggregateExpression
     {
-        return $this->_literalArgumentFunction('MAX', $expression, $types, current($types) ?: 'string');
+        return $this->aggregate('MIN', $this->toLiteralParam($expression), $types, current($types) ?: 'float');
     }
 
     /**
-     * Returns a FunctionExpression representing a call to SQL MIN function.
+     * Returns a AggregateExpression representing a call to SQL COUNT function.
      *
-     * @param mixed $expression the function argument
+     * @param \Cake\Database\ExpressionInterface|string $expression the expression for the count() function.
      * @param array $types list of types to bind to the arguments
-     * @return \Cake\Database\Expression\FunctionExpression
+     * @return \Cake\Database\Expression\AggregateExpression
      */
-    public function min($expression, $types = [])
+    public function count(ExpressionInterface|string $expression, array $types = []): AggregateExpression
     {
-        return $this->_literalArgumentFunction('MIN', $expression, $types, current($types) ?: 'string');
+        return $this->aggregate('COUNT', $this->toLiteralParam($expression), $types, 'integer');
     }
 
     /**
-     * Returns a FunctionExpression representing a call to SQL COUNT function.
+     * Returns an AggregateExpression representing a portable string aggregation call.
      *
-     * @param mixed $expression the function argument
-     * @param array $types list of types to bind to the arguments
-     * @return \Cake\Database\Expression\FunctionExpression
+     * @param \Cake\Database\ExpressionInterface|string $expression The value to aggregate.
+     * @param string $separator The separator inserted between values.
+     * @param \Cake\Database\ExpressionInterface|array|string|null $orderBy Aggregate-local ordering.
+     * @param array $types List of types to bind to the arguments.
+     * @return \Cake\Database\Expression\StringAggExpression
      */
-    public function count($expression, $types = [])
-    {
-        return $this->_literalArgumentFunction('COUNT', $expression, $types, 'integer');
+    public function stringAgg(
+        ExpressionInterface|string $expression,
+        string $separator,
+        ExpressionInterface|array|string|null $orderBy = null,
+        array $types = [],
+    ): StringAggExpression {
+        $params = $this->toLiteralParam($expression);
+        $params[] = $separator;
+
+        return new StringAggExpression($params, $types, $orderBy);
     }
 
     /**
      * Returns a FunctionExpression representing a string concatenation
      *
+     * Driver transformations:
+     *  - **Postgres**: expression1 || expression2 || expression3 ...
+     *  - **Sqlite**: expression1 || expression2 || expression3 ...
+     *  - **SqlServer**: expression1 + expression2 + expression3 ...
+     *
      * @param array $args List of strings or expressions to concatenate
      * @param array $types list of types to bind to the arguments
      * @return \Cake\Database\Expression\FunctionExpression
      */
-    public function concat($args, $types = [])
+    public function concat(array $args, array $types = []): FunctionExpression
     {
-        return $this->_build('CONCAT', $args, $types, 'string');
+        return new FunctionExpression('CONCAT', $args, $types, 'string');
     }
 
     /**
@@ -144,96 +156,141 @@ public function concat($args, $types = [])
      * @param array $types list of types to bind to the arguments
      * @return \Cake\Database\Expression\FunctionExpression
      */
-    public function coalesce($args, $types = [])
+    public function coalesce(array $args, array $types = []): FunctionExpression
     {
-        return $this->_build('COALESCE', $args, $types, current($types) ?: 'string');
+        return new FunctionExpression('COALESCE', $args, $types, current($types) ?: 'string');
+    }
+
+    /**
+     * Returns a FunctionExpression representing a SQL CAST.
+     *
+     * The `$type` parameter is a SQL type. The return type for the returned expression
+     * is the default type name. Use `setReturnType()` to update it.
+     *
+     * @param \Cake\Database\ExpressionInterface|string $field Field or expression to cast.
+     * @param string $dataType The SQL data type. Must be a simple alphanumeric string.
+     * @return \Cake\Database\Expression\FunctionExpression
+     */
+    public function cast(ExpressionInterface|string $field, string $dataType): FunctionExpression
+    {
+        $this->ensureSimpleString('dataType', $dataType);
+        $expression = new FunctionExpression('CAST', $this->toLiteralParam($field));
+
+        return $expression->setConjunction(' AS')->add([$dataType => 'literal']);
     }
 
     /**
      * Returns a FunctionExpression representing the difference in days between
      * two dates.
      *
+     * Driver transformations:
+     *  - **Postgres**: expression1 - expression2
+     *  - **Sqlite**: ROUND(JULIANDAY(expression1) - JULIANDAY(expression2))
+     *  - **SqlServer**: datediff(day, expression1, expression2)
+     *
      * @param array $args List of expressions to obtain the difference in days.
      * @param array $types list of types to bind to the arguments
      * @return \Cake\Database\Expression\FunctionExpression
      */
-    public function dateDiff($args, $types = [])
+    public function dateDiff(array $args, array $types = []): FunctionExpression
     {
-        return $this->_build('DATEDIFF', $args, $types, 'integer');
+        return new FunctionExpression('DATEDIFF', $args, $types, 'integer');
     }
 
     /**
      * Returns the specified date part from the SQL expression.
      *
-     * @param string $part Part of the date to return.
-     * @param string $expression Expression to obtain the date part from.
+     * @param string $part Part of the date to return. Must be a simple alphanumeric string.
+     * @param \Cake\Database\ExpressionInterface|string $expression Expression to obtain the date part from.
      * @param array $types list of types to bind to the arguments
      * @return \Cake\Database\Expression\FunctionExpression
      */
-    public function datePart($part, $expression, $types = [])
-    {
-        return $this->extract($part, $expression);
+    public function datePart(
+        string $part,
+        ExpressionInterface|string $expression,
+        array $types = [],
+    ): FunctionExpression {
+        return $this->extract($part, $expression, $types);
     }
 
     /**
      * Returns the specified date part from the SQL expression.
      *
-     * @param string $part Part of the date to return.
-     * @param string $expression Expression to obtain the date part from.
+     * Driver transformations:
+     *  - **Postgres**: extract(part FROM expression)
+     *  - **Sqlite**: strftime('%part', expression)
+     *  - **SqlServer**: datepart(part, expression)
+     *
+     * @param string $part Part of the date to return. Must be a simple alphanumeric string.
+     * @param \Cake\Database\ExpressionInterface|string $expression Expression to obtain the date part from.
      * @param array $types list of types to bind to the arguments
      * @return \Cake\Database\Expression\FunctionExpression
      */
-    public function extract($part, $expression, $types = [])
+    public function extract(string $part, ExpressionInterface|string $expression, array $types = []): FunctionExpression
     {
-        $expression = $this->_literalArgumentFunction('EXTRACT', $expression, $types, 'integer');
-        $expression->setConjunction(' FROM')->add([$part => 'literal'], [], true);
+        $this->ensureSimpleString('part', $part);
+        $expression = new FunctionExpression('EXTRACT', $this->toLiteralParam($expression), $types, 'integer');
 
-        return $expression;
+        return $expression->setConjunction(' FROM')->add([$part => 'literal'], [], true);
     }
 
     /**
      * Add the time unit to the date expression
      *
-     * @param string $expression Expression to obtain the date part from.
-     * @param string $value Value to be added. Use negative to substract.
-     * @param string $unit Unit of the value e.g. hour or day.
+     * Driver transformations:
+     *  - **Postgres**: expression + interval 'value unit'
+     *  - **Sqlite**: datetime(expression, 'value unit')
+     *  - **SqlServer**: dateadd(unit, value, expression)
+     *
+     * @param \Cake\Database\ExpressionInterface|string $expression Expression to obtain the date part from.
+     * @param string|int $value Value to be added. Use negative to subtract.
+     * @param string $unit Unit of the value e.g. hour or day. Must be a simple alphanumeric string.
      * @param array $types list of types to bind to the arguments
      * @return \Cake\Database\Expression\FunctionExpression
      */
-    public function dateAdd($expression, $value, $unit, $types = [])
-    {
+    public function dateAdd(
+        ExpressionInterface|string $expression,
+        string|int $value,
+        string $unit,
+        array $types = [],
+    ): FunctionExpression {
         if (!is_numeric($value)) {
             $value = 0;
         }
+        $this->ensureSimpleString('unit', $unit);
         $interval = $value . ' ' . $unit;
-        $expression = $this->_literalArgumentFunction('DATE_ADD', $expression, $types, 'datetime');
-        $expression->setConjunction(', INTERVAL')->add([$interval => 'literal']);
+        $expression = new FunctionExpression('DATE_ADD', $this->toLiteralParam($expression), $types, 'datetime');
 
-        return $expression;
+        return $expression->setConjunction(', INTERVAL')->add([$interval => 'literal']);
     }
 
     /**
      * Returns a FunctionExpression representing a call to SQL WEEKDAY function.
      * 1 - Sunday, 2 - Monday, 3 - Tuesday...
      *
-     * @param mixed $expression the function argument
+     * Driver transformations:
+     *  - **Postgres**: extract(DOW FROM expression) + 1
+     *  - **Sqlite**: strftime('%w', expression) + 1
+     *  - **SqlServer**: datepart(WEEKDAY, expression)
+     *
+     * @param \Cake\Database\ExpressionInterface|string $expression the function argument
      * @param array $types list of types to bind to the arguments
      * @return \Cake\Database\Expression\FunctionExpression
      */
-    public function dayOfWeek($expression, $types = [])
+    public function dayOfWeek(ExpressionInterface|string $expression, array $types = []): FunctionExpression
     {
-        return $this->_literalArgumentFunction('DAYOFWEEK', $expression, $types, 'integer');
+        return new FunctionExpression('DAYOFWEEK', $this->toLiteralParam($expression), $types, 'integer');
     }
 
     /**
      * Returns a FunctionExpression representing a call to SQL WEEKDAY function.
      * 1 - Sunday, 2 - Monday, 3 - Tuesday...
      *
-     * @param mixed $expression the function argument
+     * @param \Cake\Database\ExpressionInterface|string $expression the function argument
      * @param array $types list of types to bind to the arguments
      * @return \Cake\Database\Expression\FunctionExpression
      */
-    public function weekday($expression, $types = [])
+    public function weekday(ExpressionInterface|string $expression, array $types = []): FunctionExpression
     {
         return $this->dayOfWeek($expression, $types);
     }
@@ -243,20 +300,140 @@ public function weekday($expression, $types = [])
      * date and time. By default it returns both date and time, but you can also
      * make it generate only the date or only the time.
      *
+     * Driver transformations:
+     *  - **Postgres**:
+     *    - datetime: localtimestamp(0)
+     *    - date: cast(localtimestamp(0) AS date)
+     *    - time: cast(localtimestamp(0) AS time)
+     *  - **Sqlite**:
+     *    - datetime: datetime('now')
+     *    - date: date('now')
+     *    - time: time('now')
+     *  - **SqlServer**:
+     *    - datetime: getutcdate()
+     *    - date: convert(date, getutcdate())
+     *    - time: convert(time, getutcdate())
+     *
      * @param string $type (datetime|date|time)
      * @return \Cake\Database\Expression\FunctionExpression
      */
-    public function now($type = 'datetime')
+    public function now(string $type = 'datetime'): FunctionExpression
+    {
+        return match ($type) {
+            'datetime' => new FunctionExpression('NOW', [], [], 'datetime'),
+            'date' => new FunctionExpression('CURRENT_DATE', [], [], 'date'),
+            'time' => new FunctionExpression('CURRENT_TIME', [], [], 'time'),
+            default => throw new InvalidArgumentException('Invalid argument for FunctionsBuilder::now(): ' . $type),
+        };
+    }
+
+    /**
+     * Returns an AggregateExpression representing call to SQL ROW_NUMBER().
+     *
+     * @return \Cake\Database\Expression\AggregateExpression
+     */
+    public function rowNumber(): AggregateExpression
     {
-        if ($type === 'datetime') {
-            return $this->_build('NOW')->setReturnType('datetime');
+        return (new AggregateExpression('ROW_NUMBER', [], [], 'integer'))->over();
+    }
+
+    /**
+     * Returns an AggregateExpression representing call to SQL LAG().
+     *
+     * @param \Cake\Database\ExpressionInterface|string $expression The value evaluated at offset
+     * @param int $offset The row offset
+     * @param mixed $default The default value if offset doesn't exist
+     * @param string|null $type The output type of the lag expression. Defaults to float.
+     * @return \Cake\Database\Expression\AggregateExpression
+     */
+    public function lag(
+        ExpressionInterface|string $expression,
+        int $offset,
+        mixed $default = null,
+        ?string $type = null,
+    ): AggregateExpression {
+        $params = $this->toLiteralParam($expression) + [$offset => 'literal'];
+        if ($default !== null) {
+            $params[] = $default;
         }
-        if ($type === 'date') {
-            return $this->_build('CURRENT_DATE')->setReturnType('date');
+
+        $types = [];
+        if ($type !== null) {
+            $types = [$type, 'integer', $type];
+        }
+
+        return (new AggregateExpression('LAG', $params, $types, $type ?? 'float'))->over();
+    }
+
+    /**
+     * Returns an AggregateExpression representing call to SQL LEAD().
+     *
+     * @param \Cake\Database\ExpressionInterface|string $expression The value evaluated at offset
+     * @param int $offset The row offset
+     * @param mixed $default The default value if offset doesn't exist
+     * @param string|null $type The output type of the lead expression. Defaults to float.
+     * @return \Cake\Database\Expression\AggregateExpression
+     */
+    public function lead(
+        ExpressionInterface|string $expression,
+        int $offset,
+        mixed $default = null,
+        ?string $type = null,
+    ): AggregateExpression {
+        $params = $this->toLiteralParam($expression) + [$offset => 'literal'];
+        if ($default !== null) {
+            $params[] = $default;
         }
-        if ($type === 'time') {
-            return $this->_build('CURRENT_TIME')->setReturnType('time');
+
+        $types = [];
+        if ($type !== null) {
+            $types = [$type, 'integer', $type];
         }
+
+        return (new AggregateExpression('LEAD', $params, $types, $type ?? 'float'))->over();
+    }
+
+    /**
+     * Returns a FunctionExpression representing the Json Value
+     *
+     * Driver transformations:
+     *  - **Postgres**: jsonb_path_query
+     *  - **Sqlite**: json_extract
+     *
+     * @param \Cake\Database\ExpressionInterface|string $expression The Json value or json field
+     * @param string $jsonPath A valid JSON PATH Query
+     * @param array $types list of types to bind to the arguments
+     * @return \Cake\Database\Expression\FunctionExpression
+     */
+    public function jsonValue(
+        ExpressionInterface|string $expression,
+        string $jsonPath,
+        array $types = [],
+    ): FunctionExpression {
+        $params = $this->toLiteralParam($expression) + [$jsonPath];
+
+        return new FunctionExpression('JSON_VALUE', $params, $types);
+    }
+
+    /**
+     * Helper method to create arbitrary SQL aggregate function calls.
+     *
+     * @param string $name The SQL aggregate function name
+     * @param array $params Array of arguments to be passed to the function.
+     *     Can be an associative array with the literal value or identifier:
+     *     `['value' => 'literal']` or `['value' => 'identifier']`
+     * @param array $types Array of types that match the names used in `$params`:
+     *     `['name' => 'type']`
+     * @param string $return Return type of the entire expression. Defaults to float.
+     * @return \Cake\Database\Expression\AggregateExpression
+     */
+    public function aggregate(
+        string $name,
+        array $params = [],
+        array $types = [],
+        string $return = 'float',
+    ): AggregateExpression {
+        return new AggregateExpression($name, $params, $types, $return);
     }
 
     /**
@@ -268,17 +445,38 @@ public function now($type = 'datetime')
      * params, and the third one the return type of the function
      * @return \Cake\Database\Expression\FunctionExpression
      */
-    public function __call($name, $args)
+    public function __call(string $name, array $args): FunctionExpression
+    {
+        return new FunctionExpression($name, ...$args);
+    }
+
+    /**
+     * Creates function parameter array from expression or string literal.
+     *
+     * @param \Cake\Database\ExpressionInterface|string $expression function argument
+     * @return array<\Cake\Database\ExpressionInterface|string>
+     */
+    protected function toLiteralParam(ExpressionInterface|string $expression): array
+    {
+        if (is_string($expression)) {
+            return [$expression => 'literal'];
+        }
+
+        return [$expression];
+    }
+
+    /**
+     * Ensures that string values are simple ascii values with no whitespace
+     *
+     * @param string $parameterName The name of the parameter being checked.
+     * @param string $value The value to check
+     * @return void
+     */
+    protected function ensureSimpleString(string $parameterName, string $value): void
     {
-        switch (count($args)) {
-            case 0:
-                return $this->_build($name);
-            case 1:
-                return $this->_build($name, $args[0]);
-            case 2:
-                return $this->_build($name, $args[0], $args[1]);
-            default:
-                return $this->_build($name, $args[0], $args[1], $args[2]);
+        if (preg_match('/^[a-zA-Z0-9]+$/', $value)) {
+            return;
         }
+        throw new InvalidArgumentException("Argument `{$parameterName}` must be an alphanumeric string");
     }
 }
diff --git a/src/Database/IdentifierQuoter.php b/src/Database/IdentifierQuoter.php
index 348e646df07..0b2f5262e51 100644
--- a/src/Database/IdentifierQuoter.php
+++ b/src/Database/IdentifierQuoter.php
@@ -1,4 +1,6 @@
 _driver = $driver;
+        $identifier = trim($identifier);
+
+        if ($identifier === '*' || $identifier === '') {
+            return $identifier;
+        }
+
+        // string
+        if (preg_match('/^[\w-]+$/u', $identifier)) {
+            return $this->startQuote . $identifier . $this->endQuote;
+        }
+
+        // string.string
+        if (preg_match('/^[\w-]+\.[^ \*]*$/u', $identifier)) {
+            $items = explode('.', $identifier);
+
+            return $this->startQuote . implode($this->endQuote . '.' . $this->startQuote, $items) . $this->endQuote;
+        }
+
+        // string.*
+        if (preg_match('/^[\w-]+\.\*$/u', $identifier)) {
+            return $this->startQuote . str_replace('.*', $this->endQuote . '.*', $identifier);
+        }
+
+        // Functions
+        if (preg_match('/^([\w-]+)\((.*)\)$/', $identifier, $matches)) {
+            return $matches[1] . '(' . $this->quoteIdentifier($matches[2]) . ')';
+        }
+
+        // Alias.field AS thing
+        if (preg_match('/^([\w-]+(\.[\w\s-]+|\(.*\))*)\s+AS\s*([\w-]+)$/ui', $identifier, $matches)) {
+            return $this->quoteIdentifier($matches[1]) . ' AS ' . $this->quoteIdentifier($matches[3]);
+        }
+
+        // string.string with spaces
+        if (preg_match('/^([\w-]+\.[\w][\w\s-]*[\w])(.*)/u', $identifier, $matches)) {
+            $items = explode('.', $matches[1]);
+            $field = implode($this->endQuote . '.' . $this->startQuote, $items);
+
+            return $this->startQuote . $field . $this->endQuote . $matches[2];
+        }
+
+        if (preg_match('/^[\w\s-]*[\w-]+/u', $identifier)) {
+            return $this->startQuote . $identifier . $this->endQuote;
+        }
+
+        return $identifier;
     }
 
     /**
@@ -50,23 +108,26 @@ public function __construct(Driver $driver)
      * @param \Cake\Database\Query $query The query to have its identifiers quoted
      * @return \Cake\Database\Query
      */
-    public function quote(Query $query)
+    public function quote(Query $query): Query
     {
         $binder = $query->getValueBinder();
-        $query->valueBinder(false);
-
-        if ($query->type() === 'insert') {
-            $this->_quoteInsert($query);
-        } elseif ($query->type() === 'update') {
-            $this->_quoteUpdate($query);
-        } else {
-            $this->_quoteParts($query);
-        }
-
-        $query->traverseExpressions([$this, 'quoteExpression']);
-        $query->valueBinder($binder);
-
-        return $query;
+        $query->setValueBinder(null);
+
+        match (true) {
+            $query instanceof InsertQuery => $this->_quoteInsert($query),
+            $query instanceof SelectQuery => $this->_quoteSelect($query),
+            $query instanceof UpdateQuery => $this->_quoteUpdate($query),
+            $query instanceof DeleteQuery => $this->_quoteDelete($query),
+            default =>
+                throw new DatabaseException(sprintf(
+                    'Instance of SelectQuery, UpdateQuery, InsertQuery, DeleteQuery expected. Found `%s` instead.',
+                    get_debug_type($query),
+                ))
+        };
+
+        $query->traverseExpressions($this->quoteExpression(...));
+
+        return $query->setValueBinder($binder);
     }
 
     /**
@@ -75,36 +136,26 @@ public function quote(Query $query)
      * @param \Cake\Database\ExpressionInterface $expression The expression object to walk and quote.
      * @return void
      */
-    public function quoteExpression($expression)
+    public function quoteExpression(ExpressionInterface $expression): void
     {
-        if ($expression instanceof FieldInterface) {
-            $this->_quoteComparison($expression);
-
-            return;
-        }
-
-        if ($expression instanceof OrderByExpression) {
-            $this->_quoteOrderBy($expression);
-
-            return;
-        }
-
-        if ($expression instanceof IdentifierExpression) {
-            $this->_quoteIdentifierExpression($expression);
-
-            return;
-        }
+        match (true) {
+            $expression instanceof FieldInterface => $this->_quoteComparison($expression),
+            $expression instanceof OrderByExpression => $this->_quoteOrderBy($expression),
+            $expression instanceof IdentifierExpression => $this->_quoteIdentifierExpression($expression),
+            default => null // Nothing to do if there is no match
+        };
     }
 
     /**
-     * Quotes all identifiers in each of the clauses of a query
+     * Quotes all identifiers in each of the clauses/parts of a query
      *
      * @param \Cake\Database\Query $query The query to quote.
+     * @param array $parts Query clauses.
      * @return void
      */
-    protected function _quoteParts($query)
+    protected function _quoteParts(Query $query, array $parts): void
     {
-        foreach (['distinct', 'select', 'from', 'group'] as $part) {
+        foreach ($parts as $part) {
             $contents = $query->clause($part);
 
             if (!is_array($contents)) {
@@ -112,30 +163,30 @@ protected function _quoteParts($query)
             }
 
             $result = $this->_basicQuoter($contents);
-            if (!empty($result)) {
+            if ($result) {
+                $part = match ($part) {
+                    'group' => 'groupBy',
+                    'order' => 'orderBy',
+                    default => $part,
+                };
+
                 $query->{$part}($result, true);
             }
         }
-
-        $joins = $query->clause('join');
-        if ($joins) {
-            $joins = $this->_quoteJoins($joins);
-            $query->join($joins, [], true);
-        }
     }
 
     /**
      * A generic identifier quoting function used for various parts of the query
      *
-     * @param array $part the part of the query to quote
-     * @return array
+     * @param array $part the part of the query to quote
+     * @return array
      */
-    protected function _basicQuoter($part)
+    protected function _basicQuoter(array $part): array
     {
         $result = [];
-        foreach ((array)$part as $alias => $value) {
-            $value = !is_string($value) ? $value : $this->_driver->quoteIdentifier($value);
-            $alias = is_numeric($alias) ? $alias : $this->_driver->quoteIdentifier($alias);
+        foreach ($part as $alias => $value) {
+            $value = is_string($value) ? $this->quoteIdentifier($value) : $value;
+            $alias = is_numeric($alias) ? $alias : $this->quoteIdentifier($alias);
             $result[$alias] = $value;
         }
 
@@ -146,21 +197,21 @@ protected function _basicQuoter($part)
      * Quotes both the table and alias for an array of joins as stored in a Query
      * object
      *
-     * @param array $joins The joins to quote.
-     * @return array
+     * @param array $joins The joins to quote.
+     * @return array
      */
-    protected function _quoteJoins($joins)
+    protected function _quoteJoins(array $joins): array
     {
         $result = [];
         foreach ($joins as $value) {
-            $alias = null;
+            $alias = '';
             if (!empty($value['alias'])) {
-                $alias = $this->_driver->quoteIdentifier($value['alias']);
+                $alias = $this->quoteIdentifier($value['alias']);
                 $value['alias'] = $alias;
             }
 
             if (is_string($value['table'])) {
-                $value['table'] = $this->_driver->quoteIdentifier($value['table']);
+                $value['table'] = $this->quoteIdentifier($value['table']);
             }
 
             $result[$alias] = $value;
@@ -169,19 +220,58 @@ protected function _quoteJoins($joins)
         return $result;
     }
 
+    /**
+     * Quotes all identifiers in each of the clauses of a SELECT query
+     *
+     * @param \Cake\Database\Query\SelectQuery $query The query to quote.
+     * @return void
+     */
+    protected function _quoteSelect(SelectQuery $query): void
+    {
+        $this->_quoteParts($query, ['select', 'distinct', 'from', 'group']);
+
+        $joins = $query->clause('join');
+        if ($joins) {
+            $joins = $this->_quoteJoins($joins);
+            $query->join($joins, [], true);
+        }
+    }
+
+    /**
+     * Quotes all identifiers in each of the clauses of a DELETE query
+     *
+     * @param \Cake\Database\Query\DeleteQuery $query The query to quote.
+     * @return void
+     */
+    protected function _quoteDelete(DeleteQuery $query): void
+    {
+        $this->_quoteParts($query, ['from']);
+
+        $joins = $query->clause('join');
+        if ($joins) {
+            $joins = $this->_quoteJoins($joins);
+            $query->join($joins, [], true);
+        }
+    }
+
     /**
      * Quotes the table name and columns for an insert query
      *
-     * @param \Cake\Database\Query $query The insert query to quote.
+     * @param \Cake\Database\Query\InsertQuery $query The insert query to quote.
      * @return void
      */
-    protected function _quoteInsert($query)
+    protected function _quoteInsert(InsertQuery $query): void
     {
-        list($table, $columns) = $query->clause('insert');
-        $table = $this->_driver->quoteIdentifier($table);
+        /** @var array{0?: string, 1?: array} $insert */
+        $insert = $query->clause('insert');
+        if (!isset($insert[0]) || !isset($insert[1])) {
+            return;
+        }
+        [$table, $columns] = $insert;
+        $table = $this->quoteIdentifier($table);
         foreach ($columns as &$column) {
             if (is_scalar($column)) {
-                $column = $this->_driver->quoteIdentifier($column);
+                $column = $this->quoteIdentifier((string)$column);
             }
         }
         $query->insert($columns)->into($table);
@@ -190,15 +280,15 @@ protected function _quoteInsert($query)
     /**
      * Quotes the table name for an update query
      *
-     * @param \Cake\Database\Query $query The update query to quote.
+     * @param \Cake\Database\Query\UpdateQuery $query The update query to quote.
      * @return void
      */
-    protected function _quoteUpdate($query)
+    protected function _quoteUpdate(UpdateQuery $query): void
     {
         $table = $query->clause('update')[0];
 
         if (is_string($table)) {
-            $query->update($this->_driver->quoteIdentifier($table));
+            $query->update($this->quoteIdentifier($table));
         }
     }
 
@@ -208,18 +298,18 @@ protected function _quoteUpdate($query)
      * @param \Cake\Database\Expression\FieldInterface $expression The expression to quote.
      * @return void
      */
-    protected function _quoteComparison(FieldInterface $expression)
+    protected function _quoteComparison(FieldInterface $expression): void
     {
         $field = $expression->getField();
         if (is_string($field)) {
-            $expression->setField($this->_driver->quoteIdentifier($field));
+            $expression->setField($this->quoteIdentifier($field));
         } elseif (is_array($field)) {
             $quoted = [];
             foreach ($field as $f) {
-                $quoted[] = $this->_driver->quoteIdentifier($f);
+                $quoted[] = $this->quoteIdentifier($f);
             }
             $expression->setField($quoted);
-        } elseif ($field instanceof ExpressionInterface) {
+        } else {
             $this->quoteExpression($field);
         }
     }
@@ -233,16 +323,16 @@ protected function _quoteComparison(FieldInterface $expression)
      * @param \Cake\Database\Expression\OrderByExpression $expression The expression to quote.
      * @return void
      */
-    protected function _quoteOrderBy(OrderByExpression $expression)
+    protected function _quoteOrderBy(OrderByExpression $expression): void
     {
         $expression->iterateParts(function ($part, &$field) {
             if (is_string($field)) {
-                $field = $this->_driver->quoteIdentifier($field);
+                $field = $this->quoteIdentifier($field);
 
                 return $part;
             }
-            if (is_string($part) && strpos($part, ' ') === false) {
-                return $this->_driver->quoteIdentifier($part);
+            if (is_string($part) && !str_contains($part, ' ')) {
+                return $this->quoteIdentifier($part);
             }
 
             return $part;
@@ -255,10 +345,10 @@ protected function _quoteOrderBy(OrderByExpression $expression)
      * @param \Cake\Database\Expression\IdentifierExpression $expression The identifiers to quote.
      * @return void
      */
-    protected function _quoteIdentifierExpression(IdentifierExpression $expression)
+    protected function _quoteIdentifierExpression(IdentifierExpression $expression): void
     {
         $expression->setIdentifier(
-            $this->_driver->quoteIdentifier($expression->getIdentifier())
+            $this->quoteIdentifier($expression->getIdentifier()),
         );
     }
 }
diff --git a/src/Database/LICENSE.txt b/src/Database/LICENSE.txt
index 0c4b7932c31..b938c9e8ed3 100644
--- a/src/Database/LICENSE.txt
+++ b/src/Database/LICENSE.txt
@@ -1,7 +1,7 @@
 The MIT License (MIT)
 
 CakePHP(tm) : The Rapid Development PHP Framework (https://cakephp.org)
-Copyright (c) 2005-2016, Cake Software Foundation, Inc. (https://cakefoundation.org)
+Copyright (c) 2005-2020, Cake Software Foundation, Inc. (https://cakefoundation.org)
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Database/Log/LoggedQuery.php b/src/Database/Log/LoggedQuery.php
index 90c1b100dec..aaffc66a3d5 100644
--- a/src/Database/Log/LoggedQuery.php
+++ b/src/Database/Log/LoggedQuery.php
@@ -1,4 +1,6 @@
 
+     */
+    protected const CONTEXT_KEYS = ['driver', 'query', 'took', 'params', 'numRows', 'error'];
+
+    /**
+     * Driver executing the query
+     *
+     * @var \Cake\Database\Driver|null
+     */
+    protected ?Driver $driver = null;
 
     /**
      * Query string that was executed
      *
      * @var string
      */
-    public $query = '';
+    protected string $query = '';
 
     /**
      * Number of milliseconds this query took to complete
      *
-     * @var int
+     * @var float
      */
-    public $took = 0;
+    protected float $took = 0;
 
     /**
      * Associative array with the params bound to the query string
      *
      * @var array
      */
-    public $params = [];
+    protected array $params = [];
 
     /**
      * Number of rows affected or returned by the query execution
      *
      * @var int
      */
-    public $numRows = 0;
+    protected int $numRows = 0;
 
     /**
      * The exception that was thrown by the execution of this query
      *
      * @var \Exception|null
      */
-    public $error;
+    protected ?Exception $error = null;
+
+    /**
+     * Optional redactor invoked before the stored query string or bound
+     * parameters are exposed via {@see __toString()}, {@see getContext()},
+     * or {@see jsonSerialize()}. Receives `(string $query, array $params)`
+     * and must return a 2-element list `[string $query, array $params]`
+     * with sensitive substrings replaced.
+     *
+     * Used to keep secrets bound as parameters (cryptographic keys,
+     * passwords, tokens) out of file logs, structured loggers, and
+     * remote breadcrumb sinks. Set once during application bootstrap;
+     * applies to every LoggedQuery instance from then on.
+     *
+     * @var \Closure|null
+     */
+    protected static ?Closure $redactor = null;
+
+    /**
+     * Register a global redactor applied to every LoggedQuery before its
+     * query string or params are exposed for logging.
+     *
+     * The redactor receives the raw `(string $query, array $params)` and
+     * must return a 2-element list `[string $query, array $params]` with
+     * the sensitive substrings replaced. Exceptions thrown by the
+     * redactor propagate to the caller, and returning a malformed value
+     * raises a `RuntimeException` — both surface a broken redactor
+     * loudly rather than silently leaking the secrets it was supposed
+     * to scrub. Register a redactor you trust.
+     *
+     * Pass `null` to clear a previously-registered redactor.
+     *
+     * @param \Closure|null $redactor `fn(string, array): array{0: string, 1: array}`
+     * @return void
+     */
+    public static function setRedactor(?Closure $redactor): void
+    {
+        self::$redactor = $redactor;
+    }
+
+    /**
+     * Apply the configured redactor (if any) to the stored query+params
+     * and return the sanitized tuple.
+     *
+     * A redactor that throws lets the exception propagate; a redactor
+     * that returns a value not matching `[string, array]` raises a
+     * `RuntimeException`. Both surface a broken redactor instead of
+     * silently falling back to the raw values it was meant to scrub.
+     *
+     * @return array{0: string, 1: array} [sanitized query, sanitized params]
+     * @throws \RuntimeException If the redactor returns a malformed value.
+     */
+    protected function redacted(): array
+    {
+        if (self::$redactor === null) {
+            return [$this->query, $this->params];
+        }
+
+        $result = (self::$redactor)($this->query, $this->params);
+
+        if (!is_array($result) || !isset($result[0], $result[1]) || !is_string($result[0]) || !is_array($result[1])) {
+            throw new RuntimeException(sprintf(
+                'LoggedQuery redactor must return [string $query, array $params]; got %s.',
+                get_debug_type($result),
+            ));
+        }
+
+        return [$result[0], $result[1]];
+    }
+
+    /**
+     * Helper function used to replace query placeholders by the real
+     * params used to execute the query
+     *
+     * @return string
+     */
+    protected function interpolate(): string
+    {
+        [$query, $rawParams] = $this->redacted();
+
+        $params = array_map(function ($p) {
+            if ($p === null) {
+                return 'NULL';
+            }
+
+            if (is_bool($p)) {
+                if ($this->driver instanceof Sqlserver) {
+                    return $p ? '1' : '0';
+                }
+
+                return $p ? 'TRUE' : 'FALSE';
+            }
+
+            if (is_string($p)) {
+                // Likely binary data like a blob or binary uuid.
+                // pattern matches ascii control chars.
+                if (preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $p) !== $p) {
+                    $p = bin2hex($p);
+                }
+
+                $replacements = [
+                    '$' => '\\$',
+                    '\\' => '\\\\\\\\',
+                    "'" => "''",
+                ];
+
+                $p = strtr($p, $replacements);
+
+                return "'{$p}'";
+            }
+
+            return $p;
+        }, $rawParams);
+
+        $keys = [];
+        $limit = is_int(key($params)) ? 1 : -1;
+        foreach ($params as $key => $param) {
+            $keys[] = is_string($key) ? "/:{$key}\b/" : '/[?]/';
+        }
+
+        return (string)preg_replace($keys, $params, $query, $limit);
+    }
+
+    /**
+     * Get the logging context data for a query.
+     *
+     * @return array
+     */
+    public function getContext(): array
+    {
+        [$query] = $this->redacted();
+
+        $context = [
+            'query' => $query,
+            'numRows' => $this->numRows,
+            'took' => $this->took,
+            'role' => $this->driver ? $this->driver->getRole() : '',
+        ];
+
+        $connectionName = $this->getConnectionName();
+        if ($connectionName !== '') {
+            $context['connection'] = $connectionName;
+        }
+
+        return $context;
+    }
+
+    /**
+     * Get the connection name from the driver config.
+     *
+     * @return string
+     */
+    public function getConnectionName(): string
+    {
+        if ($this->driver === null) {
+            return '';
+        }
+
+        return $this->driver->config()['name'] ?? '';
+    }
+
+    /**
+     * Set logging context for this query.
+     *
+     * @param array $context Context data.
+     * @return void
+     */
+    public function setContext(array $context): void
+    {
+        foreach ($context as $key => $val) {
+            if (in_array($key, self::CONTEXT_KEYS, true)) {
+                $this->{$key} = $val;
+            }
+        }
+    }
+
+    /**
+     * Returns data that will be serialized as JSON
+     *
+     * @return array
+     */
+    public function jsonSerialize(): array
+    {
+        [$query, $params] = $this->redacted();
+
+        $error = $this->error;
+        if ($error !== null) {
+            $error = [
+                'class' => $error::class,
+                'message' => $error->getMessage(),
+                'code' => $error->getCode(),
+            ];
+        }
+
+        return [
+            'query' => $query,
+            'numRows' => $this->numRows,
+            'params' => $params,
+            'took' => $this->took,
+            'error' => $error,
+        ];
+    }
 
     /**
      * Returns the string representation of this logged query
      *
      * @return string
      */
-    public function __toString()
+    public function __toString(): string
     {
-        return "duration={$this->took} rows={$this->numRows} {$this->query}";
+        if ($this->params) {
+            return $this->interpolate();
+        }
+
+        [$query] = $this->redacted();
+
+        return $query;
     }
 }
diff --git a/src/Database/Log/LoggingStatement.php b/src/Database/Log/LoggingStatement.php
deleted file mode 100644
index 11dce68c734..00000000000
--- a/src/Database/Log/LoggingStatement.php
+++ /dev/null
@@ -1,145 +0,0 @@
-queryString = $this->queryString;
-            $query->error = $e;
-            $this->_log($query, $params, $t);
-            throw $e;
-        }
-
-        $query->numRows = $this->rowCount();
-        $this->_log($query, $params, $t);
-
-        return $result;
-    }
-
-    /**
-     * Copies the logging data to the passed LoggedQuery and sends it
-     * to the logging system.
-     *
-     * @param \Cake\Database\Log\LoggedQuery $query The query to log.
-     * @param array $params List of values to be bound to query.
-     * @param float $startTime The microtime when the query was executed.
-     * @return void
-     */
-    protected function _log($query, $params, $startTime)
-    {
-        $query->took = round((microtime(true) - $startTime) * 1000, 0);
-        $query->params = $params ?: $this->_compiledParams;
-        $query->query = $this->queryString;
-        $this->getLogger()->log($query);
-    }
-
-    /**
-     * Wrapper for bindValue function to gather each parameter to be later used
-     * in the logger function.
-     *
-     * @param string|int $column Name or param position to be bound
-     * @param mixed $value The value to bind to variable in query
-     * @param string|int|null $type PDO type or name of configured Type class
-     * @return void
-     */
-    public function bindValue($column, $value, $type = 'string')
-    {
-        parent::bindValue($column, $value, $type);
-        if ($type === null) {
-            $type = 'string';
-        }
-        if (!ctype_digit($type)) {
-            $value = $this->cast($value, $type)[0];
-        }
-        $this->_compiledParams[$column] = $value;
-    }
-
-    /**
-     * Sets the logger object instance. When called with no arguments
-     * it returns the currently setup logger instance
-     *
-     * @deprecated 3.5.0 Use getLogger() and setLogger() instead.
-     * @param \Cake\Database\Log\QueryLogger|null $instance Logger object instance.
-     * @return \Cake\Database\Log\QueryLogger|null Logger instance
-     */
-    public function logger($instance = null)
-    {
-        if ($instance === null) {
-            return $this->getLogger();
-        }
-
-        return $this->_logger = $instance;
-    }
-
-    /**
-     * Sets a logger
-     *
-     * @param \Cake\Database\Log\QueryLogger $logger Logger object
-     * @return void
-     */
-    public function setLogger($logger)
-    {
-        $this->_logger = $logger;
-    }
-
-    /**
-     * Gets the logger object
-     *
-     * @return \Cake\Database\Log\QueryLogger logger instance
-     */
-    public function getLogger()
-    {
-        return $this->_logger;
-    }
-}
diff --git a/src/Database/Log/QueryLogger.php b/src/Database/Log/QueryLogger.php
index c80669e9304..4f0974427d6 100644
--- a/src/Database/Log/QueryLogger.php
+++ b/src/Database/Log/QueryLogger.php
@@ -1,4 +1,6 @@
  $config Configuration array
      */
-    public function log(LoggedQuery $query)
+    public function __construct(array $config = [])
     {
-        if (!empty($query->params)) {
-            $query->query = $this->_interpolate($query);
-        }
-        $this->_log($query);
-    }
+        $this->_defaultConfig['scopes'] = ['queriesLog', 'cake.database.queries'];
+        $this->_defaultConfig['connection'] = '';
 
-    /**
-     * Wrapper function for the logger object, useful for unit testing
-     * or for overriding in subclasses.
-     *
-     * @param \Cake\Database\Log\LoggedQuery $query to be written in log
-     * @return void
-     */
-    protected function _log($query)
-    {
-        Log::write('debug', $query, ['queriesLog']);
+        parent::__construct($config);
     }
 
     /**
-     * Helper function used to replace query placeholders by the real
-     * params used to execute the query
-     *
-     * @param \Cake\Database\Log\LoggedQuery $query The query to log
-     * @return string
+     * @inheritDoc
      */
-    protected function _interpolate($query)
+    public function log($level, string|Stringable $message, array $context = []): void
     {
-        $params = array_map(function ($p) {
-            if ($p === null) {
-                return 'NULL';
-            }
-            if (is_bool($p)) {
-                return $p ? '1' : '0';
-            }
-
-            if (is_string($p)) {
-                $replacements = [
-                    '$' => '\\$',
-                    '\\' => '\\\\\\\\',
-                    "'" => "''",
-                ];
-
-                $p = strtr($p, $replacements);
-
-                return "'$p'";
-            }
-
-            return $p;
-        }, $query->params);
-
-        $keys = [];
-        $limit = is_int(key($params)) ? 1 : -1;
-        foreach ($params as $key => $param) {
-            $keys[] = is_string($key) ? "/:$key\b/" : '/[?]/';
+        $context += [
+            'scope' => $this->scopes() ?: ['queriesLog', 'cake.database.queries'],
+            'connection' => $this->getConfig('connection'),
+            'query' => null,
+        ];
+
+        if ($context['query'] instanceof LoggedQuery) {
+            $context = $context['query']->getContext() + $context;
+            $message = 'connection={connection} role={role} duration={took} rows={numRows} ' . $message;
         }
-
-        return preg_replace($keys, $params, $query->query, $limit);
+        Log::write('debug', (string)$message, $context);
     }
 }
diff --git a/src/Database/PostgresCompiler.php b/src/Database/PostgresCompiler.php
new file mode 100644
index 00000000000..e973d0a3189
--- /dev/null
+++ b/src/Database/PostgresCompiler.php
@@ -0,0 +1,96 @@
+
+     */
+    protected array $_templates = [
+        'delete' => 'DELETE',
+        'where' => ' WHERE %s',
+        'group' => ' GROUP BY %s',
+        'order' => ' %s',
+        'limit' => ' LIMIT %s',
+        'offset' => ' OFFSET %s',
+        'epilog' => ' %s',
+        'comment' => '/* %s */ ',
+    ];
+
+    /**
+     * Helper function used to build the string representation of a HAVING clause,
+     * it constructs the field list taking care of aliasing and
+     * converting expression objects to string.
+     *
+     * @param array $parts list of fields to be transformed to string
+     * @param \Cake\Database\Query $query The query that is being compiled
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
+     * @return string
+     */
+    protected function _buildHavingPart(array $parts, Query $query, ValueBinder $binder): string
+    {
+        $selectParts = $query->clause('select');
+
+        foreach ($selectParts as $selectKey => $selectPart) {
+            if (!$selectPart instanceof FunctionExpression) {
+                continue;
+            }
+            foreach ($parts as $k => $p) {
+                if (!is_string($p)) {
+                    continue;
+                }
+                preg_match_all(
+                    '/\b' . trim($selectKey, '"') . '\b/i',
+                    $p,
+                    $matches,
+                );
+
+                if (empty($matches[0])) {
+                    continue;
+                }
+
+                $parts[$k] = preg_replace(
+                    ['/"/', '/\b' . trim($selectKey, '"') . '\b/i'],
+                    ['', $selectPart->sql($binder)],
+                    $p,
+                );
+            }
+        }
+
+        return sprintf(' HAVING %s', implode(', ', $parts));
+    }
+}
diff --git a/src/Database/Query.php b/src/Database/Query.php
index 0b71aad58cf..923a9889548 100644
--- a/src/Database/Query.php
+++ b/src/Database/Query.php
@@ -1,4 +1,6 @@
 
      */
-    protected $_parts = [
+    protected array $_parts = [
+        'comment' => null,
         'delete' => true,
         'update' => [],
         'set' => [],
         'insert' => [],
         'values' => [],
+        'with' => [],
+        'optimizerHint' => [],
         'select' => [],
         'distinct' => false,
         'modifier' => [],
@@ -68,11 +116,14 @@ class Query implements ExpressionInterface, IteratorAggregate
         'where' => null,
         'group' => [],
         'having' => null,
+        'window' => [],
         'order' => null,
         'limit' => null,
         'offset' => null,
         'union' => [],
-        'epilog' => null
+        'except' => [],
+        'epilog' => null,
+        'intersect' => [],
     ];
 
     /**
@@ -82,60 +133,27 @@ class Query implements ExpressionInterface, IteratorAggregate
      *
      * @var bool
      */
-    protected $_dirty = false;
-
-    /**
-     * A list of callback functions to be called to alter each row from resulting
-     * statement upon retrieval. Each one of the callback function will receive
-     * the row array as first argument.
-     *
-     * @var array
-     */
-    protected $_resultDecorators = [];
+    protected bool $_dirty = false;
 
     /**
-     * Statement object resulting from executing this query.
-     *
      * @var \Cake\Database\StatementInterface|null
      */
-    protected $_iterator;
+    protected ?StatementInterface $_statement = null;
 
     /**
      * The object responsible for generating query placeholders and temporarily store values
      * associated to each of those.
      *
-     * @var \Cake\Database\ValueBinder|false|null
+     * @var \Cake\Database\ValueBinder|null
      */
-    protected $_valueBinder;
+    protected ?ValueBinder $_valueBinder = null;
 
     /**
      * Instance of functions builder object used for generating arbitrary SQL functions.
      *
      * @var \Cake\Database\FunctionsBuilder|null
      */
-    protected $_functionsBuilder;
-
-    /**
-     * Boolean for tracking whether or not buffered results
-     * are enabled.
-     *
-     * @var bool
-     */
-    protected $_useBufferedResults = true;
-
-    /**
-     * The Type map for fields in the select clause
-     *
-     * @var \Cake\Database\TypeMap
-     */
-    protected $_selectTypeMap;
-
-    /**
-     * Tracking flag to ensure only one type caster is appended.
-     *
-     * @var bool
-     */
-    protected $_typeCastAttached = false;
+    protected ?FunctionsBuilder $_functionsBuilder = null;
 
     /**
      * Constructor.
@@ -143,7 +161,7 @@ class Query implements ExpressionInterface, IteratorAggregate
      * @param \Cake\Database\Connection $connection The connection
      * object to be used for transforming and executing this query
      */
-    public function __construct($connection)
+    public function __construct(Connection $connection)
     {
         $this->setConnection($connection);
     }
@@ -154,7 +172,7 @@ public function __construct($connection)
      * @param \Cake\Database\Connection $connection Connection instance
      * @return $this
      */
-    public function setConnection($connection)
+    public function setConnection(Connection $connection)
     {
         $this->_dirty();
         $this->_connection = $connection;
@@ -167,26 +185,32 @@ public function setConnection($connection)
      *
      * @return \Cake\Database\Connection
      */
-    public function getConnection()
+    public function getConnection(): Connection
     {
         return $this->_connection;
     }
 
     /**
-     * Sets the connection instance to be used for executing and transforming this query
-     * When called with a null argument, it will return the current connection instance.
+     * Returns the connection role ('read' or 'write')
      *
-     * @deprecated 3.4.0 Use setConnection()/getConnection() instead.
-     * @param \Cake\Database\Connection|null $connection Connection instance
-     * @return $this|\Cake\Database\Connection
+     * @return string
      */
-    public function connection($connection = null)
+    public function getConnectionRole(): string
     {
-        if ($connection !== null) {
-            return $this->setConnection($connection);
-        }
+        return $this->connectionRole;
+    }
 
-        return $this->getConnection();
+    /**
+     * Returns driver for current connection role by default.
+     *
+     * See `Query::getConnectionRole()` for role options.
+     *
+     * @param string|null $role Connection role
+     * @return \Cake\Database\Driver
+     */
+    public function getDriver(?string $role = null): Driver
+    {
+        return $this->_connection->getDriver($role ?? $this->connectionRole);
     }
 
     /**
@@ -209,21 +233,13 @@ public function connection($connection = null)
      *
      * @return \Cake\Database\StatementInterface
      */
-    public function execute()
+    public function execute(): StatementInterface
     {
-        $statement = $this->_connection->run($this);
-        $typeMap = $this->getSelectTypeMap();
-
-        if ($typeMap->toArray() && $this->_typeCastAttached === false) {
-            $driver = $this->_connection->getDriver();
-            $this->decorateResults(new FieldTypeConverter($typeMap, $driver));
-            $this->_typeCastAttached = true;
-        }
-
-        $this->_iterator = $this->_decorateStatement($statement);
+        $this->_statement = null;
+        $this->_statement = $this->_connection->run($this);
         $this->_dirty = false;
 
-        return $this->_iterator;
+        return $this->_statement;
     }
 
     /**
@@ -247,7 +263,7 @@ public function execute()
      *
      * @return int
      */
-    public function rowCountAndClose()
+    public function rowCountAndClose(): int
     {
         $statement = $this->execute();
         try {
@@ -269,18 +285,20 @@ public function rowCountAndClose()
      * values when the query is executed, hence it is most suitable to use with
      * prepared statements.
      *
-     * @param \Cake\Database\ValueBinder|null $generator A placeholder object that will hold
-     * associated values for expressions
+     * To get the fully rendered query with the placeholders replaced with the actual
+     * values, `(string)$query` should be used, instead.
+     *
+     * @param \Cake\Database\ValueBinder|null $binder Value binder that generates parameter placeholders
      * @return string
      */
-    public function sql(ValueBinder $generator = null)
+    public function sql(?ValueBinder $binder = null): string
     {
-        if (!$generator) {
-            $generator = $this->getValueBinder();
-            $generator->resetCount();
+        if (!$binder) {
+            $binder = $this->getValueBinder();
+            $binder->resetCount();
         }
 
-        return $this->getConnection()->compileQuery($this, $generator);
+        return $this->getDriver()->compileQuery($this, $binder);
     }
 
     /**
@@ -292,131 +310,137 @@ public function sql(ValueBinder $generator = null)
      * The callback will receive 2 parameters, the first one is the value of the query
      * part that is being iterated and the second the name of such part.
      *
-     * ### Example:
+     * ### Example
      * ```
      * $query->select(['title'])->from('articles')->traverse(function ($value, $clause) {
      *     if ($clause === 'select') {
      *         var_dump($value);
      *     }
-     * }, ['select', 'from']);
+     * });
      * ```
      *
-     * @param callable $visitor A function or callable to be executed for each part
-     * @param array $parts The query clauses to traverse
+     * @param \Closure $callback Callback to be executed for each part
      * @return $this
      */
-    public function traverse(callable $visitor, array $parts = [])
+    public function traverse(Closure $callback)
     {
-        $parts = $parts ?: array_keys($this->_parts);
-        foreach ($parts as $name) {
-            $visitor($this->_parts[$name], $name);
+        foreach ($this->_parts as $name => $part) {
+            $callback($part, $name);
         }
 
         return $this;
     }
 
     /**
-     * Adds new fields to be returned by a `SELECT` statement when this query is
-     * executed. Fields can be passed as an array of strings, array of expression
-     * objects, a single expression or a single string.
-     *
-     * If an array is passed, keys will be used to alias fields using the value as the
-     * real field to be aliased. It is possible to alias strings, Expression objects or
-     * even other Query objects.
+     * Will iterate over the provided parts.
      *
-     * If a callable function is passed, the returning array of the function will
-     * be used as the list of fields.
+     * Traversing functions can aggregate results using variables in the closure
+     * or instance variables. This method can be used to traverse a subset of
+     * query parts in order to render a SQL query.
      *
-     * By default this function will append any passed argument to the list of fields
-     * to be selected, unless the second argument is set to true.
+     * The callback will receive 2 parameters, the first one is the value of the query
+     * part that is being iterated and the second the name of such part.
      *
-     * ### Examples:
+     * ### Example
      *
      * ```
-     * $query->select(['id', 'title']); // Produces SELECT id, title
-     * $query->select(['author' => 'author_id']); // Appends author: SELECT id, title, author_id as author
-     * $query->select('id', true); // Resets the list: SELECT id
-     * $query->select(['total' => $countQuery]); // SELECT id, (SELECT ...) AS total
-     * $query->select(function ($query) {
-     *     return ['article_id', 'total' => $query->count('*')];
-     * })
+     * $query->select(['title'])->from('articles')->traverseParts(function ($value, $clause) {
+     *     if ($clause === 'select') {
+     *         var_dump($value);
+     *     }
+     * }, ['select', 'from']);
      * ```
      *
-     * By default no fields are selected, if you have an instance of `Cake\ORM\Query` and try to append
-     * fields you should also call `Cake\ORM\Query::enableAutoFields()` to select the default fields
-     * from the table.
-     *
-     * @param array|\Cake\Database\ExpressionInterface|string|callable $fields fields to be added to the list.
-     * @param bool $overwrite whether to reset fields with passed list or not
+     * @param \Closure $visitor Callback executed for each part
+     * @param array $parts The list of query parts to traverse
      * @return $this
      */
-    public function select($fields = [], $overwrite = false)
+    public function traverseParts(Closure $visitor, array $parts)
     {
-        if (!is_string($fields) && is_callable($fields)) {
-            $fields = $fields($this);
-        }
-
-        if (!is_array($fields)) {
-            $fields = [$fields];
-        }
-
-        if ($overwrite) {
-            $this->_parts['select'] = $fields;
-        } else {
-            $this->_parts['select'] = array_merge($this->_parts['select'], $fields);
+        foreach ($parts as $name) {
+            $visitor($this->_parts[$name], $name);
         }
 
-        $this->_dirty();
-        $this->_type = 'select';
-
         return $this;
     }
 
     /**
-     * Adds a `DISTINCT` clause to the query to remove duplicates from the result set.
-     * This clause can only be used for select statements.
-     *
-     * If you wish to filter duplicates based of those rows sharing a particular field
-     * or set of fields, you may pass an array of fields to filter on. Beware that
-     * this option might not be fully supported in all database systems.
+     * Adds a new common table expression (CTE) to the query.
      *
      * ### Examples:
      *
+     * Common table expressions can either be passed as preconstructed expression
+     * objects:
+     *
+     * ```
+     * $cte = new \Cake\Database\Expression\CommonTableExpression(
+     *     'cte',
+     *     $connection
+     *         ->selectQuery('*')
+     *         ->from('articles')
+     * );
+     *
+     * $query->with($cte);
      * ```
-     * // Filters products with the same name and city
-     * $query->select(['name', 'city'])->from('products')->distinct();
      *
-     * // Filters products in the same city
-     * $query->distinct(['city']);
-     * $query->distinct('city');
+     * or returned from a closure, which will receive a new common table expression
+     * object as the first argument, and a new blank select query object as
+     * the second argument:
+     *
+     * ```
+     * $query->with(function (
+     *     \Cake\Database\Expression\CommonTableExpression $cte,
+     *     \Cake\Database\Query $query
+     *  ) {
+     *     $cteQuery = $query
+     *         ->select('*')
+     *         ->from('articles');
      *
-     * // Filter products with the same name
-     * $query->distinct(['name'], true);
-     * $query->distinct('name', true);
+     *     return $cte
+     *         ->name('cte')
+     *         ->query($cteQuery);
+     * });
      * ```
      *
-     * @param array|\Cake\Database\ExpressionInterface|string|bool $on Enable/disable distinct class
-     * or list of fields to be filtered on
-     * @param bool $overwrite whether to reset fields with passed list or not
+     * @param \Cake\Database\Expression\CommonTableExpression|\Closure|array<\Cake\Database\Expression\CommonTableExpression|\Closure> $cte The CTE to add.
+     * @param bool $overwrite Whether to reset the list of CTEs.
      * @return $this
      */
-    public function distinct($on = [], $overwrite = false)
+    public function with(CommonTableExpression|Closure|array $cte, bool $overwrite = false)
     {
-        if ($on === []) {
-            $on = true;
-        } elseif (is_string($on)) {
-            $on = [$on];
-        }
-
-        if (is_array($on)) {
-            $merge = [];
-            if (is_array($this->_parts['distinct'])) {
-                $merge = $this->_parts['distinct'];
+        $this->_dirty();
+        if ($overwrite) {
+            $this->_parts['with'] = [];
+        }
+
+        $ctes = is_array($cte) ? $cte : [$cte];
+        foreach ($ctes as $cte) {
+            if ($cte instanceof Closure) {
+                $query = $this->getConnection()->selectQuery();
+                $cte = $cte(new CommonTableExpression(), $query);
+                if (!($cte instanceof CommonTableExpression)) {
+                    throw new CakeException(
+                        'You must return a `CommonTableExpression` from a Closure passed to `with()`.',
+                    );
+                }
             }
-            $on = $overwrite ? array_values($on) : array_merge($merge, array_values($on));
+            $this->_parts['with'][] = $cte;
         }
 
-        $this->_parts['distinct'] = $on;
+        return $this;
+    }
+
+    /**
+     * Add engine-specific optimizer hint.
+     *
+     * @param array|string $hint Optimizer hint
+     * @param bool $overwrite Whether to replace existing hints
+     * @return $this
+     */
+    public function optimizerHint(array|string $hint, bool $overwrite = false)
+    {
+        $hints = array_values((array)$hint);
+        $this->_parts['optimizerHint'] = $overwrite ? $hints : array_merge($this->_parts['optimizerHint'], $hints);
         $this->_dirty();
 
         return $this;
@@ -440,17 +464,20 @@ public function distinct($on = [], $overwrite = false)
      * // It will produce the SQL: SELECT HIGH_PRIORITY SQL_NO_CACHE name, city FROM products
      * ```
      *
-     * @param array|\Cake\Database\ExpressionInterface|string $modifiers modifiers to be applied to the query
+     * @param \Cake\Database\ExpressionInterface|array|string $modifiers modifiers to be applied to the query
      * @param bool $overwrite whether to reset order with field list or not
      * @return $this
      */
-    public function modifier($modifiers, $overwrite = false)
+    public function modifier(ExpressionInterface|array|string $modifiers, bool $overwrite = false)
     {
         $this->_dirty();
         if ($overwrite) {
             $this->_parts['modifier'] = [];
         }
-        $this->_parts['modifier'] = array_merge($this->_parts['modifier'], (array)$modifiers);
+        if (!is_array($modifiers)) {
+            $modifiers = [$modifiers];
+        }
+        $this->_parts['modifier'] = array_merge($this->_parts['modifier'], $modifiers);
 
         return $this;
     }
@@ -482,14 +509,10 @@ public function modifier($modifiers, $overwrite = false)
      *  passed as an array of strings, array of expression objects, or a single string. See
      *  the examples above for the valid call types.
      * @param bool $overwrite whether to reset tables with passed list or not
-     * @return $this|array
+     * @return $this
      */
-    public function from($tables = [], $overwrite = false)
+    public function from(array|string $tables = [], bool $overwrite = false)
     {
-        if (empty($tables)) {
-            return $this->_parts['from'];
-        }
-
         $tables = (array)$tables;
 
         if ($overwrite) {
@@ -583,18 +606,14 @@ public function from($tables = [], $overwrite = false)
      * $query->join(['something' => 'different_table'], [], true); // resets joins list
      * ```
      *
-     * @param array|string|null $tables list of tables to be joined in the query
-     * @param array $types associative array of type names used to bind values to query
-     * @param bool $overwrite whether to reset joins with passed list or not
-     * @see \Cake\Database\Type
-     * @return $this|array
+     * @param array|string $tables List of tables to be joined in the query.
+     * @param array $types Associative array of type names used to bind values to query.
+     * @param bool $overwrite Whether to reset joins with passed list or not.
+     * @see \Cake\Database\TypeFactory
+     * @return $this
      */
-    public function join($tables = null, $types = [], $overwrite = false)
+    public function join(array|string $tables, array $types = [], bool $overwrite = false)
     {
-        if ($tables === null) {
-            return $this->_parts['join'];
-        }
-
         if (is_string($tables) || isset($tables['table'])) {
             $tables = [$tables];
         }
@@ -603,18 +622,18 @@ public function join($tables = null, $types = [], $overwrite = false)
         $i = count($this->_parts['join']);
         foreach ($tables as $alias => $t) {
             if (!is_array($t)) {
-                $t = ['table' => $t, 'conditions' => $this->newExpr()];
+                $t = ['table' => $t, 'conditions' => $this->expr()];
             }
 
-            if (!is_string($t['conditions']) && is_callable($t['conditions'])) {
-                $t['conditions'] = $t['conditions']($this->newExpr(), $this);
+            if ($t['conditions'] instanceof Closure) {
+                $t['conditions'] = $t['conditions']($this->expr(), $this);
             }
 
             if (!($t['conditions'] instanceof ExpressionInterface)) {
-                $t['conditions'] = $this->newExpr()->add($t['conditions'], $types);
+                $t['conditions'] = $this->expr()->add($t['conditions'], $types);
             }
             $alias = is_string($alias) ? $alias : null;
-            $joins[$alias ?: $i++] = $t + ['type' => QueryInterface::JOIN_TYPE_INNER, 'alias' => $alias];
+            $joins[$alias ?: $i++] = $t + ['type' => static::JOIN_TYPE_INNER, 'alias' => $alias];
         }
 
         if ($overwrite) {
@@ -637,7 +656,7 @@ public function join($tables = null, $types = [], $overwrite = false)
      * @param string $name The alias/name of the join to remove.
      * @return $this
      */
-    public function removeJoin($name)
+    public function removeJoin(string $name)
     {
         unset($this->_parts['join'][$name]);
         $this->_dirty();
@@ -675,16 +694,21 @@ public function removeJoin($name)
      *
      * See `join()` for further details on conditions and types.
      *
-     * @param string|array $table The table to join with
-     * @param string|array|\Cake\Database\ExpressionInterface $conditions The conditions
+     * @param array>|string $table The table to join with
+     * @param \Cake\Database\ExpressionInterface|\Closure|array|string $conditions The conditions
      * to use for joining.
      * @param array $types a list of types associated to the conditions used for converting
      * values to the corresponding database representation.
      * @return $this
      */
-    public function leftJoin($table, $conditions = [], $types = [])
-    {
-        return $this->join($this->_makeJoin($table, $conditions, QueryInterface::JOIN_TYPE_LEFT), $types);
+    public function leftJoin(
+        array|string $table,
+        ExpressionInterface|Closure|array|string $conditions = [],
+        array $types = [],
+    ) {
+        $this->join($this->_makeJoin($table, $conditions, static::JOIN_TYPE_LEFT), $types);
+
+        return $this;
     }
 
     /**
@@ -695,16 +719,21 @@ public function leftJoin($table, $conditions = [], $types = [])
      * The arguments of this method are identical to the `leftJoin()` shorthand, please refer
      * to that methods description for further details.
      *
-     * @param string|array $table The table to join with
-     * @param string|array|\Cake\Database\ExpressionInterface $conditions The conditions
+     * @param array>|string $table The table to join with
+     * @param \Cake\Database\ExpressionInterface|\Closure|array|string $conditions The conditions
      * to use for joining.
      * @param array $types a list of types associated to the conditions used for converting
      * values to the corresponding database representation.
      * @return $this
      */
-    public function rightJoin($table, $conditions = [], $types = [])
-    {
-        return $this->join($this->_makeJoin($table, $conditions, QueryInterface::JOIN_TYPE_RIGHT), $types);
+    public function rightJoin(
+        array|string $table,
+        ExpressionInterface|Closure|array|string $conditions = [],
+        array $types = [],
+    ) {
+        $this->join($this->_makeJoin($table, $conditions, static::JOIN_TYPE_RIGHT), $types);
+
+        return $this;
     }
 
     /**
@@ -713,44 +742,53 @@ public function rightJoin($table, $conditions = [], $types = [])
      * This is a shorthand method for building joins via `join()`.
      *
      * The arguments of this method are identical to the `leftJoin()` shorthand, please refer
-     * to that methods description for further details.
+     * to that method's description for further details.
      *
-     * @param string|array $table The table to join with
-     * @param string|array|\Cake\Database\ExpressionInterface $conditions The conditions
+     * @param array>|string $table The table to join with
+     * @param \Cake\Database\ExpressionInterface|\Closure|array|string $conditions The conditions
      * to use for joining.
-     * @param array $types a list of types associated to the conditions used for converting
+     * @param array $types a list of types associated to the conditions used for converting
      * values to the corresponding database representation.
      * @return $this
      */
-    public function innerJoin($table, $conditions = [], $types = [])
-    {
-        return $this->join($this->_makeJoin($table, $conditions, QueryInterface::JOIN_TYPE_INNER), $types);
+    public function innerJoin(
+        array|string $table,
+        ExpressionInterface|Closure|array|string $conditions = [],
+        array $types = [],
+    ) {
+        $this->join($this->_makeJoin($table, $conditions, static::JOIN_TYPE_INNER), $types);
+
+        return $this;
     }
 
     /**
      * Returns an array that can be passed to the join method describing a single join clause
      *
-     * @param string|array $table The table to join with
-     * @param string|array|\Cake\Database\ExpressionInterface $conditions The conditions
+     * @param array>|string $table The table to join with
+     * @param \Cake\Database\ExpressionInterface|\Closure|array|string $conditions The conditions
      * to use for joining.
      * @param string $type the join type to use
-     * @return array
-     */
-    protected function _makeJoin($table, $conditions, $type)
-    {
-        $alias = $table;
-
-        if (is_array($table)) {
+     * @return array, conditions: \Cake\Database\ExpressionInterface|\Closure|array|string, type: string}>
+     */
+    protected function _makeJoin(
+        array|string $table,
+        ExpressionInterface|Closure|array|string $conditions,
+        string $type,
+    ): array {
+        if (is_string($table)) {
+            $alias = $table;
+        } else {
+            /** @var string $alias */
             $alias = key($table);
-            $table = current($table);
+            $table = $table[$alias];
         }
 
         return [
             $alias => [
                 'table' => $table,
                 'conditions' => $conditions,
-                'type' => $type
-            ]
+                'type' => $type,
+            ],
         ];
     }
 
@@ -806,6 +844,10 @@ protected function _makeJoin($table, $conditions, $type)
      *
      * `$query->where(['OR' => [['published' => false], ['published' => true]])`
      *
+     * Would result in:
+     *
+     * `WHERE (published = false) OR (published = true)`
+     *
      * Keep in mind that every time you call where() with the third param set to false
      * (default), it will join the passed conditions to the previous stored list using
      * the `AND` operator. Also, using the same array key twice in consecutive calls to
@@ -814,7 +856,7 @@ protected function _makeJoin($table, $conditions, $type)
      * ### Using expressions objects:
      *
      * ```
-     * $exp = $query->newExpr()->add(['id !=' => 100, 'author_id' != 1])->tieWith('OR');
+     * $exp = $query->expr()->add(['id !=' => 100, 'author_id' != 1])->tieWith('OR');
      * $query->where(['published' => true], ['published' => 'boolean'])->where($exp);
      * ```
      *
@@ -826,7 +868,7 @@ protected function _makeJoin($table, $conditions, $type)
      *
      * ### Adding conditions in multiple steps:
      *
-     * You can use callable functions to construct complex expressions, functions
+     * You can use callbacks to construct complex expressions, functions
      * receive as first argument a new QueryExpression object and this query instance
      * as second argument. Functions must return an expression object, that will be
      * added the list of conditions for the query using the `AND` operator.
@@ -835,8 +877,8 @@ protected function _makeJoin($table, $conditions, $type)
      * $query
      *   ->where(['title !=' => 'Hello World'])
      *   ->where(function ($exp, $query) {
-     *     $or = $exp->or_(['id' => 1]);
-     *     $and = $exp->and_(['id >' => 2, 'id <' => 10]);
+     *     $or = $exp->or(['id' => 1]);
+     *     $and = $exp->and(['id >' => 2, 'id <' => 10]);
      *    return $or->add($and);
      *   });
      * ```
@@ -858,22 +900,38 @@ protected function _makeJoin($table, $conditions, $type)
      * Please note that when using the array notation or the expression objects, all
      * *values* will be correctly quoted and transformed to the correspondent database
      * data type automatically for you, thus securing your application from SQL injections.
-     * The keys however, are not treated as unsafe input, and should be sanitized/whitelisted.
+     * The keys however, are not treated as unsafe input, and should be validated/sanitized.
      *
      * If you use string conditions make sure that your values are correctly quoted.
      * The safest thing you can do is to never use string conditions.
      *
-     * @param string|array|\Cake\Database\ExpressionInterface|callable|null $conditions The conditions to filter on.
-     * @param array $types associative array of type names used to bind values to query
+     * ### Using null-able values
+     *
+     * When using values that can be null you can use the 'IS' keyword to let the ORM generate the correct SQL based on the value's type
+     *
+     * ```
+     * $query->where([
+     *     'posted >=' => new DateTime('3 days ago'),
+     *     'category_id IS' => $category,
+     * ]);
+     * ```
+     *
+     * If $category is `null` - it will actually convert that into `category_id IS NULL` - if it's `4` it will convert it into `category_id = 4`
+     *
+     * @param \Cake\Database\ExpressionInterface|\Closure|array|string|null $conditions The conditions to filter on.
+     * @param array $types Associative array of type names used to bind values to query
      * @param bool $overwrite whether to reset conditions with passed list or not
-     * @see \Cake\Database\Type
+     * @see \Cake\Database\TypeFactory
      * @see \Cake\Database\Expression\QueryExpression
      * @return $this
      */
-    public function where($conditions = null, $types = [], $overwrite = false)
-    {
+    public function where(
+        ExpressionInterface|Closure|array|string|null $conditions = null,
+        array $types = [],
+        bool $overwrite = false,
+    ) {
         if ($overwrite) {
-            $this->_parts['where'] = $this->newExpr();
+            $this->_parts['where'] = $this->expr();
         }
         $this->_conjugate('where', $conditions, 'AND', $types);
 
@@ -881,129 +939,200 @@ public function where($conditions = null, $types = [], $overwrite = false)
     }
 
     /**
-     * Connects any previously defined set of conditions to the provided list
-     * using the AND operator. This function accepts the conditions list in the same
-     * format as the method `where` does, hence you can use arrays, expression objects
-     * callback functions or strings.
-     *
-     * It is important to notice that when calling this function, any previous set
-     * of conditions defined for this query will be treated as a single argument for
-     * the AND operator. This function will not only operate the most recently defined
-     * condition, but all the conditions as a whole.
-     *
-     * When using an array for defining conditions, creating constraints form each
-     * array entry will use the same logic as with the `where()` function. This means
-     * that each array entry will be joined to the other using the AND operator, unless
-     * you nest the conditions in the array using other operator.
-     *
-     * ### Examples:
+     * Convenience method that adds a NOT NULL condition to the query
      *
-     * ```
-     * $query->where(['title' => 'Hello World')->andWhere(['author_id' => 1]);
-     * ```
+     * @param \Cake\Database\ExpressionInterface|array|string $fields A single field or expressions or a list of them
+     *  that should be not null.
+     * @return $this
+     */
+    public function whereNotNull(ExpressionInterface|array|string $fields)
+    {
+        if (!is_array($fields)) {
+            $fields = [$fields];
+        }
+
+        $exp = $this->expr();
+
+        foreach ($fields as $field) {
+            $exp->isNotNull($field);
+        }
+
+        return $this->where($exp);
+    }
+
+    /**
+     * Convenience method that adds a IS NULL condition to the query
      *
-     * Will produce:
+     * @param \Cake\Database\ExpressionInterface|array|string $fields A single field or expressions or a list of them
+     *   that should be null.
+     * @return $this
+     */
+    public function whereNull(ExpressionInterface|array|string $fields)
+    {
+        if (!is_array($fields)) {
+            $fields = [$fields];
+        }
+
+        $exp = $this->expr();
+
+        foreach ($fields as $field) {
+            $exp->isNull($field);
+        }
+
+        return $this->where($exp);
+    }
+
+    /**
+     * Adds an IN condition or set of conditions to be used in the WHERE clause for this
+     * query.
      *
-     * `WHERE title = 'Hello World' AND author_id = 1`
+     * This method does allow empty inputs in contrast to where() if you set
+     * 'allowEmpty' to true.
+     * Be careful about using it without proper sanity checks.
      *
-     * ```
-     * $query
-     *   ->where(['OR' => ['published' => false, 'published is NULL']])
-     *   ->andWhere(['author_id' => 1, 'comments_count >' => 10])
-     * ```
+     * Options:
      *
-     * Produces:
+     * - `types` - Associative array of type names used to bind values to query
+     * - `allowEmpty` - Allow empty array.
      *
-     * `WHERE (published = 0 OR published IS NULL) AND author_id = 1 AND comments_count > 10`
+     * @param string $field Field
+     * @param array $values Array of values
+     * @param array $options Options
+     * @return $this
+     */
+    public function whereInList(string $field, array $values, array $options = [])
+    {
+        $options += [
+            'types' => [],
+            'allowEmpty' => false,
+        ];
+
+        if ($options['allowEmpty'] && !$values) {
+            return $this->where('1=0');
+        }
+
+        return $this->where([$field . ' IN' => $values], $options['types']);
+    }
+
+    /**
+     * Adds a NOT IN condition or set of conditions to be used in the WHERE clause for this
+     * query.
      *
-     * ```
-     * $query
-     *   ->where(['title' => 'Foo'])
-     *   ->andWhere(function ($exp, $query) {
-     *     return $exp
-     *       ->or_(['author_id' => 1])
-     *       ->add(['author_id' => 2]);
-     *   });
-     * ```
+     * This method does allow empty inputs in contrast to where() if you set
+     * 'allowEmpty' to true.
+     * Be careful about using it without proper sanity checks.
      *
-     * Generates the following conditions:
+     * @param string $field Field
+     * @param array $values Array of values
+     * @param array $options Options
+     * @return $this
+     */
+    public function whereNotInList(string $field, array $values, array $options = [])
+    {
+        $options += [
+            'types' => [],
+            'allowEmpty' => false,
+        ];
+
+        if ($options['allowEmpty'] && !$values) {
+            return $this->where([$field . ' IS NOT' => null]);
+        }
+
+        return $this->where([$field . ' NOT IN' => $values], $options['types']);
+    }
+
+    /**
+     * Adds a NOT IN condition or set of conditions to be used in the WHERE clause for this
+     * query. This also allows the field to be null with a IS NULL condition since the null
+     * value would cause the NOT IN condition to always fail.
      *
-     * `WHERE (title = 'Foo') AND (author_id = 1 OR author_id = 2)`
+     * This method does allow empty inputs in contrast to where() if you set
+     * 'allowEmpty' to true.
+     * Be careful about using it without proper sanity checks.
      *
-     * @param string|array|\Cake\Database\ExpressionInterface|callable $conditions The conditions to add with AND.
-     * @param array $types associative array of type names used to bind values to query
-     * @see \Cake\Database\Query::where()
-     * @see \Cake\Database\Type
+     * @param string $field Field
+     * @param array $values Array of values
+     * @param array $options Options
      * @return $this
      */
-    public function andWhere($conditions, $types = [])
+    public function whereNotInListOrNull(string $field, array $values, array $options = [])
     {
-        $this->_conjugate('where', $conditions, 'AND', $types);
+        $options += [
+            'types' => [],
+            'allowEmpty' => false,
+        ];
 
-        return $this;
+        if ($options['allowEmpty'] && !$values) {
+            return $this->where([$field . ' IS NOT' => null]);
+        }
+
+        return $this->where(
+            [
+                'OR' => [$field . ' NOT IN' => $values, $field . ' IS' => null],
+            ],
+            $options['types'],
+        );
     }
 
     /**
      * Connects any previously defined set of conditions to the provided list
-     * using the OR operator. This function accepts the conditions list in the same
+     * using the AND operator. This function accepts the conditions list in the same
      * format as the method `where` does, hence you can use arrays, expression objects
      * callback functions or strings.
      *
      * It is important to notice that when calling this function, any previous set
      * of conditions defined for this query will be treated as a single argument for
-     * the OR operator. This function will not only operate the most recently defined
+     * the AND operator. This function will not only operate the most recently defined
      * condition, but all the conditions as a whole.
      *
      * When using an array for defining conditions, creating constraints form each
      * array entry will use the same logic as with the `where()` function. This means
-     * that each array entry will be joined to the other using the OR operator, unless
+     * that each array entry will be joined to the other using the AND operator, unless
      * you nest the conditions in the array using other operator.
      *
      * ### Examples:
      *
      * ```
-     * $query->where(['title' => 'Hello World')->orWhere(['title' => 'Foo']);
+     * $query->where(['title' => 'Hello World')->andWhere(['author_id' => 1]);
      * ```
      *
      * Will produce:
      *
-     * `WHERE title = 'Hello World' OR title = 'Foo'`
+     * `WHERE title = 'Hello World' AND author_id = 1`
      *
      * ```
      * $query
      *   ->where(['OR' => ['published' => false, 'published is NULL']])
-     *   ->orWhere(['author_id' => 1, 'comments_count >' => 10])
+     *   ->andWhere(['author_id' => 1, 'comments_count >' => 10])
      * ```
      *
      * Produces:
      *
-     * `WHERE (published = 0 OR published IS NULL) OR (author_id = 1 AND comments_count > 10)`
+     * `WHERE (published = 0 OR published IS NULL) AND author_id = 1 AND comments_count > 10`
      *
      * ```
      * $query
      *   ->where(['title' => 'Foo'])
-     *   ->orWhere(function ($exp, $query) {
+     *   ->andWhere(function ($exp, $query) {
      *     return $exp
-     *       ->or_(['author_id' => 1])
+     *       ->or(['author_id' => 1])
      *       ->add(['author_id' => 2]);
      *   });
      * ```
      *
      * Generates the following conditions:
      *
-     * `WHERE (title = 'Foo') OR (author_id = 1 OR author_id = 2)`
+     * `WHERE (title = 'Foo') AND (author_id = 1 OR author_id = 2)`
      *
-     * @param string|array|\Cake\Database\ExpressionInterface|callable $conditions The conditions to add with OR.
-     * @param array $types associative array of type names used to bind values to query
+     * @param \Cake\Database\ExpressionInterface|\Closure|array|string $conditions The conditions to add with AND.
+     * @param array $types Associative array of type names used to bind values to query
      * @see \Cake\Database\Query::where()
-     * @see \Cake\Database\Type
+     * @see \Cake\Database\TypeFactory
      * @return $this
-     * @deprecated 3.5.0 This method creates hard to predict SQL based on the current query state.
-     *   Use `Query::where()` instead as it has more predicatable and easier to understand behavior.
      */
-    public function orWhere($conditions, $types = [])
+    public function andWhere(ExpressionInterface|Closure|array|string $conditions, array $types = [])
     {
-        $this->_conjugate('where', $conditions, 'OR', $types);
+        $this->_conjugate('where', $conditions, 'AND', $types);
 
         return $this;
     }
@@ -1024,7 +1153,7 @@ public function orWhere($conditions, $types = [])
      * ### Examples:
      *
      * ```
-     * $query->order(['title' => 'DESC', 'author_id' => 'ASC']);
+     * $query->orderBy(['title' => 'DESC', 'author_id' => 'ASC']);
      * ```
      *
      * Produces:
@@ -1032,7 +1161,9 @@ public function orWhere($conditions, $types = [])
      * `ORDER BY title DESC, author_id ASC`
      *
      * ```
-     * $query->order(['title' => 'DESC NULLS FIRST'])->order('author_id');
+     * $query
+     *     ->orderBy(['title' => $query->expr('DESC NULLS FIRST')])
+     *     ->orderBy('author_id');
      * ```
      *
      * Will generate:
@@ -1040,26 +1171,103 @@ public function orWhere($conditions, $types = [])
      * `ORDER BY title DESC NULLS FIRST, author_id`
      *
      * ```
-     * $expression = $query->newExpr()->add(['id % 2 = 0']);
-     * $query->order($expression)->order(['title' => 'ASC']);
+     * $expression = $query->expr()->add(['id % 2 = 0']);
+     * $query->orderBy($expression)->orderBy(['title' => 'ASC']);
      * ```
      *
-     * Will become:
-     *
-     * `ORDER BY (id %2 = 0), title ASC`
+     * and
+     *
+     * ```
+     * $query->orderBy(function ($exp, $query) {
+     *     return [$exp->add(['id % 2 = 0']), 'title' => 'ASC'];
+     * });
+     * ```
+     *
+     * Will both become:
+     *
+     * `ORDER BY (id %2 = 0), title ASC`
+     *
+     * Order fields/directions are not sanitized by the query builder.
+     * You should use an allowed list of fields/directions when passing
+     * in user-supplied data to `order()`.
+     *
+     * If you need to set complex expressions as order conditions, you
+     * should use `orderByAsc()` or `orderByDesc()`.
+     *
+     * @param \Cake\Database\ExpressionInterface|\Closure|array|string $fields fields to be added to the list
+     * @param bool $overwrite whether to reset order with field list or not
+     * @return $this
+     * @deprecated 5.0.0 Use orderBy() instead now that CollectionInterface methods are no longer proxied.
+     */
+    public function order(ExpressionInterface|Closure|array|string $fields, bool $overwrite = false)
+    {
+        deprecationWarning('5.0.0', 'Query::order() is deprecated. Use Query::orderBy() instead.');
+
+        return $this->orderBy($fields, $overwrite);
+    }
+
+    /**
+     * Adds a single or multiple fields to be used in the ORDER clause for this query.
+     * Fields can be passed as an array of strings, array of expression
+     * objects, a single expression or a single string.
+     *
+     * If an array is passed, keys will be used as the field itself and the value will
+     * represent the order in which such field should be ordered. When called multiple
+     * times with the same fields as key, the last order definition will prevail over
+     * the others.
+     *
+     * By default this function will append any passed argument to the list of fields
+     * to be selected, unless the second argument is set to true.
+     *
+     * ### Examples:
+     *
+     * ```
+     * $query->orderBy(['title' => 'DESC', 'author_id' => 'ASC']);
+     * ```
+     *
+     * Produces:
+     *
+     * `ORDER BY title DESC, author_id ASC`
+     *
+     * ```
+     * $query
+     *     ->orderBy(['title' => $query->expr('DESC NULLS FIRST')])
+     *     ->orderBy('author_id');
+     * ```
+     *
+     * Will generate:
+     *
+     * `ORDER BY title DESC NULLS FIRST, author_id`
+     *
+     * ```
+     * $expression = $query->expr()->add(['id % 2 = 0']);
+     * $query->orderBy($expression)->orderBy(['title' => 'ASC']);
+     * ```
+     *
+     * and
+     *
+     * ```
+     * $query->orderBy(function ($exp, $query) {
+     *     return [$exp->add(['id % 2 = 0']), 'title' => 'ASC'];
+     * });
+     * ```
+     *
+     * Will both become:
+     *
+     * `ORDER BY (id %2 = 0), title ASC`
      *
      * Order fields/directions are not sanitized by the query builder.
-     * You should use a whitelist of fields/directions when passing
+     * You should use an allowed list of fields/directions when passing
      * in user-supplied data to `order()`.
      *
      * If you need to set complex expressions as order conditions, you
-     * should use `orderAsc()` or `orderDesc()`.
+     * should use `orderByAsc()` or `orderByDesc()`.
      *
-     * @param array|\Cake\Database\ExpressionInterface|string $fields fields to be added to the list
+     * @param \Cake\Database\ExpressionInterface|\Closure|array|string $fields fields to be added to the list
      * @param bool $overwrite whether to reset order with field list or not
      * @return $this
      */
-    public function order($fields, $overwrite = false)
+    public function orderBy(ExpressionInterface|Closure|array|string $fields, bool $overwrite = false)
     {
         if ($overwrite) {
             $this->_parts['order'] = null;
@@ -1069,9 +1277,7 @@ public function order($fields, $overwrite = false)
             return $this;
         }
 
-        if (!$this->_parts['order']) {
-            $this->_parts['order'] = new OrderByExpression();
-        }
+        $this->_parts['order'] ??= new OrderByExpression();
         $this->_conjugate('order', $fields, '', []);
 
         return $this;
@@ -1086,29 +1292,20 @@ public function order($fields, $overwrite = false)
      * Order fields are not suitable for use with user supplied data as they are
      * not sanitized by the query builder.
      *
-     * @param string|\Cake\Database\Expression\QueryExpression $field The field to order on.
-     * @param bool $overwrite Whether or not to reset the order clauses.
+     * @param \Cake\Database\ExpressionInterface|\Closure|string $field The field to order on.
+     * @param bool $overwrite Whether to reset the order clauses.
      * @return $this
+     * @deprecated 5.0.0 Use orderByAsc() instead now that CollectionInterface methods are no longer proxied.
      */
-    public function orderAsc($field, $overwrite = false)
+    public function orderAsc(ExpressionInterface|Closure|string $field, bool $overwrite = false)
     {
-        if ($overwrite) {
-            $this->_parts['order'] = null;
-        }
-        if (!$field) {
-            return $this;
-        }
+        deprecationWarning('5.0.0', 'Query::orderAsc() is deprecated. Use Query::orderByAsc() instead.');
 
-        if (!$this->_parts['order']) {
-            $this->_parts['order'] = new OrderByExpression();
-        }
-        $this->_parts['order']->add(new OrderClauseExpression($field, 'ASC'));
-
-        return $this;
+        return $this->orderByAsc($field, $overwrite);
     }
 
     /**
-     * Add an ORDER BY clause with a DESC direction.
+     * Add an ORDER BY clause with an ASC direction.
      *
      * This method allows you to set complex expressions
      * as order conditions unlike order()
@@ -1116,11 +1313,11 @@ public function orderAsc($field, $overwrite = false)
      * Order fields are not suitable for use with user supplied data as they are
      * not sanitized by the query builder.
      *
-     * @param string|\Cake\Database\Expression\QueryExpression $field The field to order on.
-     * @param bool $overwrite Whether or not to reset the order clauses.
+     * @param \Cake\Database\ExpressionInterface|\Closure|string $field The field to order on.
+     * @param bool $overwrite Whether to reset the order clauses.
      * @return $this
      */
-    public function orderDesc($field, $overwrite = false)
+    public function orderByAsc(ExpressionInterface|Closure|string $field, bool $overwrite = false)
     {
         if ($overwrite) {
             $this->_parts['order'] = null;
@@ -1129,120 +1326,71 @@ public function orderDesc($field, $overwrite = false)
             return $this;
         }
 
-        if (!$this->_parts['order']) {
-            $this->_parts['order'] = new OrderByExpression();
+        if ($field instanceof Closure) {
+            $field = $field($this->expr(), $this);
         }
-        $this->_parts['order']->add(new OrderClauseExpression($field, 'DESC'));
+
+        $this->_parts['order'] ??= new OrderByExpression();
+
+        /** @var \Cake\Database\Expression\QueryExpression $queryExpr */
+        $queryExpr = $this->_parts['order'];
+        $queryExpr->add(new OrderClauseExpression($field, 'ASC'));
 
         return $this;
     }
 
     /**
-     * Adds a single or multiple fields to be used in the GROUP BY clause for this query.
-     * Fields can be passed as an array of strings, array of expression
-     * objects, a single expression or a single string.
-     *
-     * By default this function will append any passed argument to the list of fields
-     * to be grouped, unless the second argument is set to true.
-     *
-     * ### Examples:
-     *
-     * ```
-     * // Produces GROUP BY id, title
-     * $query->group(['id', 'title']);
+     * Add an ORDER BY clause with a DESC direction.
      *
-     * // Produces GROUP BY title
-     * $query->group('title');
-     * ```
+     * This method allows you to set complex expressions
+     * as order conditions unlike order()
      *
-     * Group fields are not suitable for use with user supplied data as they are
+     * Order fields are not suitable for use with user supplied data as they are
      * not sanitized by the query builder.
      *
-     * @param array|\Cake\Database\ExpressionInterface|string $fields fields to be added to the list
-     * @param bool $overwrite whether to reset fields with passed list or not
+     * @param \Cake\Database\ExpressionInterface|\Closure|string $field The field to order on.
+     * @param bool $overwrite Whether to reset the order clauses.
      * @return $this
+     * @deprecated 5.0.0 Use orderByDesc() instead now that CollectionInterface methods are no longer proxied.
      */
-    public function group($fields, $overwrite = false)
+    public function orderDesc(ExpressionInterface|Closure|string $field, bool $overwrite = false)
     {
-        if ($overwrite) {
-            $this->_parts['group'] = [];
-        }
+        deprecationWarning('5.0.0', 'Query::orderDesc() is deprecated. Use Query::orderByDesc() instead.');
 
-        if (!is_array($fields)) {
-            $fields = [$fields];
-        }
-
-        $this->_parts['group'] = array_merge($this->_parts['group'], array_values($fields));
-        $this->_dirty();
-
-        return $this;
+        return $this->orderByDesc($field, $overwrite);
     }
 
     /**
-     * Adds a condition or set of conditions to be used in the `HAVING` clause for this
-     * query. This method operates in exactly the same way as the method `where()`
-     * does. Please refer to its documentation for an insight on how to using each
-     * parameter.
+     * Add an ORDER BY clause with a DESC direction.
+     *
+     * This method allows you to set complex expressions
+     * as order conditions unlike order()
      *
-     * Having fields are not suitable for use with user supplied data as they are
+     * Order fields are not suitable for use with user supplied data as they are
      * not sanitized by the query builder.
      *
-     * @param string|array|\Cake\Database\ExpressionInterface|callable|null $conditions The having conditions.
-     * @param array $types associative array of type names used to bind values to query
-     * @param bool $overwrite whether to reset conditions with passed list or not
-     * @see \Cake\Database\Query::where()
+     * @param \Cake\Database\ExpressionInterface|\Closure|string $field The field to order on.
+     * @param bool $overwrite Whether to reset the order clauses.
      * @return $this
      */
-    public function having($conditions = null, $types = [], $overwrite = false)
+    public function orderByDesc(ExpressionInterface|Closure|string $field, bool $overwrite = false)
     {
         if ($overwrite) {
-            $this->_parts['having'] = $this->newExpr();
+            $this->_parts['order'] = null;
+        }
+        if (!$field) {
+            return $this;
         }
-        $this->_conjugate('having', $conditions, 'AND', $types);
 
-        return $this;
-    }
+        if ($field instanceof Closure) {
+            $field = $field($this->expr(), $this);
+        }
 
-    /**
-     * Connects any previously defined set of conditions to the provided list
-     * using the AND operator in the HAVING clause. This method operates in exactly
-     * the same way as the method `andWhere()` does. Please refer to its
-     * documentation for an insight on how to using each parameter.
-     *
-     * Having fields are not suitable for use with user supplied data as they are
-     * not sanitized by the query builder.
-     *
-     * @param string|array|\Cake\Database\ExpressionInterface|callable $conditions The AND conditions for HAVING.
-     * @param array $types associative array of type names used to bind values to query
-     * @see \Cake\Database\Query::andWhere()
-     * @return $this
-     */
-    public function andHaving($conditions, $types = [])
-    {
-        $this->_conjugate('having', $conditions, 'AND', $types);
+        $this->_parts['order'] ??= new OrderByExpression();
 
-        return $this;
-    }
-
-    /**
-     * Connects any previously defined set of conditions to the provided list
-     * using the OR operator in the HAVING clause. This method operates in exactly
-     * the same way as the method `orWhere()` does. Please refer to its
-     * documentation for an insight on how to using each parameter.
-     *
-     * Having fields are not suitable for use with user supplied data as they are
-     * not sanitized by the query builder.
-     *
-     * @param string|array|\Cake\Database\ExpressionInterface|callable $conditions The OR conditions for HAVING.
-     * @param array $types associative array of type names used to bind values to query.
-     * @see \Cake\Database\Query::orWhere()
-     * @return $this
-     * @deprecated 3.5.0 This method creates hard to predict SQL based on the current query state.
-     *   Use `Query::having()` instead as it has more predicatable and easier to understand behavior.
-     */
-    public function orHaving($conditions, $types = [])
-    {
-        $this->_conjugate('having', $conditions, 'OR', $types);
+        /** @var \Cake\Database\Expression\QueryExpression $queryExpr */
+        $queryExpr = $this->_parts['order'];
+        $queryExpr->add(new OrderClauseExpression($field, 'DESC'));
 
         return $this;
     }
@@ -1250,34 +1398,18 @@ public function orHaving($conditions, $types = [])
     /**
      * Set the page of results you want.
      *
-     * This method provides an easier to use interface to set the limit + offset
-     * in the record set you want as results. If empty the limit will default to
-     * the existing limit clause, and if that too is empty, then `25` will be used.
-     *
-     * Pages should start at 1.
+     * This method is not implemented in the base Query class and will throw an exception.
+     * It is implemented in subclasses like SelectQuery.
      *
      * @param int $num The page number you want.
      * @param int|null $limit The number of rows you want in the page. If null
      *  the current limit clause will be used.
      * @return $this
+     * @throws \Cake\Core\Exception\CakeException Always thrown as this method is not implemented in the base class
      */
-    public function page($num, $limit = null)
+    public function page(int $num, ?int $limit = null)
     {
-        if ($limit !== null) {
-            $this->limit($limit);
-        }
-        $limit = $this->clause('limit');
-        if ($limit === null) {
-            $limit = 25;
-            $this->limit($limit);
-        }
-        $offset = ($num - 1) * $limit;
-        if (PHP_INT_MAX <= $offset) {
-            $offset = PHP_INT_MAX;
-        }
-        $this->offset((int)$offset);
-
-        return $this;
+        throw new CakeException('Not implemented');
     }
 
     /**
@@ -1290,19 +1422,16 @@ public function page($num, $limit = null)
      *
      * ```
      * $query->limit(10) // generates LIMIT 10
-     * $query->limit($query->newExpr()->add(['1 + 1'])); // LIMIT (1 + 1)
+     * $query->limit($query->expr()->add(['1 + 1'])); // LIMIT (1 + 1)
      * ```
      *
-     * @param int|\Cake\Database\ExpressionInterface $num number of records to be returned
+     * @param \Cake\Database\ExpressionInterface|int|null $limit number of records to be returned
      * @return $this
      */
-    public function limit($num)
+    public function limit(ExpressionInterface|int|null $limit)
     {
         $this->_dirty();
-        if ($num !== null && !is_object($num)) {
-            $num = (int)$num;
-        }
-        $this->_parts['limit'] = $num;
+        $this->_parts['limit'] = $limit;
 
         return $this;
     }
@@ -1319,314 +1448,120 @@ public function limit($num)
      *
      * ```
      * $query->offset(10) // generates OFFSET 10
-     * $query->offset($query->newExpr()->add(['1 + 1'])); // OFFSET (1 + 1)
+     * $query->offset($query->expr()->add(['1 + 1'])); // OFFSET (1 + 1)
      * ```
      *
-     * @param int|\Cake\Database\ExpressionInterface $num number of records to be skipped
+     * @param \Cake\Database\ExpressionInterface|int|null $offset number of records to be skipped
      * @return $this
      */
-    public function offset($num)
+    public function offset(ExpressionInterface|int|null $offset)
     {
         $this->_dirty();
-        if ($num !== null && !is_object($num)) {
-            $num = (int)$num;
-        }
-        $this->_parts['offset'] = $num;
+        $this->_parts['offset'] = $offset;
 
         return $this;
     }
 
     /**
-     * Adds a complete query to be used in conjunction with an UNION operator with
-     * this query. This is used to combine the result set of this query with the one
-     * that will be returned by the passed query. You can add as many queries as you
-     * required by calling multiple times this method with different queries.
+     * Creates an expression that refers to an identifier. Identifiers are used to refer to field names and allow
+     * the SQL compiler to apply quotes or escape the identifier.
      *
-     * By default, the UNION operator will remove duplicate rows, if you wish to include
-     * every row for all queries, use unionAll().
+     * The value is used as is, and you might be required to use aliases or include the table reference in
+     * the identifier. Do not use this method to inject SQL methods or logical statements.
      *
-     * ### Examples
+     * ### Example
      *
      * ```
-     * $union = (new Query($conn))->select(['id', 'title'])->from(['a' => 'articles']);
-     * $query->select(['id', 'name'])->from(['d' => 'things'])->union($union);
+     * $query->expr()->lte('count', $query->identifier('total'));
      * ```
      *
-     * Will produce:
-     *
-     * `SELECT id, name FROM things d UNION SELECT id, title FROM articles a`
-     *
-     * @param string|\Cake\Database\Query $query full SQL query to be used in UNION operator
-     * @param bool $overwrite whether to reset the list of queries to be operated or not
-     * @return $this
+     * @param string $identifier The identifier for an expression
+     * @return \Cake\Database\ExpressionInterface
      */
-    public function union($query, $overwrite = false)
+    public function identifier(string $identifier): ExpressionInterface
     {
-        if ($overwrite) {
-            $this->_parts['union'] = [];
-        }
-        $this->_parts['union'][] = [
-            'all' => false,
-            'query' => $query
-        ];
-        $this->_dirty();
-
-        return $this;
+        return new IdentifierExpression($identifier);
     }
 
     /**
-     * Adds a complete query to be used in conjunction with the UNION ALL operator with
-     * this query. This is used to combine the result set of this query with the one
-     * that will be returned by the passed query. You can add as many queries as you
-     * required by calling multiple times this method with different queries.
-     *
-     * Unlike UNION, UNION ALL will not remove duplicate rows.
+     * A string or expression that will be appended to the generated query
      *
+     * ### Examples:
      * ```
-     * $union = (new Query($conn))->select(['id', 'title'])->from(['a' => 'articles']);
-     * $query->select(['id', 'name'])->from(['d' => 'things'])->unionAll($union);
+     * $query->select('id')->where(['author_id' => 1])->epilog('FOR UPDATE');
+     * $query
+     *  ->insert('articles', ['title'])
+     *  ->values(['author_id' => 1])
+     *  ->epilog('RETURNING id');
      * ```
      *
-     * Will produce:
-     *
-     * `SELECT id, name FROM things d UNION ALL SELECT id, title FROM articles a`
-     *
-     * @param string|\Cake\Database\Query $query full SQL query to be used in UNION operator
-     * @param bool $overwrite whether to reset the list of queries to be operated or not
-     * @return $this
-     */
-    public function unionAll($query, $overwrite = false)
-    {
-        if ($overwrite) {
-            $this->_parts['union'] = [];
-        }
-        $this->_parts['union'][] = [
-            'all' => true,
-            'query' => $query
-        ];
-        $this->_dirty();
-
-        return $this;
-    }
-
-    /**
-     * Create an insert query.
-     *
-     * Note calling this method will reset any data previously set
-     * with Query::values().
+     * Epilog content is raw SQL and not suitable for use with user supplied data.
      *
-     * @param array $columns The columns to insert into.
-     * @param array $types A map between columns & their datatypes.
+     * @param \Cake\Database\ExpressionInterface|string|null $expression The expression to be appended
      * @return $this
-     * @throws \RuntimeException When there are 0 columns.
      */
-    public function insert(array $columns, array $types = [])
+    public function epilog(ExpressionInterface|string|null $expression = null)
     {
-        if (empty($columns)) {
-            throw new RuntimeException('At least 1 column is required to perform an insert.');
-        }
         $this->_dirty();
-        $this->_type = 'insert';
-        $this->_parts['insert'][1] = $columns;
-        if (!$this->_parts['values']) {
-            $this->_parts['values'] = new ValuesExpression($columns, $this->getTypeMap()->setTypes($types));
-        } else {
-            $this->_parts['values']->setColumns($columns);
-        }
-
-        return $this;
-    }
-
-    /**
-     * Set the table name for insert queries.
-     *
-     * @param string $table The table name to insert into.
-     * @return $this
-     */
-    public function into($table)
-    {
-        $this->_dirty();
-        $this->_type = 'insert';
-        $this->_parts['insert'][0] = $table;
-
-        return $this;
-    }
-
-    /**
-     * Set the values for an insert query.
-     *
-     * Multi inserts can be performed by calling values() more than one time,
-     * or by providing an array of value sets. Additionally $data can be a Query
-     * instance to insert data from another SELECT statement.
-     *
-     * @param array|\Cake\Database\Query $data The data to insert.
-     * @return $this
-     * @throws \Cake\Database\Exception if you try to set values before declaring columns.
-     *   Or if you try to set values on non-insert queries.
-     */
-    public function values($data)
-    {
-        if ($this->_type !== 'insert') {
-            throw new Exception(
-                'You cannot add values before defining columns to use.'
-            );
-        }
-        if (empty($this->_parts['insert'])) {
-            throw new Exception(
-                'You cannot add values before defining columns to use.'
-            );
-        }
-
-        $this->_dirty();
-        if ($data instanceof ValuesExpression) {
-            $this->_parts['values'] = $data;
-
-            return $this;
-        }
-
-        $this->_parts['values']->add($data);
-
-        return $this;
-    }
-
-    /**
-     * Create an update query.
-     *
-     * Can be combined with set() and where() methods to create update queries.
-     *
-     * @param string|\Cake\Database\ExpressionInterface $table The table you want to update.
-     * @return $this
-     */
-    public function update($table)
-    {
-        if (!is_string($table) && !($table instanceof ExpressionInterface)) {
-            $text = 'Table must be of type string or "%s", got "%s"';
-            $message = sprintf($text, ExpressionInterface::class, gettype($table));
-            throw new InvalidArgumentException($message);
-        }
-
-        $this->_dirty();
-        $this->_type = 'update';
-        $this->_parts['update'][0] = $table;
+        $this->_parts['epilog'] = $expression;
 
         return $this;
     }
 
     /**
-     * Set one or many fields to update.
-     *
-     * ### Examples
-     *
-     * Passing a string:
-     *
-     * ```
-     * $query->update('articles')->set('title', 'The Title');
-     * ```
-     *
-     * Passing an array:
+     * A string or expression that will be appended to the generated query as a comment
      *
+     * ### Examples:
      * ```
-     * $query->update('articles')->set(['title' => 'The Title'], ['title' => 'string']);
+     * $query->select('id')->where(['author_id' => 1])->comment('Filter for admin user');
      * ```
      *
-     * Passing a callable:
+     * Comment content is raw SQL and not suitable for use with user supplied data.
      *
-     * ```
-     * $query->update('articles')->set(function ($exp) {
-     *   return $exp->eq('title', 'The title', 'string');
-     * });
-     * ```
-     *
-     * @param string|array|callable|\Cake\Database\Expression\QueryExpression $key The column name or array of keys
-     *    + values to set. This can also be a QueryExpression containing a SQL fragment.
-     *    It can also be a callable, that is required to return an expression object.
-     * @param mixed $value The value to update $key to. Can be null if $key is an
-     *    array or QueryExpression. When $key is an array, this parameter will be
-     *    used as $types instead.
-     * @param array $types The column types to treat data as.
+     * @param string|null $expression The comment to be added
      * @return $this
      */
-    public function set($key, $value = null, $types = [])
+    public function comment(?string $expression = null)
     {
-        if (empty($this->_parts['set'])) {
-            $this->_parts['set'] = $this->newExpr()->setConjunction(',');
-        }
-
-        if ($this->_parts['set']->isCallable($key)) {
-            $exp = $this->newExpr()->setConjunction(',');
-            $this->_parts['set']->add($key($exp));
-
-            return $this;
-        }
-
-        if (is_array($key) || $key instanceof ExpressionInterface) {
-            $types = (array)$value;
-            $this->_parts['set']->add($key, $types);
-
-            return $this;
-        }
-
-        if (is_string($types) && is_string($key)) {
-            $types = [$key => $types];
-        }
-        $this->_parts['set']->eq($key, $value, $types);
+        $this->_dirty();
+        $this->_parts['comment'] = $expression;
 
         return $this;
     }
 
     /**
-     * Create a delete query.
-     *
-     * Can be combined with from(), where() and other methods to
-     * create delete queries with specific conditions.
+     * Returns the type of this query (select, insert, update, delete)
      *
-     * @param string|null $table The table to use when deleting.
-     * @return $this
+     * @return string
      */
-    public function delete($table = null)
+    public function type(): string
     {
-        $this->_dirty();
-        $this->_type = 'delete';
-        if ($table !== null) {
-            $this->from($table);
-        }
-
-        return $this;
+        return $this->_type;
     }
 
     /**
-     * A string or expression that will be appended to the generated query
+     * Returns a new QueryExpression object. This is a handy function when
+     * building complex queries using a fluent interface. You can also override
+     * this function in subclasses to use a more specialized QueryExpression class
+     * if required.
+     *
+     * You can optionally pass a single raw SQL string or an array or expressions in
+     * any format accepted by \Cake\Database\Expression\QueryExpression:
      *
-     * ### Examples:
      * ```
-     * $query->select('id')->where(['author_id' => 1])->epilog('FOR UPDATE');
-     * $query
-     *  ->insert('articles', ['title'])
-     *  ->values(['author_id' => 1])
-     *  ->epilog('RETURNING id');
+     * $expression = $query->expr(); // Returns an empty expression object
+     * $expression = $query->expr('Table.column = Table2.column'); // Return a raw SQL expression
      * ```
      *
-     * Epliog content is raw SQL and not suitable for use with user supplied data.
-     *
-     * @param string|\Cake\Database\Expression\QueryExpression|null $expression The expression to be appended
-     * @return $this
+     * @param \Cake\Database\ExpressionInterface|array|string|null $rawExpression A string, array or anything you want wrapped in an expression object
+     * @return \Cake\Database\Expression\QueryExpression
+     * @deprecated 5.3.0 Use `expr()` instead of `newExpr()`.
      */
-    public function epilog($expression = null)
+    public function newExpr(ExpressionInterface|array|string|null $rawExpression = null): QueryExpression
     {
-        $this->_dirty();
-        $this->_parts['epilog'] = $expression;
+        deprecationWarning('5.3.0', 'Use `expr()` instead of `newExpr()`.');
 
-        return $this;
-    }
-
-    /**
-     * Returns the type of this query (select, insert, update, delete)
-     *
-     * @return string
-     */
-    public function type()
-    {
-        return $this->_type;
+        return $this->expr($rawExpression);
     }
 
     /**
@@ -1639,14 +1574,14 @@ public function type()
      * any format accepted by \Cake\Database\Expression\QueryExpression:
      *
      * ```
-     * $expression = $query->newExpr(); // Returns an empty expression object
-     * $expression = $query->newExpr('Table.column = Table2.column'); // Return a raw SQL expression
+     * $expression = $query->expr(); // Returns an empty expression object
+     * $expression = $query->expr('Table.column = Table2.column'); // Return a raw SQL expression
      * ```
      *
-     * @param mixed $rawExpression A string, array or anything you want wrapped in an expression object
+     * @param \Cake\Database\ExpressionInterface|array|string|null $rawExpression A string, array or anything you want wrapped in an expression object
      * @return \Cake\Database\Expression\QueryExpression
      */
-    public function newExpr($rawExpression = null)
+    public function expr(ExpressionInterface|array|string|null $rawExpression = null): QueryExpression
     {
         $expression = new QueryExpression([], $this->getTypeMap());
 
@@ -1670,30 +1605,9 @@ public function newExpr($rawExpression = null)
      *
      * @return \Cake\Database\FunctionsBuilder
      */
-    public function func()
+    public function func(): FunctionsBuilder
     {
-        if ($this->_functionsBuilder === null) {
-            $this->_functionsBuilder = new FunctionsBuilder();
-        }
-
-        return $this->_functionsBuilder;
-    }
-
-    /**
-     * Executes this query and returns a results iterator. This function is required
-     * for implementing the IteratorAggregate interface and allows the query to be
-     * iterated without having to call execute() manually, thus making it look like
-     * a result set instead of the query itself.
-     *
-     * @return \Cake\Database\StatementInterface|null
-     */
-    public function getIterator()
-    {
-        if ($this->_iterator === null || $this->_dirty) {
-            $this->_iterator = $this->execute();
-        }
-
-        return $this->_iterator;
+        return $this->_functionsBuilder ??= new FunctionsBuilder();
     }
 
     /**
@@ -1701,7 +1615,7 @@ public function getIterator()
      * modifying any internal part of the query and it is used by the SQL dialects
      * to transform the query accordingly before it is executed. The valid clauses that
      * can be retrieved are: delete, update, set, insert, values, select, distinct,
-     * from, join, set, where, group, having, order, limit, offset and union.
+     * from, join, set, where, group, having, order, limit, offset, union and intersect.
      *
      * The return value for each of those parts may vary. Some clauses use QueryExpression
      * to internally store their state, some use arrays and others may use booleans or
@@ -1723,121 +1637,94 @@ public function getIterator()
      * - limit: integer or QueryExpression, null when not set
      * - offset: integer or QueryExpression, null when not set
      * - union: array
+     * - intersect: array
      *
      * @param string $name name of the clause to be returned
      * @return mixed
-     * @throws InvalidArgumentException When the named clause does not exist.
+     * @throws \InvalidArgumentException When the named clause does not exist.
      */
-    public function clause($name)
+    public function clause(string $name): mixed
     {
         if (!array_key_exists($name, $this->_parts)) {
-            $clauses = implode(', ', array_keys($this->_parts));
-            throw new InvalidArgumentException("The '$name' clause is not defined. Valid clauses are: $clauses");
+            $clauses = array_keys($this->_parts);
+            array_walk($clauses, fn(string &$x) => $x = "`{$x}`");
+            $clauses = implode(', ', $clauses);
+            throw new InvalidArgumentException(sprintf(
+                'The `%s` clause is not defined. Valid clauses are: %s.',
+                $name,
+                $clauses,
+            ));
         }
 
         return $this->_parts[$name];
     }
 
     /**
-     * Registers a callback to be executed for each result that is fetched from the
-     * result set, the callback function will receive as first parameter an array with
-     * the raw data from the database for every row that is fetched and must return the
-     * row with any possible modifications.
-     *
-     * Callbacks will be executed lazily, if only 3 rows are fetched for database it will
-     * called 3 times, event though there might be more rows to be fetched in the cursor.
-     *
-     * Callbacks are stacked in the order they are registered, if you wish to reset the stack
-     * the call this function with the second parameter set to true.
-     *
-     * If you wish to remove all decorators from the stack, set the first parameter
-     * to null and the second to true.
-     *
-     * ### Example
+     * This function works similar to the traverse() function, with the difference
+     * that it does a full depth traversal of the entire expression tree. This will execute
+     * the provided callback function for each ExpressionInterface object that is
+     * stored inside this query at any nesting depth in any part of the query.
      *
-     * ```
-     * $query->decorateResults(function ($row) {
-     *   $row['order_total'] = $row['subtotal'] + ($row['subtotal'] * $row['tax']);
-     *    return $row;
-     * });
-     * ```
+     * Callback will receive as first parameter the currently visited expression.
      *
-     * @param callable|null $callback The callback to invoke when results are fetched.
-     * @param bool $overwrite Whether or not this should append or replace all existing decorators.
+     * @param \Closure $callback the function to be executed for each ExpressionInterface
+     *   found inside this query.
      * @return $this
      */
-    public function decorateResults($callback, $overwrite = false)
+    public function traverseExpressions(Closure $callback)
     {
-        if ($overwrite) {
-            $this->_resultDecorators = [];
-            $this->_typeCastAttached = false;
-        }
-
-        if ($callback !== null) {
-            $this->_resultDecorators[] = $callback;
+        foreach ($this->_parts as $part) {
+            $this->_expressionsVisitor($part, $callback);
         }
 
         return $this;
     }
 
     /**
-     * This function works similar to the traverse() function, with the difference
-     * that it does a full depth traversal of the entire expression tree. This will execute
-     * the provided callback function for each ExpressionInterface object that is
-     * stored inside this query at any nesting depth in any part of the query.
+     * Query parts traversal method used by traverseExpressions()
      *
-     * Callback will receive as first parameter the currently visited expression.
-     *
-     * @param callable $callback the function to be executed for each ExpressionInterface
+     * @param mixed $expression Query expression or
+     *   array of expressions.
+     * @param \Closure $callback The callback to be executed for each ExpressionInterface
      *   found inside this query.
-     * @return $this|null
+     * @return void
      */
-    public function traverseExpressions(callable $callback)
+    protected function _expressionsVisitor(mixed $expression, Closure $callback): void
     {
-        $visitor = function ($expression) use (&$visitor, $callback) {
-            if (is_array($expression)) {
-                foreach ($expression as $e) {
-                    $visitor($e);
-                }
-
-                return null;
+        if (is_array($expression)) {
+            foreach ($expression as $e) {
+                $this->_expressionsVisitor($e, $callback);
             }
 
-            if ($expression instanceof ExpressionInterface) {
-                $expression->traverse($visitor);
+            return;
+        }
+
+        if ($expression instanceof ExpressionInterface) {
+            $expression->traverse(fn($exp) => $this->_expressionsVisitor($exp, $callback));
 
-                if (!($expression instanceof self)) {
-                    $callback($expression);
-                }
+            if (!$expression instanceof self) {
+                $callback($expression);
             }
-        };
-
-        return $this->traverse($visitor);
+        }
     }
 
     /**
      * Associates a query placeholder to a value and a type.
      *
-     * If type is expressed as "atype[]" (note braces) then it will cause the
-     * placeholder to be re-written dynamically so if the value is an array, it
-     * will create as many placeholders as values are in it. For example:
-     *
      * ```
-     * $query->bind(':id', [1, 2, 3], 'int[]');
+     * $query->bind(':id', 1, 'integer');
      * ```
      *
-     * Will create 3 int placeholders. When using named placeholders, this method
-     * requires that the placeholders include `:` e.g. `:value`.
-     *
      * @param string|int $param placeholder to be replaced with quoted version
      *   of $value
      * @param mixed $value The value to be bound
-     * @param string|int $type the mapped type name, used for casting when sending
+     * @param string|int|null $type the mapped type name, used for casting when sending
      *   to database
      * @return $this
      */
-    public function bind($param, $value, $type = 'string')
+    public function bind(string|int $param, mixed $value, string|int|null $type = null)
     {
+        $this->_dirty();
         $this->getValueBinder()->bind($param, $value, $type);
 
         return $this;
@@ -1852,195 +1739,60 @@ public function bind($param, $value, $type = 'string')
      *
      * @return \Cake\Database\ValueBinder
      */
-    public function getValueBinder()
+    public function getValueBinder(): ValueBinder
     {
-        if ($this->_valueBinder === null) {
-            $this->_valueBinder = new ValueBinder();
-        }
-
-        return $this->_valueBinder;
+        return $this->_valueBinder ??= new ValueBinder();
     }
 
     /**
-     * Returns the currently used ValueBinder instance. If a value is passed,
-     * it will be set as the new instance to be used.
+     * Overwrite the current value binder
      *
      * A ValueBinder is responsible for generating query placeholders and temporarily
      * associate values to those placeholders so that they can be passed correctly
      * to the statement object.
      *
-     * @deprecated 3.5.0 Use getValueBinder() for the getter part instead.
-     * @param \Cake\Database\ValueBinder|false|null $binder new instance to be set. If no value is passed the
-     *   default one will be returned
-     * @return $this|\Cake\Database\ValueBinder
-     */
-    public function valueBinder($binder = null)
-    {
-        if ($binder === null) {
-            if ($this->_valueBinder === null) {
-                $this->_valueBinder = new ValueBinder();
-            }
-
-            return $this->_valueBinder;
-        }
-        $this->_valueBinder = $binder;
-
-        return $this;
-    }
-
-    /**
-     * Enables/Disables buffered results.
-     *
-     * When enabled the results returned by this Query will be
-     * buffered. This enables you to iterate a result set multiple times, or
-     * both cache and iterate it.
-     *
-     * When disabled it will consume less memory as fetched results are not
-     * remembered for future iterations.
-     *
-     * @param bool $enable Whether or not to enable buffering
+     * @param \Cake\Database\ValueBinder|null $binder The binder or null to disable binding.
      * @return $this
      */
-    public function enableBufferedResults($enable = true)
+    public function setValueBinder(?ValueBinder $binder)
     {
-        $this->_dirty();
-        $this->_useBufferedResults = (bool)$enable;
-
-        return $this;
-    }
-
-    /**
-     * Returns whether buffered results are enabled/disabled.
-     *
-     * When enabled the results returned by this Query will be
-     * buffered. This enables you to iterate a result set multiple times, or
-     * both cache and iterate it.
-     *
-     * When disabled it will consume less memory as fetched results are not
-     * remembered for future iterations.
-     *
-     * @return bool
-     */
-    public function isBufferedResultsEnabled()
-    {
-        return $this->_useBufferedResults;
-    }
-
-    /**
-     * Enable/Disable buffered results.
-     *
-     * When enabled the results returned by this Query will be
-     * buffered. This enables you to iterate a result set multiple times, or
-     * both cache and iterate it.
-     *
-     * When disabled it will consume less memory as fetched results are not
-     * remembered for future iterations.
-     *
-     * If called with no arguments, it will return whether or not buffering is
-     * enabled.
-     *
-     * @deprecated 3.4.0 Use enableBufferedResults()/isBufferedResultsEnabled() instead.
-     * @param bool|null $enable Whether or not to enable buffering
-     * @return bool|$this
-     */
-    public function bufferResults($enable = null)
-    {
-        if ($enable !== null) {
-            return $this->enableBufferedResults($enable);
-        }
-
-        return $this->isBufferedResultsEnabled();
-    }
-
-    /**
-     * Sets the TypeMap class where the types for each of the fields in the
-     * select clause are stored.
-     *
-     * @param \Cake\Database\TypeMap $typeMap The map object to use
-     * @return $this
-     */
-    public function setSelectTypeMap(TypeMap $typeMap)
-    {
-        $this->_selectTypeMap = $typeMap;
+        $this->_valueBinder = $binder;
 
         return $this;
     }
-    /**
-     * Gets the TypeMap class where the types for each of the fields in the
-     * select clause are stored.
-     *
-     * @return \Cake\Database\TypeMap
-     */
-    public function getSelectTypeMap()
-    {
-        if ($this->_selectTypeMap === null) {
-            $this->_selectTypeMap = new TypeMap();
-        }
-
-        return $this->_selectTypeMap;
-    }
-
-    /**
-     * Sets the TypeMap class where the types for each of the fields in the
-     * select clause are stored.
-     *
-     * When called with no arguments, the current TypeMap object is returned.
-     *
-     * @deprecated 3.4.0 Use setSelectTypeMap()/getSelectTypeMap() instead.
-     * @param \Cake\Database\TypeMap|null $typeMap The map object to use
-     * @return $this|\Cake\Database\TypeMap
-     */
-    public function selectTypeMap(TypeMap $typeMap = null)
-    {
-        if ($typeMap !== null) {
-            return $this->setSelectTypeMap($typeMap);
-        }
-
-        return $this->getSelectTypeMap();
-    }
-
-    /**
-     * Auxiliary function used to wrap the original statement from the driver with
-     * any registered callbacks.
-     *
-     * @param \Cake\Database\StatementInterface $statement to be decorated
-     * @return \Cake\Database\Statement\CallbackStatement
-     */
-    protected function _decorateStatement($statement)
-    {
-        foreach ($this->_resultDecorators as $f) {
-            $statement = new CallbackStatement($statement, $this->getConnection()->getDriver(), $f);
-        }
-
-        return $statement;
-    }
 
     /**
      * Helper function used to build conditions by composing QueryExpression objects.
      *
      * @param string $part Name of the query part to append the new part to
-     * @param string|null|array|\Cake\Database\ExpressionInterface|callable $append Expression or builder function to append.
+     * @param \Cake\Database\ExpressionInterface|\Closure|array|string|null $append Expression or builder function to append.
+     *   to append.
      * @param string $conjunction type of conjunction to be used to operate part
-     * @param array $types associative array of type names used to bind values to query
+     * @param array $types Associative array of type names used to bind values to query
      * @return void
      */
-    protected function _conjugate($part, $append, $conjunction, $types)
-    {
-        $expression = $this->_parts[$part] ?: $this->newExpr();
-        if (empty($append)) {
+    protected function _conjugate(
+        string $part,
+        ExpressionInterface|Closure|array|string|null $append,
+        string $conjunction,
+        array $types,
+    ): void {
+        /** @var \Cake\Database\Expression\QueryExpression $expression */
+        $expression = $this->_parts[$part] ?: $this->expr();
+        if (!$append) {
             $this->_parts[$part] = $expression;
 
             return;
         }
 
-        if ($expression->isCallable($append)) {
-            $append = $append($this->newExpr(), $this);
+        if ($append instanceof Closure) {
+            $append = $append($this->expr(), $this);
         }
 
         if ($expression->getConjunction() === $conjunction) {
             $expression->add($append, $types);
         } else {
-            $expression = $this->newExpr()
+            $expression = $this->expr()
                 ->setConjunction($conjunction)
                 ->add([$expression, $append], $types);
         }
@@ -2055,39 +1807,37 @@ protected function _conjugate($part, $append, $conjunction, $types)
      *
      * @return void
      */
-    protected function _dirty()
+    protected function _dirty(): void
     {
         $this->_dirty = true;
 
-        if ($this->_iterator && $this->_valueBinder) {
+        if ($this->_statement && $this->_valueBinder) {
             $this->getValueBinder()->reset();
         }
     }
 
     /**
-     * Do a deep clone on this object.
-     *
-     * Will clone all of the expression objects used in
-     * each of the clauses, as well as the valueBinder.
-     *
-     * @return void
+     * Handles clearing iterator and cloning all expressions and value binders.
      */
     public function __clone()
     {
-        $this->_iterator = null;
+        $this->_statement = null;
         if ($this->_valueBinder !== null) {
             $this->_valueBinder = clone $this->_valueBinder;
         }
-        if ($this->_selectTypeMap !== null) {
-            $this->_selectTypeMap = clone $this->_selectTypeMap;
-        }
         foreach ($this->_parts as $name => $part) {
-            if (empty($part)) {
+            if (!$part) {
                 continue;
             }
             if (is_array($part)) {
                 foreach ($part as $i => $piece) {
-                    if ($piece instanceof ExpressionInterface) {
+                    if (is_array($piece)) {
+                        foreach ($piece as $j => $value) {
+                            if ($value instanceof ExpressionInterface) {
+                                $this->_parts[$name][$i][$j] = clone $value;
+                            }
+                        }
+                    } elseif ($piece instanceof ExpressionInterface) {
                         $this->_parts[$name][$i] = clone $piece;
                     }
                 }
@@ -2103,7 +1853,7 @@ public function __clone()
      *
      * @return string
      */
-    public function __toString()
+    public function __toString(): string
     {
         return $this->sql();
     }
@@ -2112,30 +1862,34 @@ public function __toString()
      * Returns an array that can be used to describe the internal state of this
      * object.
      *
-     * @return array
+     * @return array
      */
-    public function __debugInfo()
+    public function __debugInfo(): array
     {
         try {
-            set_error_handler(function ($errno, $errstr) {
-                throw new RuntimeException($errstr, $errno);
-            }, E_ALL);
+            set_error_handler(
+                /** @return no-return */
+                function ($errno, $errstr): void {
+                    throw new CakeException($errstr, $errno);
+                },
+                E_ALL,
+            );
             $sql = $this->sql();
             $params = $this->getValueBinder()->bindings();
-        } catch (RuntimeException $e) {
+        } catch (Throwable) {
             $sql = 'SQL could not be generated for this query as it is incomplete.';
             $params = [];
         } finally {
             restore_error_handler();
-        }
 
-        return [
-            '(help)' => 'This is a Query object, to get the results execute or iterate it.',
-            'sql' => $sql,
-            'params' => $params,
-            'defaultTypes' => $this->getDefaultTypes(),
-            'decorators' => count($this->_resultDecorators),
-            'executed' => $this->_iterator ? true : false
-        ];
+            return [
+                '(help)' => 'This is a Query object, to get the results execute or iterate it.',
+                'sql' => $sql,
+                'params' => $params,
+                'role' => $this->connectionRole,
+                'defaultTypes' => $this->getDefaultTypes(),
+                'executed' => (bool)$this->_statement,
+            ];
+        }
     }
 }
diff --git a/src/Database/Query/DeleteQuery.php b/src/Database/Query/DeleteQuery.php
new file mode 100644
index 00000000000..bc1a63df6d2
--- /dev/null
+++ b/src/Database/Query/DeleteQuery.php
@@ -0,0 +1,70 @@
+
+     */
+    protected array $_parts = [
+        'comment' => null,
+        'with' => [],
+        'delete' => true,
+        'optimizerHint' => [],
+        'modifier' => [],
+        'from' => [],
+        'join' => [],
+        'where' => null,
+        'order' => null,
+        'limit' => null,
+        'epilog' => null,
+    ];
+
+    /**
+     * Create a delete query.
+     *
+     * Can be combined with from(), where() and other methods to
+     * create delete queries with specific conditions.
+     *
+     * @param string|null $table The table to use when deleting.
+     * @return $this
+     */
+    public function delete(?string $table = null)
+    {
+        $this->_dirty();
+        if ($table !== null) {
+            $this->from($table);
+        }
+
+        return $this;
+    }
+}
diff --git a/src/Database/Query/InsertQuery.php b/src/Database/Query/InsertQuery.php
new file mode 100644
index 00000000000..1e8f035bef6
--- /dev/null
+++ b/src/Database/Query/InsertQuery.php
@@ -0,0 +1,127 @@
+
+     */
+    protected array $_parts = [
+        'comment' => null,
+        'with' => [],
+        'insert' => [],
+        'optimizerHint' => [],
+        'modifier' => [],
+        'values' => [],
+        'epilog' => null,
+    ];
+
+    /**
+     * Create an insert query.
+     *
+     * Note calling this method will reset any data previously set
+     * with Query::values().
+     *
+     * @param array $columns The columns to insert into.
+     * @param array $types A map between columns & their datatypes.
+     * @return $this
+     * @throws \InvalidArgumentException When there are 0 columns.
+     */
+    public function insert(array $columns, array $types = [])
+    {
+        if (!$columns) {
+            throw new InvalidArgumentException('At least 1 column is required to perform an insert.');
+        }
+        $this->_dirty();
+        $this->_parts['insert'][1] = $columns;
+        if (!$this->_parts['values']) {
+            $this->_parts['values'] = new ValuesExpression($columns, $this->getTypeMap()->setTypes($types));
+        } else {
+            /** @var \Cake\Database\Expression\ValuesExpression $valuesExpr */
+            $valuesExpr = $this->_parts['values'];
+            $valuesExpr->setColumns($columns);
+        }
+
+        return $this;
+    }
+
+    /**
+     * Set the table name for insert queries.
+     *
+     * @param string $table The table name to insert into.
+     * @return $this
+     */
+    public function into(string $table)
+    {
+        $this->_dirty();
+        $this->_parts['insert'][0] = $table;
+
+        return $this;
+    }
+
+    /**
+     * Set the values for an insert query.
+     *
+     * Multi inserts can be performed by calling values() more than one time,
+     * or by providing an array of value sets. Additionally $data can be a Query
+     * instance to insert data from another SELECT statement.
+     *
+     * @param \Cake\Database\Expression\ValuesExpression|\Cake\Database\Query|array $data The data to insert.
+     * @return $this
+     * @throws \Cake\Database\Exception\DatabaseException if you try to set values before declaring columns.
+     *   Or if you try to set values on non-insert queries.
+     */
+    public function values(ValuesExpression|Query|array $data)
+    {
+        if (empty($this->_parts['insert'])) {
+            throw new DatabaseException(
+                'You cannot add values before defining columns to use.',
+            );
+        }
+
+        $this->_dirty();
+        if ($data instanceof ValuesExpression) {
+            $this->_parts['values'] = $data;
+
+            return $this;
+        }
+
+        /** @var \Cake\Database\Expression\ValuesExpression $valuesExpr */
+        $valuesExpr = $this->_parts['values'];
+        $valuesExpr->add($data);
+
+        return $this;
+    }
+}
diff --git a/src/Database/Query/QueryFactory.php b/src/Database/Query/QueryFactory.php
new file mode 100644
index 00000000000..64514bec315
--- /dev/null
+++ b/src/Database/Query/QueryFactory.php
@@ -0,0 +1,136 @@
+ $types Associative array containing the types to be used for casting.
+     * @return \Cake\Database\Query\SelectQuery
+     */
+    public function select(
+        ExpressionInterface|Closure|array|string|float|int $fields = [],
+        array|string $table = [],
+        array $types = [],
+    ): SelectQuery {
+        $query = new SelectQuery($this->connection);
+
+        $query
+            ->select($fields)
+            ->from($table)
+            ->setDefaultTypes($types);
+
+        return $query;
+    }
+
+    /**
+     * Create a new InsertQuery instance.
+     *
+     * @param string|null $table The table to insert rows into.
+     * @param array $values Associative array of column => value to be inserted.
+     * @param array $types Associative array containing the types to be used for casting.
+     * @return \Cake\Database\Query\InsertQuery
+     */
+    public function insert(?string $table = null, array $values = [], array $types = []): InsertQuery
+    {
+        $query = new InsertQuery($this->connection);
+
+        if ($table) {
+            $query->into($table);
+        }
+
+        if ($values) {
+            $columns = array_keys($values);
+            $query
+                ->insert($columns, $types)
+                ->values($values);
+        }
+
+        return $query;
+    }
+
+    /**
+     * Create a new UpdateQuery instance.
+     *
+     * @param \Cake\Database\ExpressionInterface|string|null $table The table to update rows of.
+     * @param array $values Values to be updated.
+     * @param array $conditions Conditions to be set for the update statement.
+     * @param array $types Associative array containing the types to be used for casting.
+     * @return \Cake\Database\Query\UpdateQuery
+     */
+    public function update(
+        ExpressionInterface|string|null $table = null,
+        array $values = [],
+        array $conditions = [],
+        array $types = [],
+    ): UpdateQuery {
+        $query = new UpdateQuery($this->connection);
+
+        if ($table) {
+            $query->update($table);
+        }
+        if ($values) {
+            $query->set($values, $types);
+        }
+        if ($conditions) {
+            $query->where($conditions, $types);
+        }
+
+        return $query;
+    }
+
+    /**
+     * Create a new DeleteQuery instance.
+     *
+     * @param string|null $table The table to delete rows from.
+     * @param array $conditions Conditions to be set for the delete statement.
+     * @param array $types Associative array containing the types to be used for casting.
+     * @return \Cake\Database\Query\DeleteQuery
+     */
+    public function delete(?string $table = null, array $conditions = [], array $types = []): DeleteQuery
+    {
+        $query = (new DeleteQuery($this->connection))
+            ->delete($table);
+
+        if ($conditions) {
+            $query->where($conditions, $types);
+        }
+
+        return $query;
+    }
+}
diff --git a/src/Database/Query/SelectQuery.php b/src/Database/Query/SelectQuery.php
new file mode 100644
index 00000000000..8a6e5b03999
--- /dev/null
+++ b/src/Database/Query/SelectQuery.php
@@ -0,0 +1,907 @@
+
+ */
+class SelectQuery extends Query implements IteratorAggregate
+{
+    /**
+     * Type of this query.
+     *
+     * @var string
+     */
+    protected string $_type = self::TYPE_SELECT;
+
+    /**
+     * List of SQL parts that will be used to build this query.
+     *
+     * @var array
+     */
+    protected array $_parts = [
+        'comment' => null,
+        'with' => [],
+        'select' => [],
+        'optimizerHint' => [],
+        'modifier' => [],
+        'distinct' => false,
+        'from' => [],
+        'join' => [],
+        'where' => null,
+        'group' => [],
+        'having' => null,
+        'window' => [],
+        'order' => null,
+        'limit' => null,
+        'offset' => null,
+        'union' => [],
+        'except' => [],
+        'epilog' => null,
+        'intersect' => [],
+    ];
+
+    /**
+     * A list of callbacks to be called to alter each row from resulting
+     * statement upon retrieval. Each one of the callback function will receive
+     * the row array as first argument.
+     *
+     * @var array<\Closure>
+     */
+    protected array $_resultDecorators = [];
+
+    /**
+     * Result set from executed SELECT query.
+     *
+     * @var iterable|null
+     */
+    protected ?iterable $_results = null;
+
+    /**
+     * Boolean for tracking whether buffered results
+     * are enabled.
+     *
+     * @var bool
+     */
+    protected bool $bufferedResults = true;
+
+    /**
+     * The Type map for fields in the select clause
+     *
+     * @var \Cake\Database\TypeMap|null
+     */
+    protected ?TypeMap $_selectTypeMap = null;
+
+    /**
+     * Tracking flag to disable casting
+     *
+     * @var bool
+     */
+    protected bool $typeCastEnabled = true;
+
+    /**
+     * Executes query and returns set of decorated results.
+     *
+     * The results are cached until the query is modified and marked dirty.
+     *
+     * @return iterable
+     * @throws \Cake\Core\Exception\CakeException When query is not a SELECT query.
+     */
+    public function all(): iterable
+    {
+        if ($this->_results === null || $this->_dirty) {
+            $this->_results = $this->execute()->fetchAll(StatementInterface::FETCH_TYPE_ASSOC);
+        }
+
+        return $this->_results;
+    }
+
+    /**
+     * Adds new fields to be returned by a `SELECT` statement when this query is
+     * executed. Fields can be passed as an array of strings, array of expression
+     * objects, a single expression or a single string.
+     *
+     * If an array is passed, keys will be used to alias fields using the value as the
+     * real field to be aliased. It is possible to alias strings, Expression objects or
+     * even other Query objects.
+     *
+     * If a callback is passed, the returning array of the function will
+     * be used as the list of fields.
+     *
+     * By default this function will append any passed argument to the list of fields
+     * to be selected, unless the second argument is set to true.
+     *
+     * ### Examples:
+     *
+     * ```
+     * $query->select(['id', 'title']); // Produces SELECT id, title
+     * $query->select(['author' => 'author_id']); // Appends author: SELECT id, title, author_id as author
+     * $query->select('id', true); // Resets the list: SELECT id
+     * $query->select(['total' => $countQuery]); // SELECT id, (SELECT ...) AS total
+     * $query->select(function ($query) {
+     *     return ['article_id', 'total' => $query->func()->count('*')];
+     * })
+     * ```
+     *
+     * By default no fields are selected, if you have an instance of `Cake\ORM\Query\SelectQuery` and try to
+     * append fields you should also call `Cake\ORM\Query\SelectQuery::enableAutoFields()` to select the
+     * default fields from the table.
+     *
+     * @param \Cake\Database\ExpressionInterface|\Closure|array|string|float|int $fields fields to be added to the list.
+     * @param bool $overwrite whether to reset fields with passed list or not
+     * @return $this
+     */
+    public function select(ExpressionInterface|Closure|array|string|float|int $fields = [], bool $overwrite = false)
+    {
+        if (!is_string($fields) && $fields instanceof Closure) {
+            $fields = $fields($this);
+        }
+
+        if (!is_array($fields)) {
+            $fields = [$fields];
+        }
+
+        if ($overwrite) {
+            $this->_parts['select'] = $fields;
+        } else {
+            $this->_parts['select'] = array_merge($this->_parts['select'], $fields);
+        }
+
+        $this->_dirty();
+
+        return $this;
+    }
+
+    /**
+     * Adds a `DISTINCT` clause to the query to remove duplicates from the result set.
+     * This clause can only be used for select statements.
+     *
+     * If you wish to filter duplicates based of those rows sharing a particular field
+     * or set of fields, you may pass an array of fields to filter on. Beware that
+     * this option might not be fully supported in all database systems.
+     *
+     * ### Examples:
+     *
+     * ```
+     * // Filters products with the same name and city
+     * $query->select(['name', 'city'])->from('products')->distinct();
+     *
+     * // Filters products in the same city
+     * $query->distinct(['city']);
+     * $query->distinct('city');
+     *
+     * // Filter products with the same name
+     * $query->distinct(['name'], true);
+     * $query->distinct('name', true);
+     * ```
+     *
+     * @param \Cake\Database\ExpressionInterface|array|string|bool $on Enable/disable distinct class
+     * or list of fields to be filtered on
+     * @param bool $overwrite whether to reset fields with passed list or not
+     * @return $this
+     */
+    public function distinct(ExpressionInterface|array|string|bool $on = [], bool $overwrite = false)
+    {
+        if ($on === []) {
+            $on = true;
+        } elseif (is_string($on)) {
+            $on = [$on];
+        }
+
+        if (is_array($on)) {
+            $merge = [];
+            if (is_array($this->_parts['distinct'])) {
+                $merge = $this->_parts['distinct'];
+            }
+            $on = $overwrite ? array_values($on) : array_merge($merge, array_values($on));
+        }
+
+        $this->_parts['distinct'] = $on;
+        $this->_dirty();
+
+        return $this;
+    }
+
+    /**
+     * Adds a single or multiple fields to be used in the GROUP BY clause for this query.
+     * Fields can be passed as an array of strings, array of expression
+     * objects, a single expression or a single string.
+     *
+     * By default this function will append any passed argument to the list of fields
+     * to be grouped, unless the second argument is set to true.
+     *
+     * ### Examples:
+     *
+     * ```
+     * // Produces GROUP BY id, title
+     * $query->groupBy(['id', 'title']);
+     *
+     * // Produces GROUP BY title
+     * $query->groupBy('title');
+     * ```
+     *
+     * Group fields are not suitable for use with user supplied data as they are
+     * not sanitized by the query builder.
+     *
+     * @param \Cake\Database\ExpressionInterface|array|string $fields fields to be added to the list
+     * @param bool $overwrite whether to reset fields with passed list or not
+     * @return $this
+     * @deprecated 5.0.0 Use groupBy() instead now that CollectionInterface methods are no longer proxied.
+     */
+    public function group(ExpressionInterface|array|string $fields, bool $overwrite = false)
+    {
+        deprecationWarning('5.0.0', 'SelectQuery::group() is deprecated. Use SelectQuery::groupBy() instead.');
+
+        return $this->groupBy($fields, $overwrite);
+    }
+
+    /**
+     * Adds a single or multiple fields to be used in the GROUP BY clause for this query.
+     * Fields can be passed as an array of strings, array of expression
+     * objects, a single expression or a single string.
+     *
+     * By default this function will append any passed argument to the list of fields
+     * to be grouped, unless the second argument is set to true.
+     *
+     * ### Examples:
+     *
+     * ```
+     * // Produces GROUP BY id, title
+     * $query->groupBy(['id', 'title']);
+     *
+     * // Produces GROUP BY title
+     * $query->groupBy('title');
+     * ```
+     *
+     * Group fields are not suitable for use with user supplied data as they are
+     * not sanitized by the query builder.
+     *
+     * @param \Cake\Database\ExpressionInterface|array|string $fields fields to be added to the list
+     * @param bool $overwrite whether to reset fields with passed list or not
+     * @return $this
+     */
+    public function groupBy(ExpressionInterface|array|string $fields, bool $overwrite = false)
+    {
+        if ($overwrite) {
+            $this->_parts['group'] = [];
+        }
+
+        if (!is_array($fields)) {
+            $fields = [$fields];
+        }
+
+        $this->_parts['group'] = array_merge($this->_parts['group'], array_values($fields));
+        $this->_dirty();
+
+        return $this;
+    }
+
+    /**
+     * Adds a condition or set of conditions to be used in the `HAVING` clause for this
+     * query. This method operates in exactly the same way as the method `where()`
+     * does. Please refer to its documentation for an insight on how to using each
+     * parameter.
+     *
+     * Having fields are not suitable for use with user supplied data as they are
+     * not sanitized by the query builder.
+     *
+     * @param \Cake\Database\ExpressionInterface|\Closure|array|string|null $conditions The having conditions.
+     * @param array $types Associative array of type names used to bind values to query
+     * @param bool $overwrite whether to reset conditions with passed list or not
+     * @see \Cake\Database\Query::where()
+     * @return $this
+     */
+    public function having(
+        ExpressionInterface|Closure|array|string|null $conditions = null,
+        array $types = [],
+        bool $overwrite = false,
+    ) {
+        if ($overwrite) {
+            $this->_parts['having'] = $this->expr();
+        }
+        $this->_conjugate('having', $conditions, 'AND', $types);
+
+        return $this;
+    }
+
+    /**
+     * Connects any previously defined set of conditions to the provided list
+     * using the AND operator in the HAVING clause. This method operates in exactly
+     * the same way as the method `andWhere()` does. Please refer to its
+     * documentation for an insight on how to using each parameter.
+     *
+     * Having fields are not suitable for use with user supplied data as they are
+     * not sanitized by the query builder.
+     *
+     * @param \Cake\Database\ExpressionInterface|\Closure|array|string $conditions The AND conditions for HAVING.
+     * @param array $types Associative array of type names used to bind values to query
+     * @see \Cake\Database\Query::andWhere()
+     * @return $this
+     */
+    public function andHaving(ExpressionInterface|Closure|array|string $conditions, array $types = [])
+    {
+        $this->_conjugate('having', $conditions, 'AND', $types);
+
+        return $this;
+    }
+
+    /**
+     * Adds a named window expression.
+     *
+     * You are responsible for adding windows in the order your database requires.
+     *
+     * @param string $name Window name
+     * @param \Cake\Database\Expression\WindowExpression|\Closure $window Window expression
+     * @param bool $overwrite Clear all previous query window expressions
+     * @return $this
+     */
+    public function window(string $name, WindowExpression|Closure $window, bool $overwrite = false)
+    {
+        if ($overwrite) {
+            $this->_parts['window'] = [];
+        }
+
+        if ($window instanceof Closure) {
+            $window = $window(new WindowExpression(), $this);
+            if (!($window instanceof WindowExpression)) {
+                throw new CakeException('You must return a `WindowExpression` from a Closure passed to `window()`.');
+            }
+        }
+
+        $this->_parts['window'][] = ['name' => new IdentifierExpression($name), 'window' => $window];
+        $this->_dirty();
+
+        return $this;
+    }
+
+    /**
+     * Set the page of results you want.
+     *
+     * This method provides an easier to use interface to set the limit + offset
+     * in the record set you want as results. If empty the limit will default to
+     * the existing limit clause, and if that too is empty, then `25` will be used.
+     *
+     * Pages must start at 1.
+     *
+     * @param int $num The page number you want.
+     * @param int|null $limit The number of rows you want in the page. If null
+     *  the current limit clause will be used.
+     * @return $this
+     * @throws \InvalidArgumentException If page number < 1.
+     */
+    public function page(int $num, ?int $limit = null)
+    {
+        if ($num < 1) {
+            throw new InvalidArgumentException('Pages must start at 1.');
+        }
+        if ($limit !== null) {
+            $this->limit($limit);
+        }
+        $limit = $this->clause('limit');
+        if ($limit === null) {
+            $limit = 25;
+            $this->limit($limit);
+        }
+        $offset = ($num - 1) * $limit;
+        if (PHP_INT_MAX <= $offset) {
+            $offset = PHP_INT_MAX;
+        }
+        $this->offset((int)$offset);
+
+        return $this;
+    }
+
+    /**
+     * Adds a complete query to be used in conjunction with an UNION operator with
+     * this query. This is used to combine the result set of this query with the one
+     * that will be returned by the passed query. You can add as many queries as you
+     * required by calling multiple times this method with different queries.
+     *
+     * By default, the UNION operator will remove duplicate rows, if you wish to include
+     * every row for all queries, use unionAll().
+     *
+     * ### Examples
+     *
+     * ```
+     * $union = (new SelectQuery($conn))->select(['id', 'title'])->from(['a' => 'articles']);
+     * $query->select(['id', 'name'])->from(['d' => 'things'])->union($union);
+     * ```
+     *
+     * Will produce:
+     *
+     * `SELECT id, name FROM things d UNION SELECT id, title FROM articles a`
+     *
+     * @param \Cake\Database\Query|string $query full SQL query to be used in UNION operator
+     * @param bool $overwrite whether to reset the list of queries to be operated or not
+     * @return $this
+     */
+    public function union(Query|string $query, bool $overwrite = false)
+    {
+        if ($overwrite) {
+            $this->_parts['union'] = [];
+        }
+        $this->_parts['union'][] = [
+            'all' => false,
+            'query' => $query,
+        ];
+        $this->_dirty();
+
+        return $this;
+    }
+
+    /**
+     * Adds a complete query to be used in conjunction with the UNION ALL operator with
+     * this query. This is used to combine the result set of this query with the one
+     * that will be returned by the passed query. You can add as many queries as you
+     * required by calling multiple times this method with different queries.
+     *
+     * Unlike UNION, UNION ALL will not remove duplicate rows.
+     *
+     * ```
+     * $union = (new SelectQuery($conn))->select(['id', 'title'])->from(['a' => 'articles']);
+     * $query->select(['id', 'name'])->from(['d' => 'things'])->unionAll($union);
+     * ```
+     *
+     * Will produce:
+     *
+     * `SELECT id, name FROM things d UNION ALL SELECT id, title FROM articles a`
+     *
+     * @param \Cake\Database\Query|string $query full SQL query to be used in UNION operator
+     * @param bool $overwrite whether to reset the list of queries to be operated or not
+     * @return $this
+     */
+    public function unionAll(Query|string $query, bool $overwrite = false)
+    {
+        if ($overwrite) {
+            $this->_parts['union'] = [];
+        }
+        $this->_parts['union'][] = [
+            'all' => true,
+            'query' => $query,
+        ];
+        $this->_dirty();
+
+        return $this;
+    }
+
+    /**
+     * Adds a complete query to be used in conjunction with an INTERSECT operator with
+     * this query. This is used to combine the result set of this query with the one
+     * that will be returned by the passed query. You can add as many queries as you
+     * required by calling multiple times this method with different queries.
+     *
+     * By default, the INTERSECT operator will remove duplicate rows, if you wish to include
+     * every row for all queries, use intersectAll().
+     *
+     * ### Examples
+     *
+     * ```
+     * $intersect = (new SelectQuery($conn))->select(['id', 'title'])->from(['a' => 'articles']);
+     * $query->select(['id', 'name'])->from(['d' => 'things'])->intersect($intersect);
+     * ```
+     *
+     * Will produce:
+     *
+     * `SELECT id, name FROM things d INTERSECT SELECT id, title FROM articles a`
+     *
+     * @param \Cake\Database\Query|string $query full SQL query to be used in INTERSECT operator
+     * @param bool $overwrite whether to reset the list of queries to be operated or not
+     * @return $this
+     */
+    public function intersect(Query|string $query, bool $overwrite = false)
+    {
+        if ($overwrite) {
+            $this->_parts['intersect'] = [];
+        }
+        $this->_parts['intersect'][] = [
+            'all' => false,
+            'query' => $query,
+        ];
+        $this->_dirty();
+
+        return $this;
+    }
+
+    /**
+     * Adds a complete query to be used in conjunction with the INTERSECT ALL operator with
+     * this query. This is used to combine the result set of this query with the one
+     * that will be returned by the passed query. You can add as many queries as you
+     * required by calling multiple times this method with different queries.
+     *
+     * Unlike INTERSECT, INTERSECT ALL will not remove duplicate rows.
+     *
+     * ```
+     * $intersect = (new SelectQuery($conn))->select(['id', 'title'])->from(['a' => 'articles']);
+     * $query->select(['id', 'name'])->from(['d' => 'things'])->intersectAll($intersect);
+     * ```
+     *
+     * Will produce:
+     *
+     * `SELECT id, name FROM things d INTERSECT ALL SELECT id, title FROM articles a`
+     *
+     * @param \Cake\Database\Query|string $query full SQL query to be used in INTERSECT operator
+     * @param bool $overwrite whether to reset the list of queries to be operated or not
+     * @return $this
+     */
+    public function intersectAll(Query|string $query, bool $overwrite = false)
+    {
+        if ($overwrite) {
+            $this->_parts['intersect'] = [];
+        }
+        $this->_parts['intersect'][] = [
+            'all' => true,
+            'query' => $query,
+        ];
+        $this->_dirty();
+
+        return $this;
+    }
+
+    /**
+     * Adds a complete query to be used in conjunction with an EXCEPT operator with
+     * this query. This is used to subtract the passed query from the result set of this query.
+     * You can add as many queries as you required by calling multiple times
+     * this method with different queries.
+     *
+     * By default, the EXCEPT operator will remove duplicate rows, if you wish to include
+     * every row for all queries, use exceptAll().
+     *
+     * ### Examples
+     *
+     * ```
+     * $except = (new SelectQuery($conn))->select(['id', 'title'])->from(['a' => 'articles']);
+     * $query->select(['id', 'name'])->from(['d' => 'things'])->except($except);
+     * ```
+     *
+     * Will produce:
+     *
+     * `SELECT id, name FROM things d EXCEPT SELECT id, title FROM articles a`
+     *
+     * @param \Cake\Database\Query|string $query full SQL query to be used in EXCEPT operator
+     * @param bool $overwrite whether to reset the list of queries to be operated or not
+     * @return $this
+     */
+    public function except(Query|string $query, bool $overwrite = false)
+    {
+        if ($overwrite) {
+            $this->_parts['except'] = [];
+        }
+        $this->_parts['except'][] = [
+            'all' => false,
+            'query' => $query,
+        ];
+        $this->_dirty();
+
+        return $this;
+    }
+
+    /**
+     * Adds a complete query to be used in conjunction with the EXCEPT ALL operator with
+     * this query. This is used to subtract the passed query from the result set of this query.
+     * You can add as many queries as you required by calling multiple times
+     * this method with different queries.
+     *
+     * Unlike EXCEPT, EXCEPT ALL will not remove duplicate rows.
+     *
+     * ```
+     * $except = (new SelectQuery($conn))->select(['id', 'title'])->from(['a' => 'articles']);
+     * $query->select(['id', 'name'])->from(['d' => 'things'])->exceptAll($except);
+     * ```
+     *
+     * Will produce:
+     *
+     * `SELECT id, name FROM things d EXCEPT ALL SELECT id, title FROM articles a`
+     *
+     * @param \Cake\Database\Query|string $query full SQL query to be used in EXCEPT operator
+     * @param bool $overwrite whether to reset the list of queries to be operated or not
+     * @return $this
+     */
+    public function exceptAll(Query|string $query, bool $overwrite = false)
+    {
+        if ($overwrite) {
+            $this->_parts['except'] = [];
+        }
+        $this->_parts['except'][] = [
+            'all' => true,
+            'query' => $query,
+        ];
+        $this->_dirty();
+
+        return $this;
+    }
+
+    /**
+     * Executes this query and returns a results iterator. This function is required
+     * for implementing the IteratorAggregate interface and allows the query to be
+     * iterated without having to call all() manually, thus making it look like
+     * a result set instead of the query itself.
+     *
+     * @return \Traversable
+     */
+    public function getIterator(): Traversable
+    {
+        if ($this->bufferedResults) {
+            /** @var \Traversable|array $results */
+            $results = $this->all();
+            if (is_array($results)) {
+                return new ArrayIterator($results);
+            }
+
+            return $results;
+        }
+
+        return $this->execute();
+    }
+
+    /**
+     * Registers a callback to be executed for each result that is fetched from the
+     * result set, the callback function will receive as first parameter an array with
+     * the raw data from the database for every row that is fetched and must return the
+     * row with any possible modifications.
+     *
+     * Callbacks will be executed lazily, if only 3 rows are fetched for database it will
+     * be called 3 times, event though there might be more rows to be fetched in the cursor.
+     *
+     * Callbacks are stacked in the order they are registered, if you wish to reset the stack
+     * the call this function with the second parameter set to true.
+     *
+     * If you wish to remove all decorators from the stack, set the first parameter
+     * to null and the second to true.
+     *
+     * ### Example
+     *
+     * ```
+     * $query->decorateResults(function ($row) {
+     *   $row['order_total'] = $row['subtotal'] + ($row['subtotal'] * $row['tax']);
+     *    return $row;
+     * });
+     * ```
+     *
+     * @param \Closure|null $callback The callback to invoke when results are fetched.
+     * @param bool $overwrite Whether this should append or replace all existing decorators.
+     * @return $this
+     */
+    public function decorateResults(?Closure $callback, bool $overwrite = false)
+    {
+        $this->_dirty();
+        if ($overwrite) {
+            $this->_resultDecorators = [];
+        }
+
+        if ($callback !== null) {
+            $this->_resultDecorators[] = $callback;
+        }
+
+        return $this;
+    }
+
+    /**
+     * Get result decorators.
+     *
+     * @return array
+     */
+    public function getResultDecorators(): array
+    {
+        return $this->_resultDecorators;
+    }
+
+    /**
+     * Enables buffered results.
+     *
+     * When enabled the results returned by this query will be
+     * buffered. This enables you to iterate a result set multiple times, or
+     * both cache and iterate it.
+     *
+     * When disabled it will consume less memory as fetched results are not
+     * remembered for future iterations.
+     *
+     * @return $this
+     */
+    public function enableBufferedResults()
+    {
+        $this->_dirty();
+        $this->bufferedResults = true;
+
+        return $this;
+    }
+
+    /**
+     * Disables buffered results.
+     *
+     * Disabling buffering will consume less memory as fetched results are not
+     * remembered for future iterations.
+     *
+     * @return $this
+     */
+    public function disableBufferedResults()
+    {
+        $this->_dirty();
+        $this->bufferedResults = false;
+
+        return $this;
+    }
+
+    /**
+     * Returns whether buffered results are enabled/disabled.
+     *
+     * When enabled the results returned by this query will be
+     * buffered. This enables you to iterate a result set multiple times, or
+     * both cache and iterate it.
+     *
+     * When disabled it will consume less memory as fetched results are not
+     * remembered for future iterations.
+     *
+     * @return bool
+     */
+    public function isBufferedResultsEnabled(): bool
+    {
+        return $this->bufferedResults;
+    }
+
+    /**
+     * Sets the TypeMap class where the types for each of the fields in the
+     * select clause are stored.
+     *
+     * @param \Cake\Database\TypeMap|array $typeMap Creates a TypeMap if array, otherwise sets the given TypeMap.
+     * @return $this
+     */
+    public function setSelectTypeMap(TypeMap|array $typeMap)
+    {
+        $this->_selectTypeMap = is_array($typeMap) ? new TypeMap($typeMap) : $typeMap;
+        $this->_dirty();
+
+        return $this;
+    }
+
+    /**
+     * Gets the TypeMap class where the types for each of the fields in the
+     * select clause are stored.
+     *
+     * @return \Cake\Database\TypeMap
+     */
+    public function getSelectTypeMap(): TypeMap
+    {
+        return $this->_selectTypeMap ??= new TypeMap();
+    }
+
+    /**
+     * Disables result casting.
+     *
+     * When disabled, the fields will be returned as received from the database
+     * driver (which in most environments means they are being returned as
+     * strings), which can improve performance with larger datasets.
+     *
+     * @return $this
+     */
+    public function disableResultsCasting()
+    {
+        $this->typeCastEnabled = false;
+
+        return $this;
+    }
+
+    /**
+     * Enables result casting.
+     *
+     * When enabled, the fields in the results returned by this Query will be
+     * cast to their corresponding PHP data type.
+     *
+     * @return $this
+     */
+    public function enableResultsCasting()
+    {
+        $this->typeCastEnabled = true;
+
+        return $this;
+    }
+
+    /**
+     * Returns whether result casting is enabled/disabled.
+     *
+     * When enabled, the fields in the results returned by this Query will be
+     * casted to their corresponding PHP data type.
+     *
+     * When disabled, the fields will be returned as received from the database
+     * driver (which in most environments means they are being returned as
+     * strings), which can improve performance with larger datasets.
+     *
+     * @return bool
+     */
+    public function isResultsCastingEnabled(): bool
+    {
+        return $this->typeCastEnabled;
+    }
+
+    /**
+     * Handles clearing iterator and cloning all expressions and value binders.
+     */
+    public function __clone()
+    {
+        parent::__clone();
+
+        $this->_results = null;
+        if ($this->_selectTypeMap !== null) {
+            $this->_selectTypeMap = clone $this->_selectTypeMap;
+        }
+    }
+
+    /**
+     * Returns an array that can be used to describe the internal state of this
+     * object.
+     *
+     * @return array
+     */
+    public function __debugInfo(): array
+    {
+        $return = parent::__debugInfo();
+        $return['decorators'] = count($this->_resultDecorators);
+
+        return $return;
+    }
+
+    /**
+     * Sets the connection role.
+     *
+     * @param string $role Connection role ('read' or 'write')
+     * @return $this
+     */
+    public function setConnectionRole(string $role)
+    {
+        assert($role === Connection::ROLE_READ || $role === Connection::ROLE_WRITE);
+        $this->connectionRole = $role;
+
+        return $this;
+    }
+
+    /**
+     * Sets the connection role to read.
+     *
+     * @return $this
+     */
+    public function useReadRole()
+    {
+        return $this->setConnectionRole(Connection::ROLE_READ);
+    }
+
+    /**
+     * Sets the connection role to write.
+     *
+     * @return $this
+     */
+    public function useWriteRole()
+    {
+        return $this->setConnectionRole(Connection::ROLE_WRITE);
+    }
+}
diff --git a/src/Database/Query/UpdateQuery.php b/src/Database/Query/UpdateQuery.php
new file mode 100644
index 00000000000..af5dccc8faf
--- /dev/null
+++ b/src/Database/Query/UpdateQuery.php
@@ -0,0 +1,150 @@
+
+     */
+    protected array $_parts = [
+        'comment' => null,
+        'with' => [],
+        'update' => [],
+        'optimizerHint' => [],
+        'modifier' => [],
+        'join' => [],
+        'set' => [],
+        'where' => null,
+        'order' => null,
+        'limit' => null,
+        'epilog' => null,
+    ];
+
+    /**
+     * Create an update query.
+     *
+     * Can be combined with set() and where() methods to create update queries.
+     *
+     * @param \Cake\Database\ExpressionInterface|string $table The table you want to update.
+     * @return $this
+     */
+    public function update(ExpressionInterface|string $table)
+    {
+        $this->_dirty();
+        $this->_parts['update'][0] = $table;
+
+        return $this;
+    }
+
+    /**
+     * Set one or many fields to update.
+     *
+     * ### Examples
+     *
+     * Passing a string:
+     *
+     * ```
+     * $query->update('articles')->set('title', 'The Title');
+     * ```
+     *
+     * Passing an array:
+     *
+     * ```
+     * $query->update('articles')->set(['title' => 'The Title'], ['title' => 'string']);
+     * ```
+     *
+     * Passing a callback:
+     *
+     * ```
+     * $query->update('articles')->set(function (ExpressionInterface $exp) {
+     *   return $exp->eq('title', 'The title', 'string');
+     * });
+     * ```
+     *
+     * @param \Cake\Database\Expression\QueryExpression|\Closure|array|string $key The column name or array of keys
+     *    + values to set. This can also be a QueryExpression containing a SQL fragment.
+     *    It can also be a Closure, that is required to return an expression object.
+     * @param mixed $value The value to update $key to. Can be null if $key is an
+     *    array or QueryExpression. When $key is an array, this parameter will be
+     *    used as $types instead.
+     * @param array|string $types The column types to treat data as.
+     * @return $this
+     */
+    public function set(QueryExpression|Closure|array|string $key, mixed $value = null, array|string $types = [])
+    {
+        if (empty($this->_parts['set'])) {
+            $this->_parts['set'] = $this->expr()->setConjunction(',');
+        }
+
+        if ($key instanceof Closure) {
+            $exp = $this->expr()->setConjunction(',');
+            /** @var \Cake\Database\Expression\QueryExpression $setExpr */
+            $setExpr = $this->_parts['set'];
+            $setExpr->add($key($exp));
+
+            return $this;
+        }
+
+        if (is_array($key) && !isset($key[0])) {
+            $typeMap = $this->getTypeMap()->setTypes($value ?? []);
+            /** @var \Cake\Database\Expression\QueryExpression $setExpr */
+            $setExpr = $this->_parts['set'];
+            foreach ($key as $k => $v) {
+                $setExpr->add(new ComparisonExpression($k, $v, $typeMap->type($k)));
+            }
+
+            return $this;
+        }
+
+        if (is_array($key) || $key instanceof ExpressionInterface) {
+            $types = (array)$value;
+            /** @var \Cake\Database\Expression\QueryExpression $setExpr */
+            $setExpr = $this->_parts['set'];
+            $setExpr->add($key, $types);
+
+            return $this;
+        }
+
+        if (!is_string($types)) {
+            $types = null;
+        }
+        /** @var \Cake\Database\Expression\QueryExpression $setExpr */
+        $setExpr = $this->_parts['set'];
+        $setExpr->eq($key, $value, $types);
+
+        return $this;
+    }
+}
diff --git a/src/Database/QueryCompiler.php b/src/Database/QueryCompiler.php
index 79f5c6791c8..3be56131535 100644
--- a/src/Database/QueryCompiler.php
+++ b/src/Database/QueryCompiler.php
@@ -1,4 +1,6 @@
 
      */
-    protected $_templates = [
+    protected array $_templates = [
         'delete' => 'DELETE',
         'where' => ' WHERE %s',
-        'group' => ' GROUP BY %s ',
-        'having' => ' HAVING %s ',
+        'group' => ' GROUP BY %s',
+        'having' => ' HAVING %s',
         'order' => ' %s',
         'limit' => ' LIMIT %s',
         'offset' => ' OFFSET %s',
-        'epilog' => ' %s'
+        'epilog' => ' %s',
+        'comment' => '/* %s */ ',
     ];
 
     /**
      * The list of query clauses to traverse for generating a SELECT statement
      *
-     * @var array
+     * @var array
      */
-    protected $_selectParts = [
-        'select', 'from', 'join', 'where', 'group', 'having', 'order', 'limit',
-        'offset', 'union', 'epilog'
+    protected array $_selectParts = [
+        'comment', 'with', 'select', 'from', 'join', 'where', 'group', 'having', 'window', 'order',
+        'limit', 'offset', 'union', 'except', 'epilog', 'intersect',
     ];
 
     /**
      * The list of query clauses to traverse for generating an UPDATE statement
      *
-     * @var array
+     * @var array
      */
-    protected $_updateParts = ['update', 'set', 'where', 'epilog'];
+    protected array $_updateParts = ['comment', 'with', 'update', 'set', 'where', 'epilog'];
 
     /**
      * The list of query clauses to traverse for generating a DELETE statement
      *
-     * @var array
+     * @var array
      */
-    protected $_deleteParts = ['delete', 'modifier', 'from', 'where', 'epilog'];
+    protected array $_deleteParts = ['comment', 'with', 'delete', 'optimizerHint', 'modifier', 'from', 'where',
+        'epilog'];
 
     /**
      * The list of query clauses to traverse for generating an INSERT statement
      *
-     * @var array
+     * @var array
      */
-    protected $_insertParts = ['insert', 'values', 'epilog'];
+    protected array $_insertParts = ['comment', 'with', 'insert', 'values', 'epilog'];
 
     /**
-     * Indicate whether or not this query dialect supports ordered unions.
-     *
-     * Overridden in subclasses.
+     * Indicate whether aliases in SELECT clause need to be always quoted.
      *
      * @var bool
      */
-    protected $_orderedUnion = true;
+    protected bool $_quotedSelectAliases = false;
 
     /**
      * Returns the SQL representation of the provided query after generating
      * the placeholders for the bound values using the provided generator
      *
      * @param \Cake\Database\Query $query The query that is being compiled
-     * @param \Cake\Database\ValueBinder $generator the placeholder generator to be used in expressions
-     * @return \Closure
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholders
+     * @return string
      */
-    public function compile(Query $query, ValueBinder $generator)
+    public function compile(Query $query, ValueBinder $binder): string
     {
         $sql = '';
         $type = $query->type();
-        $query->traverse(
-            $this->_sqlCompiler($sql, $query, $generator),
-            $this->{'_' . $type . 'Parts'}
+        $query->traverseParts(
+            $this->_sqlCompiler($sql, $query, $binder),
+            $this->{"_{$type}Parts"},
         );
 
         // Propagate bound parameters from sub-queries if the
-        // placeholders can be found in the SQL statement.
-        if ($query->getValueBinder() !== $generator) {
+        // placeholders can be found in the SQL statement. Only
+        // add new placeholders, as sub-queries may have been executed already.
+        if ($query->getValueBinder() !== $binder) {
+            $existing = $binder->bindings();
             foreach ($query->getValueBinder()->bindings() as $binding) {
                 $placeholder = ':' . $binding['placeholder'];
-                if (preg_match('/' . $placeholder . '(?:\W|$)/', $sql) > 0) {
-                    $generator->bind($placeholder, $binding['value'], $binding['type']);
+                if (!isset($existing[$placeholder]) && preg_match('/' . $placeholder . '(?:\W|$)/', $sql) > 0) {
+                    $binder->bind($placeholder, $binding['value'], $binding['type']);
                 }
             }
         }
@@ -114,35 +119,62 @@ public function compile(Query $query, ValueBinder $generator)
     }
 
     /**
-     * Returns a callable object that can be used to compile a SQL string representation
+     * Returns a closure that can be used to compile a SQL string representation
      * of this query.
      *
      * @param string $sql initial sql string to append to
      * @param \Cake\Database\Query $query The query that is being compiled
-     * @param \Cake\Database\ValueBinder $generator The placeholder and value binder object
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
      * @return \Closure
      */
-    protected function _sqlCompiler(&$sql, $query, $generator)
+    protected function _sqlCompiler(string &$sql, Query $query, ValueBinder $binder): Closure
     {
-        return function ($parts, $name) use (&$sql, $query, $generator) {
-            if (!isset($parts) ||
-                ((is_array($parts) || $parts instanceof \Countable) && !count($parts))
+        return function ($part, $partName) use (&$sql, $query, $binder): void {
+            if (
+                $part === null ||
+                ($part === []) ||
+                ($part instanceof Countable && count($part) === 0)
             ) {
                 return;
             }
-            if ($parts instanceof ExpressionInterface) {
-                $parts = [$parts->sql($generator)];
-            }
-            if (isset($this->_templates[$name])) {
-                $parts = $this->_stringifyExpressions((array)$parts, $generator);
 
-                return $sql .= sprintf($this->_templates[$name], implode(', ', $parts));
+            if ($part instanceof ExpressionInterface) {
+                $part = [$part->sql($binder)];
             }
+            if (isset($this->_templates[$partName])) {
+                $part = $this->_stringifyExpressions((array)$part, $binder);
+                $sql .= sprintf($this->_templates[$partName], implode(', ', $part));
 
-            return $sql .= $this->{'_build' . ucfirst($name) . 'Part'}($parts, $query, $generator);
+                return;
+            }
+            $sql .= $this->{'_build' . $partName . 'Part'}($part, $query, $binder);
         };
     }
 
+    /**
+     * Helper function used to build the string representation of a `WITH` clause,
+     * it constructs the CTE definitions list and generates the `RECURSIVE`
+     * keyword when required.
+     *
+     * @param array<\Cake\Database\Expression\CommonTableExpression> $parts List of CTEs to be transformed to string
+     * @param \Cake\Database\Query $query The query that is being compiled
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
+     * @return string
+     */
+    protected function _buildWithPart(array $parts, Query $query, ValueBinder $binder): string
+    {
+        $recursive = false;
+        $expressions = [];
+        foreach ($parts as $cte) {
+            $recursive = $recursive || $cte->isRecursive();
+            $expressions[] = $cte->sql($binder);
+        }
+
+        $recursive = $recursive ? 'RECURSIVE ' : '';
+
+        return sprintf('WITH %s%s ', $recursive, implode(', ', $expressions));
+    }
+
     /**
      * Helper function used to build the string representation of a SELECT clause,
      * it constructs the field list taking care of aliasing and
@@ -151,38 +183,47 @@ protected function _sqlCompiler(&$sql, $query, $generator)
      *
      * @param array $parts list of fields to be transformed to string
      * @param \Cake\Database\Query $query The query that is being compiled
-     * @param \Cake\Database\ValueBinder $generator the placeholder generator to be used in expressions
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
      * @return string
      */
-    protected function _buildSelectPart($parts, $query, $generator)
+    protected function _buildSelectPart(array $parts, Query $query, ValueBinder $binder): string
     {
-        $driver = $query->getConnection()->getDriver();
-        $select = 'SELECT%s %s%s';
-        if ($this->_orderedUnion && $query->clause('union')) {
-            $select = '(SELECT%s %s%s';
+        $driver = $query->getDriver();
+        $select = 'SELECT%s%s %s%s';
+        if (
+            ($query->clause('union') || $query->clause('except') || $query->clause('intersect')) &&
+            $driver->supports(DriverFeatureEnum::SET_OPERATIONS_ORDER_BY)
+        ) {
+            $select = '(SELECT%s%s %s%s';
         }
-        $distinct = $query->clause('distinct');
-        $modifiers = $this->_buildModifierPart($query->clause('modifier'), $query, $generator);
 
+        $hint = $this->_buildOptimizerHintPart($query->clause('optimizerHint'), $query, $binder);
+        $modifiers = $this->_buildModifierPart($query->clause('modifier'), $query, $binder);
+
+        $quoteIdentifiers = $driver->isAutoQuotingEnabled() || $this->_quotedSelectAliases;
         $normalized = [];
-        $parts = $this->_stringifyExpressions($parts, $generator);
+        $parts = $this->_stringifyExpressions($parts, $binder);
         foreach ($parts as $k => $p) {
             if (!is_numeric($k)) {
-                $p = $p . ' AS ' . $driver->quoteIdentifier($k);
+                $p .= ' AS ';
+                if ($quoteIdentifiers) {
+                    $p .= $driver->quoteIdentifier($k);
+                } else {
+                    $p .= $k;
+                }
             }
             $normalized[] = $p;
         }
 
+        $distinct = $query->clause('distinct');
         if ($distinct === true) {
             $distinct = 'DISTINCT ';
-        }
-
-        if (is_array($distinct)) {
-            $distinct = $this->_stringifyExpressions($distinct, $generator);
+        } elseif (is_array($distinct)) {
+            $distinct = $this->_stringifyExpressions($distinct, $binder);
             $distinct = sprintf('DISTINCT ON (%s) ', implode(', ', $distinct));
         }
 
-        return sprintf($select, $modifiers, $distinct, implode(', ', $normalized));
+        return sprintf($select, $hint, $modifiers, $distinct, implode(', ', $normalized));
     }
 
     /**
@@ -192,14 +233,14 @@ protected function _buildSelectPart($parts, $query, $generator)
      *
      * @param array $parts list of tables to be transformed to string
      * @param \Cake\Database\Query $query The query that is being compiled
-     * @param \Cake\Database\ValueBinder $generator the placeholder generator to be used in expressions
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
      * @return string
      */
-    protected function _buildFromPart($parts, $query, $generator)
+    protected function _buildFromPart(array $parts, Query $query, ValueBinder $binder): string
     {
         $select = ' FROM %s';
         $normalized = [];
-        $parts = $this->_stringifyExpressions($parts, $generator);
+        $parts = $this->_stringifyExpressions($parts, $binder);
         foreach ($parts as $k => $p) {
             if (!is_numeric($k)) {
                 $p = $p . ' ' . $k;
@@ -218,54 +259,78 @@ protected function _buildFromPart($parts, $query, $generator)
      *
      * @param array $parts list of joins to be transformed to string
      * @param \Cake\Database\Query $query The query that is being compiled
-     * @param \Cake\Database\ValueBinder $generator the placeholder generator to be used in expressions
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
      * @return string
      */
-    protected function _buildJoinPart($parts, $query, $generator)
+    protected function _buildJoinPart(array $parts, Query $query, ValueBinder $binder): string
     {
         $joins = '';
         foreach ($parts as $join) {
-            $subquery = $join['table'] instanceof Query || $join['table'] instanceof QueryExpression;
-            if ($join['table'] instanceof ExpressionInterface) {
-                $join['table'] = $join['table']->sql($generator);
+            if (!isset($join['table'])) {
+                throw new DatabaseException(sprintf(
+                    'Could not compile join clause for alias `%s`. No table was specified. ' .
+                    'Use the `table` key to define a table.',
+                    $join['alias'],
+                ));
             }
-
-            if ($subquery) {
-                $join['table'] = '(' . $join['table'] . ')';
+            if ($join['table'] instanceof ExpressionInterface) {
+                $join['table'] = '(' . $join['table']->sql($binder) . ')';
             }
 
             $joins .= sprintf(' %s JOIN %s %s', $join['type'], $join['table'], $join['alias']);
 
             $condition = '';
             if (isset($join['conditions']) && $join['conditions'] instanceof ExpressionInterface) {
-                $condition = $join['conditions']->sql($generator);
+                $condition = $join['conditions']->sql($binder);
             }
-            if (strlen($condition)) {
-                $joins .= " ON {$condition}";
-            } else {
+            if ($condition === '') {
                 $joins .= ' ON 1 = 1';
+            } else {
+                $joins .= " ON {$condition}";
             }
         }
 
         return $joins;
     }
 
+    /**
+     * Helper function to build the string representation of a window clause.
+     *
+     * @param array $parts List of windows to be transformed to string
+     * @param \Cake\Database\Query $query The query that is being compiled
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
+     * @return string
+     */
+    protected function _buildWindowPart(array $parts, Query $query, ValueBinder $binder): string
+    {
+        $windows = [];
+        foreach ($parts as $window) {
+            /** @var \Cake\Database\Expression\IdentifierExpression $expr */
+            $expr = $window['name'];
+            /** @var \Cake\Database\Expression\IdentifierExpression $windowExpr */
+            $windowExpr = $window['window'];
+            $windows[] = $expr->sql($binder) . ' AS (' . $windowExpr->sql($binder) . ')';
+        }
+
+        return ' WINDOW ' . implode(', ', $windows);
+    }
+
     /**
      * Helper function to generate SQL for SET expressions.
      *
-     * @param array $parts List of keys & values to set.
+     * @param array $parts List of keys and values to set.
      * @param \Cake\Database\Query $query The query that is being compiled
-     * @param \Cake\Database\ValueBinder $generator the placeholder generator to be used in expressions
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
      * @return string
      */
-    protected function _buildSetPart($parts, $query, $generator)
+    protected function _buildSetPart(array $parts, Query $query, ValueBinder $binder): string
     {
         $set = [];
         foreach ($parts as $part) {
             if ($part instanceof ExpressionInterface) {
-                $part = $part->sql($generator);
+                $part = $part->sql($binder);
             }
-            if ($part[0] === '(') {
+            if (str_starts_with($part, '(')) {
                 $part = substr($part, 1, -1);
             }
             $set[] = $part;
@@ -275,33 +340,90 @@ protected function _buildSetPart($parts, $query, $generator)
     }
 
     /**
-     * Builds the SQL string for all the UNION clauses in this query, when dealing
+     * Builds the SQL string for all the `operation` clauses in this query, when dealing
      * with query objects it will also transform them using their configured SQL
      * dialect.
      *
-     * @param array $parts list of queries to be operated with UNION
-     * @param \Cake\Database\Query $query The query that is being compiled
-     * @param \Cake\Database\ValueBinder $generator the placeholder generator to be used in expressions
+     * @param string $operation
+     * @param array $parts
+     * @param \Cake\Database\Query $query
+     * @param \Cake\Database\ValueBinder $binder
      * @return string
      */
-    protected function _buildUnionPart($parts, $query, $generator)
-    {
-        $parts = array_map(function ($p) use ($generator) {
-            $p['query'] = $p['query']->sql($generator);
-            $p['query'] = $p['query'][0] === '(' ? trim($p['query'], '()') : $p['query'];
+    protected function _buildSetOperationPart(
+        string $operation,
+        array $parts,
+        Query $query,
+        ValueBinder $binder,
+    ): string {
+        $setOperationsOrderBy = $query
+            ->getConnection()
+            ->getDriver($query->getConnectionRole())
+            ->supports(DriverFeatureEnum::SET_OPERATIONS_ORDER_BY);
+
+        $parts = array_map(function (array $p) use ($binder, $setOperationsOrderBy) {
+            /** @var \Cake\Database\Expression\IdentifierExpression $expr */
+            $expr = $p['query'];
+            $p['query'] = $expr->sql($binder);
+            $p['query'] = str_starts_with($p['query'], '(') ? trim($p['query'], '()') : $p['query'];
             $prefix = $p['all'] ? 'ALL ' : '';
-            if ($this->_orderedUnion) {
+            if ($setOperationsOrderBy) {
                 return "{$prefix}({$p['query']})";
             }
 
             return $prefix . $p['query'];
         }, $parts);
 
-        if ($this->_orderedUnion) {
-            return sprintf(")\nUNION %s", implode("\nUNION ", $parts));
+        if ($setOperationsOrderBy) {
+            return sprintf(")\n{$operation} %s", implode("\n{$operation} ", $parts));
         }
 
-        return sprintf("\nUNION %s", implode("\nUNION ", $parts));
+        return sprintf("\n{$operation} %s", implode("\n{$operation} ", $parts));
+    }
+
+    /**
+     * Builds the SQL string for all the INTERSECT clauses in this query, when dealing
+     * with query objects it will also transform them using their configured SQL
+     * dialect.
+     *
+     * @param array $parts list of queries to be operated with INTERSECT
+     * @param \Cake\Database\Query $query The query that is being compiled
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
+     * @return string
+     */
+    protected function _buildIntersectPart(array $parts, Query $query, ValueBinder $binder): string
+    {
+        return $this->_buildSetOperationPart('INTERSECT', $parts, $query, $binder);
+    }
+
+    /**
+     * Builds the SQL string for all the EXCEPT clauses in this query, when dealing
+     * with query objects it will also transform them using their configured SQL
+     * dialect.
+     *
+     * @param array $parts list of queries to be operated with EXCEPT
+     * @param \Cake\Database\Query $query The query that is being compiled
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
+     * @return string
+     */
+    protected function _buildExceptPart(array $parts, Query $query, ValueBinder $binder): string
+    {
+        return $this->_buildSetOperationPart('EXCEPT', $parts, $query, $binder);
+    }
+
+    /**
+     * Builds the SQL string for all the UNION clauses in this query, when dealing
+     * with query objects it will also transform them using their configured SQL
+     * dialect.
+     *
+     * @param array $parts list of queries to be operated with UNION
+     * @param \Cake\Database\Query $query The query that is being compiled
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
+     * @return string
+     */
+    protected function _buildUnionPart(array $parts, Query $query, ValueBinder $binder): string
+    {
+        return $this->_buildSetOperationPart('UNION', $parts, $query, $binder);
     }
 
     /**
@@ -309,16 +431,23 @@ protected function _buildUnionPart($parts, $query, $generator)
      *
      * @param array $parts The insert parts.
      * @param \Cake\Database\Query $query The query that is being compiled
-     * @param \Cake\Database\ValueBinder $generator the placeholder generator to be used in expressions
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
      * @return string SQL fragment.
      */
-    protected function _buildInsertPart($parts, $query, $generator)
+    protected function _buildInsertPart(array $parts, Query $query, ValueBinder $binder): string
     {
+        if (!isset($parts[0])) {
+            throw new DatabaseException(
+                'Could not compile insert query. No table was specified. ' .
+                'Use `into()` to define a table.',
+            );
+        }
         $table = $parts[0];
-        $columns = $this->_stringifyExpressions($parts[1], $generator);
-        $modifiers = $this->_buildModifierPart($query->clause('modifier'), $query, $generator);
+        $columns = $this->_stringifyExpressions($parts[1], $binder);
+        $hint = $this->_buildOptimizerHintPart($query->clause('optimizerHint'), $query, $binder);
+        $modifiers = $this->_buildModifierPart($query->clause('modifier'), $query, $binder);
 
-        return sprintf('INSERT%s INTO %s (%s)', $modifiers, $table, implode(', ', $columns));
+        return sprintf('INSERT%s%s INTO %s (%s)', $hint, $modifiers, $table, implode(', ', $columns));
     }
 
     /**
@@ -326,12 +455,12 @@ protected function _buildInsertPart($parts, $query, $generator)
      *
      * @param array $parts The values parts.
      * @param \Cake\Database\Query $query The query that is being compiled
-     * @param \Cake\Database\ValueBinder $generator the placeholder generator to be used in expressions
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
      * @return string SQL fragment.
      */
-    protected function _buildValuesPart($parts, $query, $generator)
+    protected function _buildValuesPart(array $parts, Query $query, ValueBinder $binder): string
     {
-        return implode('', $this->_stringifyExpressions($parts, $generator));
+        return implode('', $this->_stringifyExpressions($parts, $binder));
     }
 
     /**
@@ -339,15 +468,33 @@ protected function _buildValuesPart($parts, $query, $generator)
      *
      * @param array $parts The update parts.
      * @param \Cake\Database\Query $query The query that is being compiled
-     * @param \Cake\Database\ValueBinder $generator the placeholder generator to be used in expressions
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
      * @return string SQL fragment.
      */
-    protected function _buildUpdatePart($parts, $query, $generator)
+    protected function _buildUpdatePart(array $parts, Query $query, ValueBinder $binder): string
     {
-        $table = $this->_stringifyExpressions($parts, $generator);
-        $modifiers = $this->_buildModifierPart($query->clause('modifier'), $query, $generator);
+        $table = $this->_stringifyExpressions($parts, $binder);
+        $hint = $this->_buildOptimizerHintPart($query->clause('optimizerHint'), $query, $binder);
+        $modifiers = $this->_buildModifierPart($query->clause('modifier'), $query, $binder);
+
+        return sprintf('UPDATE%s%s %s', $hint, $modifiers, implode(',', $table));
+    }
+
+    /**
+     * Builds the optimizer hint comment part.
+     *
+     * @param list $parts The optmizer hints
+     * @param \Cake\Database\Query $query The query that is being compiled
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
+     * @return string Optimizer hint comment
+     */
+    protected function _buildOptimizerHintPart(array $parts, Query $query, ValueBinder $binder): string
+    {
+        if ($parts === [] || !$query->getDriver()->supports(DriverFeatureEnum::OPTIMIZER_HINT_COMMENT)) {
+            return '';
+        }
 
-        return sprintf('UPDATE%s %s', $modifiers, implode(',', $table));
+        return sprintf(' /*+ %s */', implode(' ', $parts));
     }
 
     /**
@@ -355,16 +502,16 @@ protected function _buildUpdatePart($parts, $query, $generator)
      *
      * @param array $parts The query modifier parts
      * @param \Cake\Database\Query $query The query that is being compiled
-     * @param \Cake\Database\ValueBinder $generator the placeholder generator to be used in expressions
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
      * @return string SQL fragment.
      */
-    protected function _buildModifierPart($parts, $query, $generator)
+    protected function _buildModifierPart(array $parts, Query $query, ValueBinder $binder): string
     {
         if ($parts === []) {
             return '';
         }
 
-        return ' ' . implode(' ', $this->_stringifyExpressions($parts, $generator, false));
+        return ' ' . implode(' ', $this->_stringifyExpressions($parts, $binder, false));
     }
 
     /**
@@ -372,16 +519,16 @@ protected function _buildModifierPart($parts, $query, $generator)
      * into their string representation.
      *
      * @param array $expressions list of strings and ExpressionInterface objects
-     * @param \Cake\Database\ValueBinder $generator the placeholder generator to be used in expressions
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
      * @param bool $wrap Whether to wrap each expression object with parenthesis
      * @return array
      */
-    protected function _stringifyExpressions($expressions, $generator, $wrap = true)
+    protected function _stringifyExpressions(array $expressions, ValueBinder $binder, bool $wrap = true): array
     {
         $result = [];
         foreach ($expressions as $k => $expression) {
             if ($expression instanceof ExpressionInterface) {
-                $value = $expression->sql($generator);
+                $value = $expression->sql($binder);
                 $expression = $wrap ? '(' . $value . ')' : $value;
             }
             $result[$k] = $expression;
diff --git a/src/Database/README.md b/src/Database/README.md
index ee38d23052e..46806ecf7ce 100644
--- a/src/Database/README.md
+++ b/src/Database/README.md
@@ -35,35 +35,29 @@ to use:
 ```php
 use Cake\Database\Connection;
 use Cake\Database\Driver\Mysql;
+use Cake\Database\Driver\Sqlite;
 
-$driver = new Mysql([
+$connection = new Connection([
+	'driver' => Mysql::class,
 	'database' => 'test',
 	'username' => 'root',
-	'password' => 'secret'
-]);
-$connection = new Connection([
-	'driver' => $driver
+	'password' => 'secret',
 ]);
-```
-
-Drivers are classes responsible for actually executing the commands to the database and
-correctly building the SQL according to the database specific dialect. Drivers can also
-be specified by passing a class name. In that case, include all the connection details
-directly in the options array:
-
-```php
-use Cake\Database\Connection;
 
-$connection = new Connection([
-	'driver' => 'Cake\Database\Driver\Sqlite'
+$connection2 = new Connection([
+	'driver' => Sqlite::class,
 	'database' => '/path/to/file.db'
 ]);
 ```
 
+Drivers are classes responsible for actually executing the commands to the database and
+correctly building the SQL according to the database specific dialect.
+
 ### Connection options
 
 This is a list of possible options that can be passed when creating a connection:
 
+* `driver`: Driver class name
 * `persistent`: Creates a persistent connection
 * `host`: The server host
 * `database`: The database name
@@ -75,8 +69,9 @@ This is a list of possible options that can be passed when creating a connection
 ## Using connections
 
 After creating a connection, you can immediately interact with the database. You can choose
-either to use the shorthand methods `execute()`, `insert()`, `update()`, `delete()` or use the
-`newQuery()` for using a query builder.
+either to use the shorthand methods `execute()`, `insert()`, `update()`, `delete()` or use
+one of `selectQuery()`, `updateQuery()`, `insertQuery()` or `deleteQuery()`
+to get a query builder for particular type of query.
 
 The easiest way of executing queries is by using the `execute()` method, it will return a
 `Cake\Database\StatementInterface` that you can use to get the data back:
@@ -84,7 +79,7 @@ The easiest way of executing queries is by using the `execute()` method, it will
 ```php
 $statement = $connection->execute('SELECT * FROM articles');
 
-while($row = $statement->fetch('assoc')) {
+while($row = $statement->fetch(\PDO::FETCH_ASSOC)) {
 	echo $row['title'] . PHP_EOL;
 }
 ```
@@ -92,7 +87,7 @@ Binding values to parametrized arguments is also possible with the execute funct
 
 ```php
 $statement = $connection->execute('SELECT * FROM articles WHERE id = :id', ['id' => 1], ['id' => 'integer']);
-$results = $statement->fetch('assoc');
+$results = $statement->fetch(\PDO::FETCH_ASSOC);
 ```
 
 The third parameter is the types the passed values should be converted to when passed to the database. If
@@ -103,7 +98,7 @@ Alternatively you can construct a statement manually and then fetch rows from it
 ```php
 $statement = $connection->prepare('SELECT * from articles WHERE id != :id');
 $statement->bind(['id' => 1], ['id' => 'integer']);
-$results = $statement->fetchAll('assoc');
+$results = $statement->fetchAll(\PDO::FETCH_ASSOC);
 ```
 
 The default types that are understood by this library and can be passed to the `bind()` function or to `execute()`
@@ -127,10 +122,10 @@ Statements can be reused by binding new values to the parameters in the query:
 ```php
 $statement = $connection->prepare('SELECT * from articles WHERE id = :id');
 $statement->bind(['id' => 1], ['id' => 'integer']);
-$results = $statement->fetchAll('assoc');
+$results = $statement->fetchAll(\PDO::FETCH_ASSOC);
 
 $statement->bind(['id' => 1], ['id' => 'integer']);
-$results = $statement->fetchAll('assoc');
+$results = $statement->fetchAll(\PDO::FETCH_ASSOC);
 ```
 
 ### Updating Rows
@@ -196,7 +191,7 @@ One of the goals of this library is to allow the generation of both simple and c
 ease. The query builder can be accessed by getting a new instance of a query:
 
 ```php
-$query = $connection->newQuery();
+$query = $connection->selectQuery();
 ```
 
 ### Selecting Fields
@@ -223,7 +218,7 @@ Generating conditions:
 // WHERE id = 1
 $query->where(['id' => 1]);
 
-// WHERE id > 2
+// WHERE id > 1
 $query->where(['id >' => 1]);
 ```
 
@@ -240,16 +235,13 @@ $query->where(['id >' => 1, 'title' => 'My title']);
 It is possible to generate `OR` conditions as well
 
 ```php
-$query->where(['id >' => 1])->orWhere(['title' => 'My Title']);
-
-// Equivalent to
 $query->where(['OR' => ['id >' => 1, 'title' => 'My title']]);
 ```
 
 For even more complex conditions you can use closures and expression objects:
 
 ```php
-$query->where(function ($exp) {
+$query->where(function (ExpressionInterface $exp) {
         return $exp
             ->eq('author_id', 2)
             ->eq('published', true)
@@ -272,8 +264,8 @@ WHERE
 Combining expressions is also possible:
 
 ```php
-$query->where(function ($exp) {
-        $orConditions = $exp->or_(['author_id' => 2])
+$query->where(function (ExpressionInterface $exp) {
+        $orConditions = $exp->or(['author_id' => 2])
             ->eq('author_id', 5);
         return $exp
             ->not($orConditions)
@@ -345,7 +337,7 @@ SELECT CONCAT(title, :c0) ...;
 
 ### Other SQL Clauses
 
-Read of all other SQL clauses that the builder is capable of generating in the [official API docs](https://api.cakephp.org/3.x/class-Cake.Database.Query.html)
+Read of all other SQL clauses that the builder is capable of generating in the [official API docs](https://api.cakephp.org/4.x/class-Cake.Database.Query.html)
 
 ### Getting Results out of a Query
 
@@ -358,10 +350,10 @@ foreach ($query as $row) {
 }
 
 // Get the statement and fetch all results
-$results = $query->execute()->fetchAll('assoc');
+$results = $query->execute()->fetchAll(\PDO::FETCH_ASSOC);
 ```
 
 ## Official API
 
-You can read the official [official API docs](https://api.cakephp.org/3.x/namespace-Cake.Database.html) to learn more of what this library
+You can read the official [official API docs](https://api.cakephp.org/5.x/namespace-Cake.Database.html) to learn more of what this library
 has to offer.
diff --git a/src/Database/Retry/ErrorCodeWaitStrategy.php b/src/Database/Retry/ErrorCodeWaitStrategy.php
new file mode 100644
index 00000000000..963b30b2981
--- /dev/null
+++ b/src/Database/Retry/ErrorCodeWaitStrategy.php
@@ -0,0 +1,69 @@
+
+     */
+    protected array $errorCodes;
+
+    /**
+     * @var int
+     */
+    protected int $retryInterval;
+
+    /**
+     * @param array $errorCodes DB-specific error codes that allow retrying
+     * @param int $retryInterval Seconds to wait before allowing next retry, 0 for no wait.
+     */
+    public function __construct(array $errorCodes, int $retryInterval)
+    {
+        $this->errorCodes = $errorCodes;
+        $this->retryInterval = $retryInterval;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function shouldRetry(Exception $exception, int $retryCount): bool
+    {
+        if (
+            $exception instanceof PDOException &&
+            $exception->errorInfo &&
+            in_array($exception->errorInfo[1], $this->errorCodes)
+        ) {
+            if ($this->retryInterval > 0) {
+                sleep($this->retryInterval);
+            }
+
+            return true;
+        }
+
+        return false;
+    }
+}
diff --git a/src/Database/Retry/ReconnectStrategy.php b/src/Database/Retry/ReconnectStrategy.php
new file mode 100644
index 00000000000..78fd336a119
--- /dev/null
+++ b/src/Database/Retry/ReconnectStrategy.php
@@ -0,0 +1,125 @@
+
+     */
+    protected static array $causes = [
+        'gone away',
+        'Lost connection',
+        'Transaction() on null',
+        'closed the connection unexpectedly',
+        'closed unexpectedly',
+        'deadlock avoided',
+        'decryption failed or bad record mac',
+        'is dead or not enabled',
+        'no connection to the server',
+        'query_wait_timeout',
+        'reset by peer',
+        'terminate due to client_idle_limit',
+        'while sending',
+        'writing data to the connection',
+    ];
+
+    /**
+     * The connection to check for validity
+     *
+     * @var \Cake\Database\Connection
+     */
+    protected Connection $connection;
+
+    /**
+     * Creates the ReconnectStrategy object by storing a reference to the
+     * passed connection. This reference will be used to automatically
+     * reconnect to the server in case of failure.
+     *
+     * @param \Cake\Database\Connection $connection The connection to check
+     */
+    public function __construct(Connection $connection)
+    {
+        $this->connection = $connection;
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * Checks whether the exception was caused by a lost connection,
+     * and returns true if it was able to successfully reconnect.
+     */
+    public function shouldRetry(Exception $exception, int $retryCount): bool
+    {
+        $message = $exception->getMessage();
+
+        foreach (static::$causes as $cause) {
+            if (str_contains($message, $cause)) {
+                return $this->reconnect();
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Tries to re-establish the connection to the server, if it is safe to do so
+     *
+     * @return bool Whether the connection was re-established
+     */
+    protected function reconnect(): bool
+    {
+        if ($this->connection->inTransaction()) {
+            // It is not safe to blindly reconnect in the middle of a transaction
+            return false;
+        }
+
+        try {
+            // Make sure we free any resources associated with the old connection
+            $this->connection->getDriver()->disconnect();
+        } catch (Exception) {
+        }
+
+        try {
+            $this->connection->getDriver()->connect();
+            $this->connection->getDriver()->log(
+                'connection={connection} [RECONNECT]',
+                ['connection' => $this->connection->configName()],
+            );
+
+            return true;
+        } catch (Exception) {
+            // If there was an error connecting again, don't report it back,
+            // let the retry handler do it.
+            return false;
+        }
+    }
+}
diff --git a/src/Database/Schema/BaseSchema.php b/src/Database/Schema/BaseSchema.php
deleted file mode 100644
index 281c2208ea7..00000000000
--- a/src/Database/Schema/BaseSchema.php
+++ /dev/null
@@ -1,276 +0,0 @@
-connect();
-        $this->_driver = $driver;
-    }
-
-    /**
-     * Generate an ON clause for a foreign key.
-     *
-     * @param string|null $on The on clause
-     * @return string
-     */
-    protected function _foreignOnClause($on)
-    {
-        if ($on === TableSchema::ACTION_SET_NULL) {
-            return 'SET NULL';
-        }
-        if ($on === TableSchema::ACTION_SET_DEFAULT) {
-            return 'SET DEFAULT';
-        }
-        if ($on === TableSchema::ACTION_CASCADE) {
-            return 'CASCADE';
-        }
-        if ($on === TableSchema::ACTION_RESTRICT) {
-            return 'RESTRICT';
-        }
-        if ($on === TableSchema::ACTION_NO_ACTION) {
-            return 'NO ACTION';
-        }
-    }
-
-    /**
-     * Convert string on clauses to the abstract ones.
-     *
-     * @param string $clause The on clause to convert.
-     * @return string|null
-     */
-    protected function _convertOnClause($clause)
-    {
-        if ($clause === 'CASCADE' || $clause === 'RESTRICT') {
-            return strtolower($clause);
-        }
-        if ($clause === 'NO ACTION') {
-            return TableSchema::ACTION_NO_ACTION;
-        }
-
-        return TableSchema::ACTION_SET_NULL;
-    }
-
-    /**
-     * Convert foreign key constraints references to a valid
-     * stringified list
-     *
-     * @param string|array $references The referenced columns of a foreign key constraint statement
-     * @return string
-     */
-    protected function _convertConstraintColumns($references)
-    {
-        if (is_string($references)) {
-            return $this->_driver->quoteIdentifier($references);
-        }
-
-        return implode(', ', array_map(
-            [$this->_driver, 'quoteIdentifier'],
-            $references
-        ));
-    }
-
-    /**
-     * Generate the SQL to drop a table.
-     *
-     * @param \Cake\Database\Schema\TableSchema $schema Schema instance
-     * @return array SQL statements to drop a table.
-     */
-    public function dropTableSql(TableSchema $schema)
-    {
-        $sql = sprintf(
-            'DROP TABLE %s',
-            $this->_driver->quoteIdentifier($schema->name())
-        );
-
-        return [$sql];
-    }
-
-    /**
-     * Generate the SQL to list the tables.
-     *
-     * @param array $config The connection configuration to use for
-     *    getting tables from.
-     * @return array An array of (sql, params) to execute.
-     */
-    abstract public function listTablesSql($config);
-
-    /**
-     * Generate the SQL to describe a table.
-     *
-     * @param string $tableName The table name to get information on.
-     * @param array $config The connection configuration.
-     * @return array An array of (sql, params) to execute.
-     */
-    abstract public function describeColumnSql($tableName, $config);
-
-    /**
-     * Generate the SQL to describe the indexes in a table.
-     *
-     * @param string $tableName The table name to get information on.
-     * @param array $config The connection configuration.
-     * @return array An array of (sql, params) to execute.
-     */
-    abstract public function describeIndexSql($tableName, $config);
-
-    /**
-     * Generate the SQL to describe the foreign keys in a table.
-     *
-     * @param string $tableName The table name to get information on.
-     * @param array $config The connection configuration.
-     * @return array An array of (sql, params) to execute.
-     */
-    abstract public function describeForeignKeySql($tableName, $config);
-
-    /**
-     * Generate the SQL to describe table options
-     *
-     * @param string $tableName Table name.
-     * @param array $config The connection configuration.
-     * @return array SQL statements to get options for a table.
-     */
-    public function describeOptionsSql($tableName, $config)
-    {
-        return ['', ''];
-    }
-
-    /**
-     * Convert field description results into abstract schema fields.
-     *
-     * @param \Cake\Database\Schema\TableSchema $schema The table object to append fields to.
-     * @param array $row The row data from `describeColumnSql`.
-     * @return void
-     */
-    abstract public function convertColumnDescription(TableSchema $schema, $row);
-
-    /**
-     * Convert an index description results into abstract schema indexes or constraints.
-     *
-     * @param \Cake\Database\Schema\TableSchema $schema The table object to append
-     *    an index or constraint to.
-     * @param array $row The row data from `describeIndexSql`.
-     * @return void
-     */
-    abstract public function convertIndexDescription(TableSchema $schema, $row);
-
-    /**
-     * Convert a foreign key description into constraints on the Table object.
-     *
-     * @param \Cake\Database\Schema\TableSchema $schema The table object to append
-     *    a constraint to.
-     * @param array $row The row data from `describeForeignKeySql`.
-     * @return void
-     */
-    abstract public function convertForeignKeyDescription(TableSchema $schema, $row);
-
-    /**
-     * Convert options data into table options.
-     *
-     * @param \Cake\Database\Schema\TableSchema $schema Table instance.
-     * @param array $row The row of data.
-     * @return void
-     */
-    public function convertOptionsDescription(TableSchema $schema, $row)
-    {
-    }
-
-    /**
-     * Generate the SQL to create a table.
-     *
-     * @param \Cake\Database\Schema\TableSchema $schema Table instance.
-     * @param array $columns The columns to go inside the table.
-     * @param array $constraints The constraints for the table.
-     * @param array $indexes The indexes for the table.
-     * @return array SQL statements to create a table.
-     */
-    abstract public function createTableSql(TableSchema $schema, $columns, $constraints, $indexes);
-
-    /**
-     * Generate the SQL fragment for a single column in a table.
-     *
-     * @param \Cake\Database\Schema\TableSchema $schema The table instance the column is in.
-     * @param string $name The name of the column.
-     * @return string SQL fragment.
-     */
-    abstract public function columnSql(TableSchema $schema, $name);
-
-    /**
-     * Generate the SQL queries needed to add foreign key constraints to the table
-     *
-     * @param \Cake\Database\Schema\TableSchema $schema The table instance the foreign key constraints are.
-     * @return array SQL fragment.
-     */
-    abstract public function addConstraintSql(TableSchema $schema);
-
-    /**
-     * Generate the SQL queries needed to drop foreign key constraints from the table
-     *
-     * @param \Cake\Database\Schema\TableSchema $schema The table instance the foreign key constraints are.
-     * @return array SQL fragment.
-     */
-    abstract public function dropConstraintSql(TableSchema $schema);
-
-    /**
-     * Generate the SQL fragments for defining table constraints.
-     *
-     * @param \Cake\Database\Schema\TableSchema $schema The table instance the column is in.
-     * @param string $name The name of the column.
-     * @return string SQL fragment.
-     */
-    abstract public function constraintSql(TableSchema $schema, $name);
-
-    /**
-     * Generate the SQL fragment for a single index in a table.
-     *
-     * @param \Cake\Database\Schema\TableSchema $schema The table object the column is in.
-     * @param string $name The name of the column.
-     * @return string SQL fragment.
-     */
-    abstract public function indexSql(TableSchema $schema, $name);
-
-    /**
-     * Generate the SQL to truncate a table.
-     *
-     * @param \Cake\Database\Schema\TableSchema $schema Table instance.
-     * @return array SQL statements to truncate a table.
-     */
-    abstract public function truncateTableSql(TableSchema $schema);
-}
diff --git a/src/Database/Schema/CachedCollection.php b/src/Database/Schema/CachedCollection.php
index 0c999c6d59f..e6d5856d2ee 100644
--- a/src/Database/Schema/CachedCollection.php
+++ b/src/Database/Schema/CachedCollection.php
@@ -1,4 +1,6 @@
 collection = $collection;
+        $this->prefix = $prefix;
+        $this->cacher = $cacher;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function listTablesWithoutViews(): array
+    {
+        return $this->collection->listTablesWithoutViews();
+    }
+
+    /**
+     * @inheritDoc
      */
-    public function __construct(ConnectionInterface $connection, $cacheKey = true)
+    public function listTables(): array
     {
-        parent::__construct($connection);
-        $this->setCacheMetadata($cacheKey);
+        return $this->collection->listTables();
     }
 
     /**
-     * {@inheritDoc}
+     * Get the column metadata for a table.
+     *
+     * The name can include a database schema name in the form 'schema.table'.
+     *
+     * Caching will be applied if `cacheMetadata` key is present in the Connection
+     * configuration options. Defaults to _cake_model_ when true.
+     *
+     * ### Options
+     *
+     * - `forceRefresh` - Set to true to force rebuilding the cached metadata.
+     *   Defaults to false.
      *
+     * @param string $name The name of the table to describe.
+     * @param array $options The options to use, see above.
+     * @return \Cake\Database\Schema\TableSchemaInterface Object with column metadata.
+     * @throws \Cake\Database\Exception\DatabaseException when table cannot be described.
      */
-    public function describe($name, array $options = [])
+    public function describe(string $name, array $options = []): TableSchemaInterface
     {
         $options += ['forceRefresh' => false];
-        $cacheConfig = $this->getCacheMetadata();
         $cacheKey = $this->cacheKey($name);
 
-        if (!empty($cacheConfig) && !$options['forceRefresh']) {
-            $cached = Cache::read($cacheKey, $cacheConfig);
-            if ($cached !== false) {
+        if (!$options['forceRefresh']) {
+            $cached = $this->cacher->get($cacheKey);
+            if ($cached !== null) {
                 return $cached;
             }
         }
 
-        $table = parent::describe($name, $options);
-
-        if (!empty($cacheConfig)) {
-            Cache::write($cacheKey, $table, $cacheConfig);
-        }
+        $table = $this->collection->describe($name, $options);
+        $this->cacher->set($cacheKey, $table);
 
         return $table;
     }
@@ -75,54 +116,31 @@ public function describe($name, array $options = [])
      * @param string $name The name to get a cache key for.
      * @return string The cache key.
      */
-    public function cacheKey($name)
+    public function cacheKey(string $name): string
     {
-        return $this->_connection->configName() . '_' . $name;
+        return $this->prefix . '_' . $name;
     }
 
     /**
-     * Sets the cache config name to use for caching table metadata, or
-     * disables it if false is passed.
+     * Set a cacher.
      *
-     * @param bool $enable Whether or not to enable caching
+     * @param \Psr\SimpleCache\CacheInterface $cacher Cacher object
      * @return $this
      */
-    public function setCacheMetadata($enable)
+    public function setCacher(CacheInterface $cacher)
     {
-        if ($enable === true) {
-            $enable = '_cake_model_';
-        }
-
-        $this->_cache = $enable;
+        $this->cacher = $cacher;
 
         return $this;
     }
 
     /**
-     * Gets the cache config name to use for caching table metadata, false means disabled.
+     * Get a cacher.
      *
-     * @return string|bool
+     * @return \Psr\SimpleCache\CacheInterface $cacher Cacher object
      */
-    public function getCacheMetadata()
+    public function getCacher(): CacheInterface
     {
-        return $this->_cache;
-    }
-
-    /**
-     * Sets the cache config name to use for caching table metadata, or
-     * disables it if false is passed.
-     * If called with no arguments it returns the current configuration name.
-     *
-     * @deprecated 3.4.0 Use setCacheMetadata()/getCacheMetadata()
-     * @param bool|null $enable Whether or not to enable caching
-     * @return string|bool
-     */
-    public function cacheMetadata($enable = null)
-    {
-        if ($enable !== null) {
-            $this->setCacheMetadata($enable);
-        }
-
-        return $this->getCacheMetadata();
+        return $this->cacher;
     }
 }
diff --git a/src/Database/Schema/CheckConstraint.php b/src/Database/Schema/CheckConstraint.php
new file mode 100644
index 00000000000..208818a94d1
--- /dev/null
+++ b/src/Database/Schema/CheckConstraint.php
@@ -0,0 +1,84 @@
+= 18")
+     */
+    public function __construct(
+        protected string $name,
+        protected string $expression,
+    ) {
+    }
+
+    /**
+     * Set the check constraint expression.
+     *
+     * @param string $expression The SQL expression for the check constraint
+     * @return $this
+     * @throws \InvalidArgumentException
+     */
+    public function setExpression(string $expression)
+    {
+        if (trim($expression) === '') {
+            throw new InvalidArgumentException('Check constraint expression cannot be empty');
+        }
+
+        $this->expression = trim($expression);
+
+        return $this;
+    }
+
+    /**
+     * Get the check constraint expression.
+     *
+     * @return string
+     */
+    public function getExpression(): string
+    {
+        return $this->expression;
+    }
+
+    /**
+     * Converts a constraint to an array that is compatible
+     * with the constructor.
+     *
+     * @return array
+     */
+    public function toArray(): array
+    {
+        return [
+            'name' => $this->name,
+            'type' => $this->type,
+            'expression' => $this->expression,
+        ];
+    }
+}
diff --git a/src/Database/Schema/Collection.php b/src/Database/Schema/Collection.php
index 483bc9a3bb8..23ce2084a40 100644
--- a/src/Database/Schema/Collection.php
+++ b/src/Database/Schema/Collection.php
@@ -1,4 +1,6 @@
 _connection = $connection;
-        $this->_dialect = $connection->getDriver()->schemaDialect();
     }
 
     /**
-     * Get the list of tables available in the current connection.
+     * Get the list of tables, excluding any views, available in the current connection.
      *
-     * @return array The list of tables in the connected database/schema.
+     * @return array The list of tables in the connected database/schema.
      */
-    public function listTables()
+    public function listTablesWithoutViews(): array
     {
-        list($sql, $params) = $this->_dialect->listTablesSql($this->_connection->config());
-        $result = [];
-        $statement = $this->_connection->execute($sql, $params);
-        while ($row = $statement->fetch()) {
-            $result[] = $row[0];
-        }
-        $statement->closeCursor();
+        return $this->getDialect()->listTablesWithoutViews();
+    }
 
-        return $result;
+    /**
+     * Get the list of tables and views available in the current connection.
+     *
+     * @return array The list of tables and views in the connected database/schema.
+     */
+    public function listTables(): array
+    {
+        return $this->getDialect()->listTables();
     }
 
     /**
      * Get the column metadata for a table.
      *
-     * Caching will be applied if `cacheMetadata` key is present in the Connection
-     * configuration options. Defaults to _cake_model_ when true.
-     *
-     * ### Options
-     *
-     * - `forceRefresh` - Set to true to force rebuilding the cached metadata.
-     *   Defaults to false.
+     * The name can include a database schema name in the form 'schema.table'.
      *
      * @param string $name The name of the table to describe.
-     * @param array $options The options to use, see above.
-     * @return \Cake\Database\Schema\TableSchema Object with column metadata.
-     * @throws \Cake\Database\Exception when table cannot be described.
+     * @param array $options Unused
+     * @return \Cake\Database\Schema\TableSchemaInterface Object with column metadata.
+     * @throws \Cake\Database\Exception\DatabaseException when table cannot be described.
      */
-    public function describe($name, array $options = [])
+    public function describe(string $name, array $options = []): TableSchemaInterface
     {
-        $config = $this->_connection->config();
-        if (strpos($name, '.')) {
-            list($config['schema'], $name) = explode('.', $name);
-        }
-        $table = new TableSchema($name);
-
-        $this->_reflect('Column', $name, $config, $table);
-        if (count($table->columns()) === 0) {
-            throw new Exception(sprintf('Cannot describe %s. It has 0 columns.', $name));
-        }
-
-        $this->_reflect('Index', $name, $config, $table);
-        $this->_reflect('ForeignKey', $name, $config, $table);
-        $this->_reflect('Options', $name, $config, $table);
-
-        return $table;
+        return $this->getDialect()->describe($name);
     }
 
     /**
-     * Helper method for running each step of the reflection process.
+     * Setups the schema dialect to be used for this collection.
      *
-     * @param string $stage The stage name.
-     * @param string $name The table name.
-     * @param array $config The config data.
-     * @param \Cake\Database\Schema\TableSchema $schema The table instance
-     * @return void
-     * @throws \Cake\Database\Exception on query failure.
+     * @return \Cake\Database\Schema\SchemaDialect
      */
-    protected function _reflect($stage, $name, $config, $schema)
+    protected function getDialect(): SchemaDialect
     {
-        $describeMethod = "describe{$stage}Sql";
-        $convertMethod = "convert{$stage}Description";
-
-        list($sql, $params) = $this->_dialect->{$describeMethod}($name, $config);
-        if (empty($sql)) {
-            return;
-        }
-        try {
-            $statement = $this->_connection->execute($sql, $params);
-        } catch (PDOException $e) {
-            throw new Exception($e->getMessage(), 500, $e);
-        }
-        foreach ($statement->fetchAll('assoc') as $row) {
-            $this->_dialect->{$convertMethod}($schema, $row);
-        }
-        $statement->closeCursor();
+        return $this->_dialect ??= $this->_connection->getWriteDriver()->schemaDialect();
     }
 }
diff --git a/src/Database/Schema/CollectionInterface.php b/src/Database/Schema/CollectionInterface.php
new file mode 100644
index 00000000000..1861e36825c
--- /dev/null
+++ b/src/Database/Schema/CollectionInterface.php
@@ -0,0 +1,54 @@
+ listTablesWithoutViews() Get the list of tables available in the current connection.
+ * This will exclude any views in the schema.
+ */
+interface CollectionInterface
+{
+    /**
+     * Get the list of tables available in the current connection.
+     *
+     * @return array The list of tables in the connected database/schema.
+     */
+    public function listTables(): array;
+
+    /**
+     * Get the column metadata for a table.
+     *
+     * Caching will be applied if `cacheMetadata` key is present in the Connection
+     * configuration options. Defaults to _cake_model_ when true.
+     *
+     * ### Options
+     *
+     * - `forceRefresh` - Set to true to force rebuilding the cached metadata.
+     *   Defaults to false.
+     *
+     * @param string $name The name of the table to describe.
+     * @param array $options The options to use, see above.
+     * @return \Cake\Database\Schema\TableSchemaInterface Object with column metadata.
+     * @throws \Cake\Database\Exception\DatabaseException when table cannot be described.
+     */
+    public function describe(string $name, array $options = []): TableSchemaInterface;
+}
diff --git a/src/Database/Schema/Column.php b/src/Database/Schema/Column.php
new file mode 100644
index 00000000000..0a812cc50e8
--- /dev/null
+++ b/src/Database/Schema/Column.php
@@ -0,0 +1,699 @@
+null ??= null;
+        $this->length ??= null;
+        $this->generated ??= null;
+        $this->precision ??= null;
+        $this->increment ??= null;
+        $this->after ??= null;
+        $this->onUpdate ??= null;
+        $this->comment ??= null;
+        $this->unsigned ??= null;
+        $this->collate ??= null;
+        $this->srid ??= null;
+        $this->geometryType ??= null;
+        $this->baseType ??= null;
+        $this->fixed ??= null;
+    }
+
+    /**
+     * Sets the column name.
+     *
+     * @param string $name Name
+     * @return $this
+     */
+    public function setName(string $name)
+    {
+        $this->name = $name;
+
+        return $this;
+    }
+
+    /**
+     * Gets the column name.
+     *
+     * @return string|null
+     */
+    public function getName(): ?string
+    {
+        return $this->name;
+    }
+
+    /**
+     * Get the base type if defined. Will fallback to `type` if not set.
+     *
+     * Used to get the base type of a column when the column type is a complex/custom type.
+     *
+     * @return string|null
+     */
+    public function getBaseType(): ?string
+    {
+        if (isset($this->baseType)) {
+            return $this->baseType;
+        }
+        $type = $this->type;
+        if (TypeFactory::getMapped($type)) {
+            $type = TypeFactory::build($type)->getBaseType();
+        }
+
+        return $this->baseType = $type;
+    }
+
+    /**
+     * Sets the base type of the column.
+     *
+     * Used to set the base type of a column when the column type is a complex/custom type.
+     *
+     * @param string|null $baseType Base type
+     * @return $this
+     */
+    public function setBaseType(?string $baseType)
+    {
+        $this->baseType = $baseType;
+
+        return $this;
+    }
+
+    /**
+     * Sets the column type.
+     *
+     * Type names are not validated, as drivers and dialects may implement
+     * platform specific types that are not known by cakephp.
+     *
+     * Drivers are expected to handle unknown types gracefully.
+     *
+     * @param string $type Column type
+     * @return $this
+     */
+    public function setType(string $type)
+    {
+        $this->type = $type;
+
+        return $this;
+    }
+
+    /**
+     * Gets the column type.
+     *
+     * @return string
+     */
+    public function getType(): string
+    {
+        return $this->type;
+    }
+
+    /**
+     * Sets the column length.
+     *
+     * @param int|null $length Length
+     * @return $this
+     */
+    public function setLength(?int $length)
+    {
+        $this->length = $length;
+
+        return $this;
+    }
+
+    /**
+     * Gets the column length.
+     *
+     * @return int|null
+     */
+    public function getLength(): ?int
+    {
+        return $this->length;
+    }
+
+    /**
+     * Sets whether the column allows nulls.
+     *
+     * @param bool $null Null
+     * @return $this
+     */
+    public function setNull(bool $null)
+    {
+        $this->null = $null;
+
+        return $this;
+    }
+
+    /**
+     * Gets whether the column allows nulls.
+     *
+     * @return bool|null
+     */
+    public function getNull(): ?bool
+    {
+        return $this->null;
+    }
+
+    /**
+     * Does the column allow nulls?
+     *
+     * @return bool
+     */
+    public function isNull(): bool
+    {
+        return $this->getNull() === true;
+    }
+
+    /**
+     * Sets the default column value.
+     *
+     * @param mixed $default Default
+     * @return $this
+     */
+    public function setDefault(mixed $default)
+    {
+        $this->default = $default;
+
+        return $this;
+    }
+
+    /**
+     * Gets the default column value.
+     *
+     * @return mixed
+     */
+    public function getDefault(): mixed
+    {
+        return $this->default;
+    }
+
+    /**
+     * Sets generated option for identity columns. Ignored otherwise.
+     *
+     * @param string|null $generated Generated option
+     * @return $this
+     */
+    public function setGenerated(?string $generated)
+    {
+        $this->generated = $generated;
+
+        return $this;
+    }
+
+    /**
+     * Gets generated option for identity columns. Null otherwise
+     *
+     * @return string|null
+     */
+    public function getGenerated(): ?string
+    {
+        return $this->generated;
+    }
+
+    /**
+     * Sets whether the column is an identity column.
+     *
+     * @param bool $identity Identity
+     * @return $this
+     */
+    public function setIdentity(bool $identity)
+    {
+        $this->identity = $identity;
+
+        return $this;
+    }
+
+    /**
+     * Gets whether the column is an identity column.
+     *
+     * @return bool
+     */
+    public function getIdentity(): bool
+    {
+        return $this->identity;
+    }
+
+    /**
+     * Is the column an identity column?
+     *
+     * @return bool
+     */
+    public function isIdentity(): bool
+    {
+        return $this->getIdentity();
+    }
+
+    /**
+     * Sets the name of the column to add this column after.
+     *
+     * @param string $after After
+     * @return $this
+     */
+    public function setAfter(string $after)
+    {
+        $this->after = $after;
+
+        return $this;
+    }
+
+    /**
+     * Returns the name of the column to add this column after.
+     *
+     * Used by MySQL and MariaDB in ALTER TABLE statements.
+     *
+     * @return string|null
+     */
+    public function getAfter(): ?string
+    {
+        return $this->after;
+    }
+
+    /**
+     * Sets the 'ON UPDATE' mysql column function.
+     *
+     * Used by MySQL and MariaDB in ALTER TABLE statements.
+     *
+     * @param string $update On Update function
+     * @return $this
+     */
+    public function setOnUpdate(string $update)
+    {
+        $this->onUpdate = $update;
+
+        return $this;
+    }
+
+    /**
+     * Returns the value of the ON UPDATE column function.
+     *
+     * @return string|null
+     */
+    public function getOnUpdate(): ?string
+    {
+        return $this->onUpdate;
+    }
+
+    /**
+     * Sets the number precision for decimal or float column.
+     *
+     * For example `DECIMAL(5,2)`, 5 is the length and 2 is the precision,
+     * and the column could store value from -999.99 to 999.99.
+     *
+     * @param int|null $precision Number precision
+     * @return $this
+     */
+    public function setPrecision(?int $precision)
+    {
+        $this->precision = $precision;
+
+        return $this;
+    }
+
+    /**
+     * Gets the number precision for decimal or float column.
+     *
+     * For example `DECIMAL(5,2)`, 5 is the length and 2 is the precision,
+     * and the column could store value from -999.99 to 999.99.
+     *
+     * @return int|null
+     */
+    public function getPrecision(): ?int
+    {
+        return $this->precision;
+    }
+
+    /**
+     * Sets the column identity increment.
+     *
+     * @param int $increment Number increment
+     * @return $this
+     */
+    public function setIncrement(int $increment)
+    {
+        $this->increment = $increment;
+
+        return $this;
+    }
+
+    /**
+     * Gets the column identity increment.
+     *
+     * @return int|null
+     */
+    public function getIncrement(): ?int
+    {
+        return $this->increment;
+    }
+
+    /**
+     * Sets the column comment.
+     *
+     * @param string|null $comment Comment
+     * @return $this
+     */
+    public function setComment(?string $comment)
+    {
+        $this->comment = $comment;
+
+        return $this;
+    }
+
+    /**
+     * Gets the column comment.
+     *
+     * @return string|null
+     */
+    public function getComment(): ?string
+    {
+        return $this->comment;
+    }
+
+    /**
+     * Sets whether field should be unsigned.
+     *
+     * @param bool $unsigned Signed
+     * @return $this
+     */
+    public function setUnsigned(bool $unsigned)
+    {
+        $this->unsigned = $unsigned;
+
+        return $this;
+    }
+
+    /**
+     * Gets whether field should be unsigned.
+     *
+     * @return bool|null
+     */
+    public function getUnsigned(): ?bool
+    {
+        return $this->unsigned;
+    }
+
+    /**
+     * Should the column be signed?
+     *
+     * @return bool
+     */
+    public function isSigned(): bool
+    {
+        return !$this->getUnsigned();
+    }
+
+    /**
+     * Should the column be unsigned?
+     *
+     * @return bool
+     */
+    public function isUnsigned(): bool
+    {
+        return $this->getUnsigned() === true;
+    }
+
+    /**
+     * Sets the column collation.
+     *
+     * @param string $collation Collation
+     * @return $this
+     */
+    public function setCollate(string $collation)
+    {
+        $this->collate = $collation;
+
+        return $this;
+    }
+
+    /**
+     * Gets the column collation.
+     *
+     * @return string|null
+     */
+    public function getCollate(): ?string
+    {
+        return $this->collate;
+    }
+
+    /**
+     * Sets the column SRID for geometry fields.
+     *
+     * @param int $srid SRID
+     * @return $this
+     */
+    public function setSrid(int $srid)
+    {
+        $this->srid = $srid;
+
+        return $this;
+    }
+
+    /**
+     * Gets the column SRID from geometry fields.
+     *
+     * @return int|null
+     */
+    public function getSrid(): ?int
+    {
+        return $this->srid;
+    }
+
+    /**
+     * Sets the geometry type for geometry fields.
+     *
+     * @param string $geometryType Geometry type (e.g., Point, Polygon)
+     * @return $this
+     */
+    public function setGeometryType(string $geometryType)
+    {
+        $this->geometryType = $geometryType;
+
+        return $this;
+    }
+
+    /**
+     * Gets the geometry type for geometry fields.
+     *
+     * @return string|null
+     */
+    public function getGeometryType(): ?string
+    {
+        return $this->geometryType;
+    }
+
+    /**
+     * Sets whether the column is fixed-length.
+     *
+     * Used for binary columns to distinguish between BINARY and VARBINARY.
+     *
+     * @param bool $fixed Fixed
+     * @return $this
+     */
+    public function setFixed(bool $fixed)
+    {
+        $this->fixed = $fixed;
+
+        return $this;
+    }
+
+    /**
+     * Gets whether the column is fixed-length.
+     *
+     * @return bool|null
+     */
+    public function getFixed(): ?bool
+    {
+        return $this->fixed;
+    }
+
+    /**
+     * Is the column fixed-length?
+     *
+     * @return bool
+     */
+    public function isFixed(): bool
+    {
+        return $this->getFixed() === true;
+    }
+
+    /**
+     * Gets all allowed options. Each option must have a corresponding `setFoo` method.
+     *
+     * @return array
+     */
+    protected function getValidOptions(): array
+    {
+        return [
+            'name',
+            'length',
+            'precision',
+            'default',
+            'null',
+            'identity',
+            'after',
+            'onUpdate',
+            'comment',
+            'unsigned',
+            'type',
+            'properties',
+            'collate',
+            'srid',
+            'geometryType',
+            'increment',
+            'generated',
+            'fixed',
+        ];
+    }
+
+    /**
+     * Utility method that maps an array of column attributes to this object's methods.
+     *
+     * @param array $attributes Attributes
+     * @throws \RuntimeException
+     * @return $this
+     */
+    public function setAttributes(array $attributes)
+    {
+        $validOptions = $this->getValidOptions();
+        if (isset($attributes['identity']) && $attributes['identity'] && !isset($attributes['null'])) {
+            $attributes['null'] = false;
+        }
+
+        foreach ($attributes as $attribute => $value) {
+            if (!in_array($attribute, $validOptions, true)) {
+                throw new RuntimeException(sprintf('"%s" is not a valid column option.', $attribute));
+            }
+
+            $method = 'set' . ucfirst($attribute);
+            $this->$method($value);
+        }
+
+        return $this;
+    }
+
+    /**
+     * Convert an index into an array that is compatible with the Column constructor.
+     *
+     * @return array{name: ?string, baseType: ?string, type: string, length: ?int, null: ?bool, default: mixed, generated: ?string, unsigned: ?bool, onUpdate: ?string, collate: ?string, precision: ?int, srid: ?int, comment: ?string, autoIncrement: bool, identity: bool, fixed: ?bool, geometryType?: ?string}
+     */
+    public function toArray(): array
+    {
+        $type = $this->getType();
+        $length = $this->getLength();
+        $precision = $this->getPrecision();
+        if ($precision !== null && $precision > 0) {
+            if ($type === TableSchemaInterface::TYPE_TIMESTAMP) {
+                $type = 'timestampfractional';
+            } elseif ($type === TableSchemaInterface::TYPE_DATETIME) {
+                $type = 'datetimefractional';
+            }
+        }
+
+        $result = [
+            'name' => $this->getName(),
+            'baseType' => $this->getBaseType(),
+            'type' => $type,
+            'length' => $length,
+            'null' => $this->getNull(),
+            'default' => $this->getDefault(),
+            'generated' => $this->getGenerated(),
+            'unsigned' => $this->getUnsigned(),
+            'onUpdate' => $this->getOnUpdate(),
+            'collate' => $this->getCollate(),
+            'precision' => $precision,
+            'srid' => $this->getSrid(),
+            'comment' => $this->getComment(),
+            'autoIncrement' => $this->getIdentity(),
+            'identity' => $this->getIdentity(),
+            'fixed' => $this->getFixed(),
+        ];
+
+        // Only include geometryType when set (for PostGIS reflection)
+        if ($this->getGeometryType() !== null) {
+            $result['geometryType'] = $this->getGeometryType();
+        }
+
+        return $result;
+    }
+}
diff --git a/src/Database/Schema/Constraint.php b/src/Database/Schema/Constraint.php
new file mode 100644
index 00000000000..b695f73493c
--- /dev/null
+++ b/src/Database/Schema/Constraint.php
@@ -0,0 +1,145 @@
+ $columns The columns to constraint.
+     * @param string $type The type of constraint, e.g. 'unique', 'primary'.
+     */
+    public function __construct(
+        protected string $name,
+        protected array $columns,
+        protected string $type,
+    ) {
+    }
+
+    /**
+     * Sets the constraint columns.
+     *
+     * @param array|string $columns Columns
+     * @return $this
+     */
+    public function setColumns(string|array $columns)
+    {
+        $this->columns = (array)$columns;
+
+        return $this;
+    }
+
+    /**
+     * Gets the constraint columns.
+     *
+     * @return ?array
+     */
+    public function getColumns(): ?array
+    {
+        return $this->columns;
+    }
+
+    /**
+     * Sets the constraint type.
+     *
+     * @param string $type Type
+     * @return $this
+     */
+    public function setType(string $type)
+    {
+        $this->type = $type;
+
+        return $this;
+    }
+
+    /**
+     * Gets the constraint type.
+     *
+     * @return string
+     */
+    public function getType(): string
+    {
+        return $this->type;
+    }
+
+    /**
+     * Sets the constraint name.
+     *
+     * @param string $name Name
+     * @return $this
+     */
+    public function setName(string $name)
+    {
+        $this->name = $name;
+
+        return $this;
+    }
+
+    /**
+     * Gets the constraint name.
+     *
+     * @return ?string
+     */
+    public function getName(): ?string
+    {
+        return $this->name;
+    }
+
+    /**
+     * Converts a constraint to an array that is compatible
+     * with the constructor.
+     *
+     * @return array
+     */
+    public function toArray(): array
+    {
+        return [
+            'name' => $this->name,
+            'type' => $this->type,
+            'columns' => $this->columns,
+        ];
+    }
+}
diff --git a/src/Database/Schema/ForeignKey.php b/src/Database/Schema/ForeignKey.php
new file mode 100644
index 00000000000..2f6aadc0d77
--- /dev/null
+++ b/src/Database/Schema/ForeignKey.php
@@ -0,0 +1,267 @@
+
+     */
+    protected array $validActions = [
+        self::CASCADE,
+        self::RESTRICT,
+        self::SET_NULL,
+        self::NO_ACTION,
+        self::SET_DEFAULT,
+    ];
+
+    /**
+     * The action to take when the referenced row is deleted.
+     */
+    protected ?string $delete = null;
+
+    /**
+     * The action to take when the referenced row is updated.
+     */
+    protected ?string $update = null;
+
+    /**
+     * @var string|null
+     */
+    protected ?string $deferrable = null;
+
+    /**
+     * Constructor
+     *
+     * @param string $name The name of the index.
+     * @param array $columns The columns to index.
+     * @param ?string $referencedTable The columns to index.
+     * @param array $referencedColumns The columns in $referencedTable that this key references.
+     * @param ?string $delete The action to take when the referenced row is deleted.
+     * @param ?string $update The action to take when the referenced row is updated.
+     */
+    public function __construct(
+        protected string $name,
+        protected array $columns,
+        protected ?string $referencedTable = null,
+        protected array $referencedColumns = [],
+        ?string $delete = null,
+        ?string $update = null,
+        ?string $deferrable = null,
+    ) {
+        $this->type = self::FOREIGN;
+        $this->delete = $this->normalizeAction($delete ?? self::NO_ACTION);
+        $this->update = $this->normalizeAction($update ?? self::NO_ACTION);
+        if ($deferrable) {
+            $this->deferrable = $this->normalizeDeferrable($deferrable);
+        }
+    }
+
+    /**
+     * Sets the foreign key referenced table.
+     *
+     * @param string $table The table this KEY is pointing to
+     * @return $this
+     */
+    public function setReferencedTable(string $table)
+    {
+        $this->referencedTable = $table;
+
+        return $this;
+    }
+
+    /**
+     * Gets the foreign key referenced table.
+     *
+     * @return ?string
+     */
+    public function getReferencedTable(): ?string
+    {
+        return $this->referencedTable;
+    }
+
+    /**
+     * Sets the foreign key referenced columns.
+     *
+     * @param array|string $referencedColumns Referenced columns
+     * @return $this
+     */
+    public function setReferencedColumns(array|string $referencedColumns)
+    {
+        $referencedColumns = is_string($referencedColumns) ? [$referencedColumns] : $referencedColumns;
+        $this->referencedColumns = $referencedColumns;
+
+        return $this;
+    }
+
+    /**
+     * Gets the foreign key referenced columns.
+     *
+     * @return array
+     */
+    public function getReferencedColumns(): array
+    {
+        return $this->referencedColumns;
+    }
+
+    /**
+     * Converts the foreign key to an array that is compatible
+     * with the constructor.
+     *
+     * @return array
+     */
+    public function toArray(): array
+    {
+        return [
+            'name' => $this->name,
+            'type' => $this->type,
+            'columns' => $this->columns,
+            'referencedTable' => $this->referencedTable,
+            'referencedColumns' => $this->referencedColumns,
+            'delete' => $this->delete,
+            'update' => $this->update,
+            'deferrable' => $this->deferrable,
+        ];
+    }
+
+    /**
+     * Sets ON DELETE action for the foreign key.
+     *
+     * @param string $delete On Delete
+     * @return $this
+     */
+    public function setDelete(string $delete)
+    {
+        $this->delete = $this->normalizeAction($delete);
+
+        return $this;
+    }
+
+    /**
+     * Gets ON DELETE action for the foreign key.
+     *
+     * @return string|null
+     */
+    public function getDelete(): ?string
+    {
+        return $this->delete;
+    }
+
+    /**
+     * Gets ON UPDATE action for the foreign key.
+     *
+     * @return string|null
+     */
+    public function getUpdate(): ?string
+    {
+        return $this->update;
+    }
+
+    /**
+     * Sets ON UPDATE action for the foreign key.
+     *
+     * @param string $update On Update
+     * @return $this
+     */
+    public function setUpdate(string $update)
+    {
+        $this->update = $this->normalizeAction($update);
+
+        return $this;
+    }
+
+    /**
+     * From passed value checks if it's correct and fixes if needed
+     *
+     * @param string $action Action
+     * @throws \InvalidArgumentException
+     * @return string
+     */
+    protected function normalizeAction(string $action): string
+    {
+        if (in_array($action, $this->validActions, true)) {
+            return $action;
+        }
+        throw new InvalidArgumentException('Unknown action passed: ' . $action);
+    }
+
+    /**
+     * Sets deferrable mode for the foreign key.
+     *
+     * @param string $deferrable Constraint
+     * @return $this
+     */
+    public function setDeferrable(string $deferrable)
+    {
+        $this->deferrable = $this->normalizeDeferrable($deferrable);
+
+        return $this;
+    }
+
+    /**
+     * Gets deferrable mode for the foreign key.
+     */
+    public function getDeferrable(): ?string
+    {
+        return $this->deferrable;
+    }
+
+    /**
+     * From passed value checks if it's correct and fixes if needed
+     *
+     * @param string $deferrable Deferrable
+     * @throws \InvalidArgumentException
+     * @return string
+     */
+    protected function normalizeDeferrable(string $deferrable): string
+    {
+        $mapping = [
+            'DEFERRED' => ForeignKey::DEFERRED,
+            'IMMEDIATE' => ForeignKey::IMMEDIATE,
+            'NOT DEFERRED' => ForeignKey::NOT_DEFERRED,
+            ForeignKey::DEFERRED => ForeignKey::DEFERRED,
+            ForeignKey::IMMEDIATE => ForeignKey::IMMEDIATE,
+            ForeignKey::NOT_DEFERRED => ForeignKey::NOT_DEFERRED,
+        ];
+        $normalized = strtoupper(str_replace('_', ' ', $deferrable));
+        if (array_key_exists($normalized, $mapping)) {
+            return $mapping[$normalized];
+        }
+
+        throw new InvalidArgumentException('Unknown deferrable passed: ' . $deferrable);
+    }
+}
diff --git a/src/Database/Schema/Index.php b/src/Database/Schema/Index.php
new file mode 100644
index 00000000000..18ad143967a
--- /dev/null
+++ b/src/Database/Schema/Index.php
@@ -0,0 +1,341 @@
+ $columns The columns to index.
+     * @param string $type The type of index, e.g. 'index', 'fulltext'.
+     * @param array|int|null $length The length of the index.
+     * @param array|null $order The sort order of the index columns.
+     * @param array|null $include The included columns for covering indexes.
+     * @param ?string $where The where clause for partial indexes.
+     * @param ?string $accessMethod The index access method for PostgreSQL (gin, gist, spgist, brin, hash).
+     */
+    public function __construct(
+        protected string $name,
+        protected array $columns,
+        protected string $type = self::INDEX,
+        protected array|int|null $length = null,
+        protected ?array $order = null,
+        protected ?array $include = null,
+        protected ?string $where = null,
+        protected ?string $accessMethod = null,
+    ) {
+    }
+
+    /**
+     * Re-initializes nullable properties that may be absent when unserializing
+     * index data produced by an older CakePHP version.
+     *
+     * Nullable index attributes were added incrementally (e.g. `include` in 5.3,
+     * `accessMethod` in 5.4). A serialized payload created before a given property
+     * existed does not contain it, and PHP does not apply the promoted default on
+     * the unserialize path. Reading such a property would otherwise fail with
+     * "Typed property ...::$accessMethod must not be accessed before
+     * initialization". This most commonly surfaces via the Migrations plugin's
+     * `schema-dump-*.lock` files during `migrations diff`.
+     *
+     * @return void
+     */
+    public function __wakeup(): void
+    {
+        $this->length ??= null;
+        $this->order ??= null;
+        $this->include ??= null;
+        $this->where ??= null;
+        $this->accessMethod ??= null;
+    }
+
+    /**
+     * Sets the index columns.
+     *
+     * @param array|string $columns Columns
+     * @return $this
+     */
+    public function setColumns(string|array $columns)
+    {
+        $this->columns = (array)$columns;
+
+        return $this;
+    }
+
+    /**
+     * Gets the index columns.
+     *
+     * @return ?array
+     */
+    public function getColumns(): ?array
+    {
+        return $this->columns;
+    }
+
+    /**
+     * Sets the index type.
+     *
+     * @param string $type Type
+     * @return $this
+     */
+    public function setType(string $type)
+    {
+        $this->type = $type;
+
+        return $this;
+    }
+
+    /**
+     * Gets the index type.
+     *
+     * @return string
+     */
+    public function getType(): string
+    {
+        return $this->type;
+    }
+
+    /**
+     * Sets the index name.
+     *
+     * @param string $name Name
+     * @return $this
+     */
+    public function setName(string $name)
+    {
+        $this->name = $name;
+
+        return $this;
+    }
+
+    /**
+     * Gets the index name.
+     *
+     * @return ?string
+     */
+    public function getName(): ?string
+    {
+        return $this->name;
+    }
+
+    /**
+     * Sets the index length.
+     *
+     * In MySQL indexes can have limit clauses to control the number of
+     * characters indexed in text and char columns.
+     *
+     * @param array|int $length length value or array of length value
+     * @return $this
+     */
+    public function setLength(int|array $length)
+    {
+        $this->length = $length;
+
+        return $this;
+    }
+
+    /**
+     * Gets the index length.
+     *
+     * Can be an array of column names and lengths under MySQL.
+     *
+     * @return array|int|null
+     */
+    public function getLength(): array|int|null
+    {
+        return $this->length;
+    }
+
+    /**
+     * Sets the index columns sort order.
+     *
+     * @param array $order column name sort order key value pair
+     * @return $this
+     */
+    public function setOrder(array $order)
+    {
+        $this->order = $order;
+
+        return $this;
+    }
+
+    /**
+     * Gets the index columns sort order.
+     *
+     * @return ?array
+     */
+    public function getOrder(): ?array
+    {
+        return $this->order;
+    }
+
+    /**
+     * Sets the index included columns for a 'covering index'.
+     *
+     * In postgres and sqlserver, indexes can define additional non-key
+     * columns to build 'covering indexes'. This feature allows you to
+     * further optimize well-crafted queries that leverage specific
+     * indexes by reading all data from the index.
+     *
+     * @param array $includedColumns Columns
+     * @return $this
+     */
+    public function setInclude(array $includedColumns)
+    {
+        $this->include = $includedColumns;
+
+        return $this;
+    }
+
+    /**
+     * Gets the index included columns.
+     *
+     * @return ?array
+     */
+    public function getInclude(): ?array
+    {
+        return $this->include;
+    }
+
+    /**
+     * Set the where clause for partial indexes.
+     *
+     * @param ?string $where The where clause for partial indexes.
+     * @return $this
+     */
+    public function setWhere(?string $where)
+    {
+        $this->where = $where;
+
+        return $this;
+    }
+
+    /**
+     * Get the where clause for partial indexes.
+     *
+     * @return ?string
+     */
+    public function getWhere(): ?string
+    {
+        return $this->where;
+    }
+
+    /**
+     * Set the index access method for PostgreSQL.
+     *
+     * PostgreSQL supports multiple index access methods: btree (default),
+     * gin, gist, spgist, brin, and hash.
+     *
+     * @param ?string $accessMethod The access method (gin, gist, spgist, brin, hash).
+     * @return $this
+     */
+    public function setAccessMethod(?string $accessMethod)
+    {
+        $this->accessMethod = $accessMethod;
+
+        return $this;
+    }
+
+    /**
+     * Get the index access method for PostgreSQL.
+     *
+     * @return ?string
+     */
+    public function getAccessMethod(): ?string
+    {
+        return $this->accessMethod;
+    }
+
+    /**
+     * Utility method that maps an array of index options to this object's methods.
+     *
+     * @param array $attributes Attributes to set.
+     * @throws \RuntimeException
+     * @return $this
+     */
+    public function setAttributes(array $attributes)
+    {
+        // Valid Options
+        $validOptions = ['columns', 'type', 'name', 'length', 'order', 'include', 'where', 'accessMethod'];
+        foreach ($attributes as $attr => $value) {
+            if (!in_array($attr, $validOptions, true)) {
+                throw new RuntimeException(sprintf('"%s" is not a valid index option.', $attr));
+            }
+            $method = 'set' . ucfirst($attr);
+            $this->$method($value);
+        }
+
+        return $this;
+    }
+
+    /**
+     * Convert an index into an array that is compatible with the Index constructor.
+     *
+     * @return array
+     */
+    public function toArray(): array
+    {
+        $result = [
+            'name' => $this->getName(),
+            'columns' => $this->getColumns(),
+            'type' => $this->getType(),
+            'length' => $this->getLength(),
+            'order' => $this->getOrder(),
+            'include' => $this->getInclude(),
+            'where' => $this->getWhere(),
+        ];
+        // Only include accessMethod when set (PostgreSQL-specific)
+        if ($this->accessMethod !== null) {
+            $result['accessMethod'] = $this->accessMethod;
+        }
+
+        return $result;
+    }
+}
diff --git a/src/Database/Schema/MysqlSchema.php b/src/Database/Schema/MysqlSchema.php
deleted file mode 100644
index 68ade6356ab..00000000000
--- a/src/Database/Schema/MysqlSchema.php
+++ /dev/null
@@ -1,560 +0,0 @@
-_driver->quoteIdentifier($config['database']), []];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function describeColumnSql($tableName, $config)
-    {
-        return ['SHOW FULL COLUMNS FROM ' . $this->_driver->quoteIdentifier($tableName), []];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function describeIndexSql($tableName, $config)
-    {
-        return ['SHOW INDEXES FROM ' . $this->_driver->quoteIdentifier($tableName), []];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function describeOptionsSql($tableName, $config)
-    {
-        return ['SHOW TABLE STATUS WHERE Name = ?', [$tableName]];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function convertOptionsDescription(TableSchema $schema, $row)
-    {
-        $schema->setOptions([
-            'engine' => $row['Engine'],
-            'collation' => $row['Collation'],
-        ]);
-    }
-
-    /**
-     * Convert a MySQL column type into an abstract type.
-     *
-     * The returned type will be a type that Cake\Database\Type can handle.
-     *
-     * @param string $column The column type + length
-     * @return array Array of column information.
-     * @throws \Cake\Database\Exception When column type cannot be parsed.
-     */
-    protected function _convertColumn($column)
-    {
-        preg_match('/([a-z]+)(?:\(([0-9,]+)\))?\s*([a-z]+)?/i', $column, $matches);
-        if (empty($matches)) {
-            throw new Exception(sprintf('Unable to parse column type from "%s"', $column));
-        }
-
-        $col = strtolower($matches[1]);
-        $length = $precision = null;
-        if (isset($matches[2])) {
-            $length = $matches[2];
-            if (strpos($matches[2], ',') !== false) {
-                list($length, $precision) = explode(',', $length);
-            }
-            $length = (int)$length;
-            $precision = (int)$precision;
-        }
-
-        if (in_array($col, ['date', 'time', 'datetime', 'timestamp'])) {
-            return ['type' => $col, 'length' => null];
-        }
-        if (($col === 'tinyint' && $length === 1) || $col === 'boolean') {
-            return ['type' => TableSchema::TYPE_BOOLEAN, 'length' => null];
-        }
-
-        $unsigned = (isset($matches[3]) && strtolower($matches[3]) === 'unsigned');
-        if (strpos($col, 'bigint') !== false || $col === 'bigint') {
-            return ['type' => TableSchema::TYPE_BIGINTEGER, 'length' => $length, 'unsigned' => $unsigned];
-        }
-        if ($col === 'tinyint') {
-            return ['type' => TableSchema::TYPE_TINYINTEGER, 'length' => $length, 'unsigned' => $unsigned];
-        }
-        if ($col === 'smallint') {
-            return ['type' => TableSchema::TYPE_SMALLINTEGER, 'length' => $length, 'unsigned' => $unsigned];
-        }
-        if (in_array($col, ['int', 'integer', 'mediumint'])) {
-            return ['type' => TableSchema::TYPE_INTEGER, 'length' => $length, 'unsigned' => $unsigned];
-        }
-        if ($col === 'char' && $length === 36) {
-            return ['type' => TableSchema::TYPE_UUID, 'length' => null];
-        }
-        if ($col === 'char') {
-            return ['type' => TableSchema::TYPE_STRING, 'fixed' => true, 'length' => $length];
-        }
-        if (strpos($col, 'char') !== false) {
-            return ['type' => TableSchema::TYPE_STRING, 'length' => $length];
-        }
-        if (strpos($col, 'text') !== false) {
-            $lengthName = substr($col, 0, -4);
-            $length = isset(Table::$columnLengths[$lengthName]) ? Table::$columnLengths[$lengthName] : null;
-
-            return ['type' => TableSchema::TYPE_TEXT, 'length' => $length];
-        }
-        if (strpos($col, 'blob') !== false || $col === 'binary') {
-            $lengthName = substr($col, 0, -4);
-            $length = isset(Table::$columnLengths[$lengthName]) ? Table::$columnLengths[$lengthName] : null;
-
-            return ['type' => TableSchema::TYPE_BINARY, 'length' => $length];
-        }
-        if (strpos($col, 'float') !== false || strpos($col, 'double') !== false) {
-            return [
-                'type' => TableSchema::TYPE_FLOAT,
-                'length' => $length,
-                'precision' => $precision,
-                'unsigned' => $unsigned
-            ];
-        }
-        if (strpos($col, 'decimal') !== false) {
-            return [
-                'type' => TableSchema::TYPE_DECIMAL,
-                'length' => $length,
-                'precision' => $precision,
-                'unsigned' => $unsigned
-            ];
-        }
-
-        if (strpos($col, 'json') !== false) {
-            return ['type' => TableSchema::TYPE_JSON, 'length' => null];
-        }
-
-        return ['type' => TableSchema::TYPE_STRING, 'length' => null];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function convertColumnDescription(TableSchema $schema, $row)
-    {
-        $field = $this->_convertColumn($row['Type']);
-        $field += [
-            'null' => $row['Null'] === 'YES',
-            'default' => $row['Default'],
-            'collate' => $row['Collation'],
-            'comment' => $row['Comment'],
-        ];
-        if (isset($row['Extra']) && $row['Extra'] === 'auto_increment') {
-            $field['autoIncrement'] = true;
-        }
-        $schema->addColumn($row['Field'], $field);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function convertIndexDescription(TableSchema $schema, $row)
-    {
-        $type = null;
-        $columns = $length = [];
-
-        $name = $row['Key_name'];
-        if ($name === 'PRIMARY') {
-            $name = $type = Table::CONSTRAINT_PRIMARY;
-        }
-
-        $columns[] = $row['Column_name'];
-
-        if ($row['Index_type'] === 'FULLTEXT') {
-            $type = Table::INDEX_FULLTEXT;
-        } elseif ($row['Non_unique'] == 0 && $type !== 'primary') {
-            $type = Table::CONSTRAINT_UNIQUE;
-        } elseif ($type !== 'primary') {
-            $type = Table::INDEX_INDEX;
-        }
-
-        if (!empty($row['Sub_part'])) {
-            $length[$row['Column_name']] = $row['Sub_part'];
-        }
-        $isIndex = (
-            $type === Table::INDEX_INDEX ||
-            $type === Table::INDEX_FULLTEXT
-        );
-        if ($isIndex) {
-            $existing = $schema->getIndex($name);
-        } else {
-            $existing = $schema->getConstraint($name);
-        }
-
-        // MySQL multi column indexes come back as multiple rows.
-        if (!empty($existing)) {
-            $columns = array_merge($existing['columns'], $columns);
-            $length = array_merge($existing['length'], $length);
-        }
-        if ($isIndex) {
-            $schema->addIndex($name, [
-                'type' => $type,
-                'columns' => $columns,
-                'length' => $length
-            ]);
-        } else {
-            $schema->addConstraint($name, [
-                'type' => $type,
-                'columns' => $columns,
-                'length' => $length
-            ]);
-        }
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function describeForeignKeySql($tableName, $config)
-    {
-        $sql = 'SELECT * FROM information_schema.key_column_usage AS kcu
-            INNER JOIN information_schema.referential_constraints AS rc
-            ON (
-                kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
-                AND kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
-            )
-            WHERE kcu.TABLE_SCHEMA = ? AND kcu.TABLE_NAME = ? AND rc.TABLE_NAME = ?';
-
-        return [$sql, [$config['database'], $tableName, $tableName]];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function convertForeignKeyDescription(TableSchema $schema, $row)
-    {
-        $data = [
-            'type' => Table::CONSTRAINT_FOREIGN,
-            'columns' => [$row['COLUMN_NAME']],
-            'references' => [$row['REFERENCED_TABLE_NAME'], $row['REFERENCED_COLUMN_NAME']],
-            'update' => $this->_convertOnClause($row['UPDATE_RULE']),
-            'delete' => $this->_convertOnClause($row['DELETE_RULE']),
-        ];
-        $name = $row['CONSTRAINT_NAME'];
-        $schema->addConstraint($name, $data);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function truncateTableSql(TableSchema $schema)
-    {
-        return [sprintf('TRUNCATE TABLE `%s`', $schema->name())];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function createTableSql(TableSchema $schema, $columns, $constraints, $indexes)
-    {
-        $content = implode(",\n", array_merge($columns, $constraints, $indexes));
-        $temporary = $schema->isTemporary() ? ' TEMPORARY ' : ' ';
-        $content = sprintf("CREATE%sTABLE `%s` (\n%s\n)", $temporary, $schema->name(), $content);
-        $options = $schema->getOptions();
-        if (isset($options['engine'])) {
-            $content .= sprintf(' ENGINE=%s', $options['engine']);
-        }
-        if (isset($options['charset'])) {
-            $content .= sprintf(' DEFAULT CHARSET=%s', $options['charset']);
-        }
-        if (isset($options['collate'])) {
-            $content .= sprintf(' COLLATE=%s', $options['collate']);
-        }
-
-        return [$content];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function columnSql(TableSchema $schema, $name)
-    {
-        $data = $schema->getColumn($name);
-        $out = $this->_driver->quoteIdentifier($name);
-        $nativeJson = $this->_driver->supportsNativeJson();
-
-        $typeMap = [
-            TableSchema::TYPE_TINYINTEGER => ' TINYINT',
-            TableSchema::TYPE_SMALLINTEGER => ' SMALLINT',
-            TableSchema::TYPE_INTEGER => ' INTEGER',
-            TableSchema::TYPE_BIGINTEGER => ' BIGINT',
-            TableSchema::TYPE_BOOLEAN => ' BOOLEAN',
-            TableSchema::TYPE_FLOAT => ' FLOAT',
-            TableSchema::TYPE_DECIMAL => ' DECIMAL',
-            TableSchema::TYPE_DATE => ' DATE',
-            TableSchema::TYPE_TIME => ' TIME',
-            TableSchema::TYPE_DATETIME => ' DATETIME',
-            TableSchema::TYPE_TIMESTAMP => ' TIMESTAMP',
-            TableSchema::TYPE_UUID => ' CHAR(36)',
-            TableSchema::TYPE_JSON => $nativeJson ? ' JSON' : ' LONGTEXT'
-        ];
-        $specialMap = [
-            'string' => true,
-            'text' => true,
-            'binary' => true,
-        ];
-        if (isset($typeMap[$data['type']])) {
-            $out .= $typeMap[$data['type']];
-        }
-        if (isset($specialMap[$data['type']])) {
-            switch ($data['type']) {
-                case TableSchema::TYPE_STRING:
-                    $out .= !empty($data['fixed']) ? ' CHAR' : ' VARCHAR';
-                    if (!isset($data['length'])) {
-                        $data['length'] = 255;
-                    }
-                    break;
-                case TableSchema::TYPE_TEXT:
-                    $isKnownLength = in_array($data['length'], Table::$columnLengths);
-                    if (empty($data['length']) || !$isKnownLength) {
-                        $out .= ' TEXT';
-                        break;
-                    }
-
-                    if ($isKnownLength) {
-                        $length = array_search($data['length'], Table::$columnLengths);
-                        $out .= ' ' . strtoupper($length) . 'TEXT';
-                    }
-
-                    break;
-                case TableSchema::TYPE_BINARY:
-                    $isKnownLength = in_array($data['length'], Table::$columnLengths);
-                    if (empty($data['length']) || !$isKnownLength) {
-                        $out .= ' BLOB';
-                        break;
-                    }
-
-                    if ($isKnownLength) {
-                        $length = array_search($data['length'], Table::$columnLengths);
-                        $out .= ' ' . strtoupper($length) . 'BLOB';
-                    }
-
-                    break;
-            }
-        }
-        $hasLength = [
-            TableSchema::TYPE_INTEGER,
-            TableSchema::TYPE_SMALLINTEGER,
-            TableSchema::TYPE_TINYINTEGER,
-            TableSchema::TYPE_STRING
-        ];
-        if (in_array($data['type'], $hasLength, true) && isset($data['length'])) {
-            $out .= '(' . (int)$data['length'] . ')';
-        }
-
-        $hasPrecision = [TableSchema::TYPE_FLOAT, TableSchema::TYPE_DECIMAL];
-        if (in_array($data['type'], $hasPrecision, true) &&
-            (isset($data['length']) || isset($data['precision']))
-        ) {
-            $out .= '(' . (int)$data['length'] . ',' . (int)$data['precision'] . ')';
-        }
-
-        $hasUnsigned = [
-            TableSchema::TYPE_TINYINTEGER,
-            TableSchema::TYPE_SMALLINTEGER,
-            TableSchema::TYPE_INTEGER,
-            TableSchema::TYPE_BIGINTEGER,
-            TableSchema::TYPE_FLOAT,
-            TableSchema::TYPE_DECIMAL
-        ];
-        if (in_array($data['type'], $hasUnsigned, true) &&
-            isset($data['unsigned']) && $data['unsigned'] === true
-        ) {
-            $out .= ' UNSIGNED';
-        }
-
-        $hasCollate = [
-            TableSchema::TYPE_TEXT,
-            TableSchema::TYPE_STRING,
-        ];
-        if (in_array($data['type'], $hasCollate, true) && isset($data['collate']) && $data['collate'] !== '') {
-            $out .= ' COLLATE ' . $data['collate'];
-        }
-
-        if (isset($data['null']) && $data['null'] === false) {
-            $out .= ' NOT NULL';
-        }
-        $addAutoIncrement = (
-            [$name] == (array)$schema->primaryKey() &&
-            !$schema->hasAutoincrement() &&
-            !isset($data['autoIncrement'])
-        );
-        if (in_array($data['type'], [TableSchema::TYPE_INTEGER, TableSchema::TYPE_BIGINTEGER]) &&
-            ($data['autoIncrement'] === true || $addAutoIncrement)
-        ) {
-            $out .= ' AUTO_INCREMENT';
-        }
-        if (isset($data['null']) && $data['null'] === true && $data['type'] === TableSchema::TYPE_TIMESTAMP) {
-            $out .= ' NULL';
-            unset($data['default']);
-        }
-        if (isset($data['default']) &&
-            in_array($data['type'], [TableSchema::TYPE_TIMESTAMP, TableSchema::TYPE_DATETIME]) &&
-            in_array(strtolower($data['default']), ['current_timestamp', 'current_timestamp()'])
-        ) {
-            $out .= ' DEFAULT CURRENT_TIMESTAMP';
-            unset($data['default']);
-        }
-        if (isset($data['default'])) {
-            $out .= ' DEFAULT ' . $this->_driver->schemaValue($data['default']);
-            unset($data['default']);
-        }
-        if (isset($data['comment']) && $data['comment'] !== '') {
-            $out .= ' COMMENT ' . $this->_driver->schemaValue($data['comment']);
-        }
-
-        return $out;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function constraintSql(TableSchema $schema, $name)
-    {
-        $data = $schema->getConstraint($name);
-        if ($data['type'] === Table::CONSTRAINT_PRIMARY) {
-            $columns = array_map(
-                [$this->_driver, 'quoteIdentifier'],
-                $data['columns']
-            );
-
-            return sprintf('PRIMARY KEY (%s)', implode(', ', $columns));
-        }
-
-        $out = '';
-        if ($data['type'] === Table::CONSTRAINT_UNIQUE) {
-            $out = 'UNIQUE KEY ';
-        }
-        if ($data['type'] === Table::CONSTRAINT_FOREIGN) {
-            $out = 'CONSTRAINT ';
-        }
-        $out .= $this->_driver->quoteIdentifier($name);
-
-        return $this->_keySql($out, $data);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function addConstraintSql(TableSchema $schema)
-    {
-        $sqlPattern = 'ALTER TABLE %s ADD %s;';
-        $sql = [];
-
-        foreach ($schema->constraints() as $name) {
-            $constraint = $schema->getConstraint($name);
-            if ($constraint['type'] === Table::CONSTRAINT_FOREIGN) {
-                $tableName = $this->_driver->quoteIdentifier($schema->name());
-                $sql[] = sprintf($sqlPattern, $tableName, $this->constraintSql($schema, $name));
-            }
-        }
-
-        return $sql;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function dropConstraintSql(TableSchema $schema)
-    {
-        $sqlPattern = 'ALTER TABLE %s DROP FOREIGN KEY %s;';
-        $sql = [];
-
-        foreach ($schema->constraints() as $name) {
-            $constraint = $schema->getConstraint($name);
-            if ($constraint['type'] === Table::CONSTRAINT_FOREIGN) {
-                $tableName = $this->_driver->quoteIdentifier($schema->name());
-                $constraintName = $this->_driver->quoteIdentifier($name);
-                $sql[] = sprintf($sqlPattern, $tableName, $constraintName);
-            }
-        }
-
-        return $sql;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function indexSql(TableSchema $schema, $name)
-    {
-        $data = $schema->getIndex($name);
-        $out = '';
-        if ($data['type'] === Table::INDEX_INDEX) {
-            $out = 'KEY ';
-        }
-        if ($data['type'] === Table::INDEX_FULLTEXT) {
-            $out = 'FULLTEXT KEY ';
-        }
-        $out .= $this->_driver->quoteIdentifier($name);
-
-        return $this->_keySql($out, $data);
-    }
-
-    /**
-     * Helper method for generating key SQL snippets.
-     *
-     * @param string $prefix The key prefix
-     * @param array $data Key data.
-     * @return string
-     */
-    protected function _keySql($prefix, $data)
-    {
-        $columns = array_map(
-            [$this->_driver, 'quoteIdentifier'],
-            $data['columns']
-        );
-        foreach ($data['columns'] as $i => $column) {
-            if (isset($data['length'][$column])) {
-                $columns[$i] .= sprintf('(%d)', $data['length'][$column]);
-            }
-        }
-        if ($data['type'] === Table::CONSTRAINT_FOREIGN) {
-            return $prefix . sprintf(
-                ' FOREIGN KEY (%s) REFERENCES %s (%s) ON UPDATE %s ON DELETE %s',
-                implode(', ', $columns),
-                $this->_driver->quoteIdentifier($data['references'][0]),
-                $this->_convertConstraintColumns($data['references'][1]),
-                $this->_foreignOnClause($data['update']),
-                $this->_foreignOnClause($data['delete'])
-            );
-        }
-
-        return $prefix . ' (' . implode(', ', $columns) . ')';
-    }
-}
diff --git a/src/Database/Schema/MysqlSchemaDialect.php b/src/Database/Schema/MysqlSchemaDialect.php
new file mode 100644
index 00000000000..0c778a6bebf
--- /dev/null
+++ b/src/Database/Schema/MysqlSchemaDialect.php
@@ -0,0 +1,1076 @@
+ $config The connection configuration to use for
+     *    getting tables from.
+     * @return array An array of (sql, params) to execute.
+     */
+    public function listTablesSql(array $config): array
+    {
+        return [
+            'SHOW FULL TABLES FROM ' . $this->_driver->quoteIdentifier($config['database'])
+            . " WHERE TABLE_TYPE IN ('BASE TABLE', 'VIEW')"
+            , []];
+    }
+
+    /**
+     * Generate the SQL to list the tables, excluding all views.
+     *
+     * @param array $config The connection configuration to use for
+     *    getting tables from.
+     * @return array An array of (sql, params) to execute.
+     */
+    public function listTablesWithoutViewsSql(array $config): array
+    {
+        return [
+            'SHOW FULL TABLES FROM ' . $this->_driver->quoteIdentifier($config['database'])
+            . ' WHERE TABLE_TYPE = "BASE TABLE"'
+        , []];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeColumnSql(string $tableName, array $config): array
+    {
+        $sql = $this->describeColumnQuery($tableName);
+
+        return [$sql, []];
+    }
+
+    /**
+     * Helper method for creating SQL to describe columns in a table.
+     *
+     * @param string $tableName The table to describe.
+     * @return string SQL to reflect columns
+     */
+    private function describeColumnQuery(string $tableName): string
+    {
+        return 'SHOW FULL COLUMNS FROM ' . $this->_driver->quoteIdentifier($tableName);
+    }
+
+    /**
+     * Split a table name into a tuple of database, table
+     * If the table does not have a database name included, the connection
+     * database will be used.
+     *
+     * @param string $tableName The table name to split
+     * @return array A tuple of [database, tablename]
+     */
+    private function splitTableName(string $tableName): array
+    {
+        $config = $this->_driver->config();
+        $db = $config['database'];
+        if (str_contains($tableName, '.')) {
+            return explode('.', $tableName);
+        }
+
+        return [$db, $tableName];
+    }
+
+    /**
+     * Get a list of column metadata as a array
+     *
+     * Each item in the array will contain the following:
+     *
+     * - name : the name of the column.
+     * - type : the abstract type of the column.
+     * - length : the length of the column.
+     * - default : the default value of the column or null.
+     * - null : boolean indicating whether the column can be null.
+     * - comment : the column comment or null.
+     *
+     * The following keys will be set as required:
+     *
+     * - autoIncrement : set for columns that are an integer primary key.
+     * - onUpdate : set for datetime/timestamp columns with `ON UPDATE` clauses.
+     *
+     * @param string $tableName The name of the table to describe columns on.
+     * @return array
+     */
+    public function describeColumns(string $tableName): array
+    {
+        $sql = $this->describeColumnQuery($tableName);
+        try {
+            $rows = $this->_driver->execute($sql)->fetchAll('assoc');
+        } catch (PDOException $e) {
+            throw new DatabaseException("Could not describe columns on `{$tableName}`", null, $e);
+        }
+
+        $geometryColumns = [];
+        if (array_intersect(array_column($rows, 'Type'), TableSchemaInterface::GEOSPATIAL_TYPES)) {
+            $geometryColumns = $this->describeGeometryColumns($tableName);
+        }
+
+        $columns = [];
+        foreach ($rows as $row) {
+            $field = $this->_convertColumn($row['Type']);
+            $default = $this->parseDefault($field['type'], $row);
+
+            $field += [
+                'name' => $row['Field'],
+                'null' => $row['Null'] === 'YES',
+                'default' => $default,
+                'collate' => $row['Collation'],
+                'comment' => $row['Comment'],
+                'length' => null,
+            ];
+            $extra = trim($row['Extra'] ?? '');
+            if ($extra === 'auto_increment') {
+                $field['autoIncrement'] = true;
+            }
+            // Depending on the MySQL Version the extra column can contain/start with DEFAULT_GENERATED as well
+            if (
+                str_ends_with($extra, 'on update CURRENT_TIMESTAMP') ||
+                str_ends_with($extra, 'on update current_timestamp()')
+            ) {
+                $field['onUpdate'] = 'CURRENT_TIMESTAMP';
+            }
+
+            $srid = $geometryColumns[$field['name']]['srid'] ?? null;
+            if ($srid !== null) {
+                $field['srid'] = $srid;
+            }
+
+            $columns[] = $field;
+        }
+
+        return $columns;
+    }
+
+    /**
+     * Describes geometry-specific column information.
+     *
+     * @param string $table The table name.
+     * @return array The column information.
+     */
+    private function describeGeometryColumns(string $table): array
+    {
+        /** @var \Cake\Database\Driver\Mysql $driver */
+        $driver = $this->_driver;
+
+        if (!$driver->isMariaDb() && version_compare($driver->version(), '8.0.1', '>=')) {
+            $sql = <<config()['database'];
+        $columns = $this->_driver->execute($sql, [$table, $schema])->fetchAll('assoc');
+
+        return array_combine(array_column($columns, 'name'), $columns);
+    }
+
+    /**
+     * Parse the default value if required.
+     *
+     * @param string $type The type of column
+     * @param array $row a Row of schema reflection data
+     * @return ?string The default value of a column.
+     */
+    protected function parseDefault(string $type, array $row): ?string
+    {
+        $default = $row['Default'];
+        if (
+            is_string($default) &&
+            in_array(
+                $type,
+                array_merge(
+                    TableSchema::GEOSPATIAL_TYPES,
+                    [TableSchema::TYPE_BINARY, TableSchema::TYPE_JSON, TableSchema::TYPE_TEXT],
+                ),
+                true,
+            )
+        ) {
+            // The default that comes back from MySQL for these types prefixes the collation type and
+            // surrounds the value with escaped single quotes, for example "_utf8mbf4\'abc\'", and so
+            // this converts that then down to the default value of "abc" to correspond to what the user
+            // would have specified in a migration.
+            $default = (string)preg_replace("/^_(?:[a-zA-Z0-9]+?)\\\'(.*)\\\'$/", '\1', $default);
+
+            // If the default is wrapped in a function, and has a collation marker on it, strip
+            // the collation marker out
+            $default = (string)preg_replace(
+                "/^(?[a-zA-Z0-9_]*\()(?_[a-zA-Z0-9]+)\\\'(?.*)\\\'\)$/",
+                "\\1'\\3')",
+                $default,
+            );
+        }
+
+        if (
+            $this->_driver instanceof Mysql &&
+            $this->_driver->isMariaDb() &&
+            $default === 'current_timestamp()'
+        ) {
+            return 'CURRENT_TIMESTAMP';
+        }
+
+        return $default;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeIndexSql(string $tableName, array $config): array
+    {
+        $sql = $this->describeIndexQuery($tableName);
+
+        return [$sql, []];
+    }
+
+    /**
+     * Helper method for creating SQL to reflect indexes in a table.
+     *
+     * @param string $tableName The table to get indexes from.
+     * @return string SQL to reflect indexes
+     */
+    private function describeIndexQuery(string $tableName): string
+    {
+        return 'SHOW INDEXES FROM ' . $this->_driver->quoteIdentifier($tableName);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeIndexes(string $tableName): array
+    {
+        $sql = $this->describeIndexQuery($tableName);
+        $statement = $this->_driver->execute($sql);
+        $indexes = [];
+
+        foreach ($statement->fetchAll('assoc') as $row) {
+            $name = $row['Key_name'];
+            $type = null;
+            if ($name === 'PRIMARY') {
+                $name = TableSchema::CONSTRAINT_PRIMARY;
+                $type = TableSchema::CONSTRAINT_PRIMARY;
+            }
+            if ($row['Index_type'] === 'FULLTEXT') {
+                $type = TableSchema::INDEX_FULLTEXT;
+            } elseif ((int)$row['Non_unique'] === 0 && $type !== TableSchema::CONSTRAINT_PRIMARY) {
+                $type = TableSchema::CONSTRAINT_UNIQUE;
+            } elseif ($type !== TableSchema::CONSTRAINT_PRIMARY) {
+                $type = TableSchema::INDEX_INDEX;
+            }
+            if (!isset($indexes[$name])) {
+                $indexes[$name] = [
+                    'name' => $name,
+                    'type' => $type,
+                    'columns' => [],
+                    'length' => [],
+                ];
+            }
+            // conditional indexes can have null columns
+            if ($row['Column_name'] !== null) {
+                $indexes[$name]['columns'][] = $row['Column_name'];
+            }
+            if (!empty($row['Sub_part'])) {
+                $indexes[$name]['length'][$row['Column_name']] = $row['Sub_part'];
+            }
+        }
+
+        return array_values($indexes);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeOptionsSql(string $tableName, array $config): array
+    {
+        return ['SHOW TABLE STATUS WHERE Name = ?', [$tableName]];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function convertOptionsDescription(TableSchema $schema, array $row): void
+    {
+        $schema->setOptions([
+            'engine' => $row['Engine'],
+            'collation' => $row['Collation'],
+        ]);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeOptions(string $tableName): array
+    {
+        [, $name] = $this->splitTableName($tableName);
+        $sql = 'SHOW TABLE STATUS WHERE Name = ?';
+        $statement = $this->_driver->execute($sql, [$name]);
+        $row = $statement->fetch('assoc');
+
+        return [
+            'engine' => $row['Engine'],
+            'collation' => $row['Collation'],
+        ];
+    }
+
+    /**
+     * Convert a MySQL column type into an abstract type.
+     *
+     * The returned type will be a type that Cake\Database\TypeFactory can handle.
+     *
+     * @param string $column The column type + length
+     * @return array Array of column information.
+     * @throws \Cake\Database\Exception\DatabaseException When column type cannot be parsed.
+     */
+    protected function _convertColumn(string $column): array
+    {
+        preg_match('/([a-z]+)(?:\(([0-9,]+)\))?\s*([a-z]+)?/i', $column, $matches);
+        if (!$matches) {
+            throw new DatabaseException(sprintf('Unable to parse column type from `%s`', $column));
+        }
+
+        $col = strtolower($matches[1]);
+        $length = null;
+        $precision = null;
+        $scale = null;
+        if (isset($matches[2]) && strlen($matches[2])) {
+            $length = $matches[2];
+            if (str_contains($matches[2], ',')) {
+                [$length, $precision] = explode(',', $length);
+            }
+            $length = (int)$length;
+            $precision = (int)$precision;
+        }
+
+        $type = $this->_applyTypeSpecificColumnConversion(
+            $col,
+            compact('length', 'precision', 'scale'),
+        );
+        if ($type !== null) {
+            return $type;
+        }
+
+        if (in_array($col, ['date', 'time', 'year'], true)) {
+            return ['type' => $col, 'length' => null];
+        }
+        if (in_array($col, ['datetime', 'timestamp'], true)) {
+            $typeName = $col;
+            if ($length > 0) {
+                $typeName = $col . 'fractional';
+            }
+
+            return ['type' => $typeName, 'length' => null, 'precision' => $length];
+        }
+
+        if (($col === 'tinyint' && $length === 1) || $col === 'boolean') {
+            return ['type' => TableSchemaInterface::TYPE_BOOLEAN, 'length' => null];
+        }
+
+        if ($col === 'bit') {
+            return ['type' => TableSchemaInterface::TYPE_BIT, 'length' => $length];
+        }
+
+        $unsigned = (isset($matches[3]) && strtolower($matches[3]) === 'unsigned');
+        if (str_contains($col, 'bigint')) {
+            return ['type' => TableSchemaInterface::TYPE_BIGINTEGER, 'length' => null, 'unsigned' => $unsigned];
+        }
+        if ($col === 'tinyint') {
+            return ['type' => TableSchemaInterface::TYPE_TINYINTEGER, 'length' => null, 'unsigned' => $unsigned];
+        }
+        if ($col === 'smallint') {
+            return ['type' => TableSchemaInterface::TYPE_SMALLINTEGER, 'length' => null, 'unsigned' => $unsigned];
+        }
+        if (in_array($col, ['int', 'integer', 'mediumint'], true)) {
+            return ['type' => TableSchemaInterface::TYPE_INTEGER, 'length' => null, 'unsigned' => $unsigned];
+        }
+        if ($col === 'char' && $length === 36) {
+            return ['type' => TableSchemaInterface::TYPE_UUID, 'length' => null];
+        }
+        if ($col === 'char') {
+            return ['type' => TableSchemaInterface::TYPE_CHAR, 'length' => $length];
+        }
+        if (str_contains($col, 'char')) {
+            return ['type' => TableSchemaInterface::TYPE_STRING, 'length' => $length];
+        }
+        if (str_contains($col, 'text')) {
+            $lengthName = substr($col, 0, -4);
+            $length = TableSchema::$columnLengths[$lengthName] ?? null;
+
+            return ['type' => TableSchemaInterface::TYPE_TEXT, 'length' => $length];
+        }
+        if ($col === 'binary' && $length === 16) {
+            return ['type' => TableSchemaInterface::TYPE_BINARY_UUID, 'length' => null];
+        }
+        if ($col === 'uuid') {
+            return ['type' => TableSchemaInterface::TYPE_NATIVE_UUID, 'length' => null];
+        }
+        if (str_contains($col, 'blob') || in_array($col, ['binary', 'varbinary'], true)) {
+            $lengthName = substr($col, 0, -4);
+            $length = TableSchema::$columnLengths[$lengthName] ?? $length;
+
+            $result = ['type' => TableSchemaInterface::TYPE_BINARY, 'length' => $length];
+            if ($col === 'binary') {
+                $result['fixed'] = true;
+            }
+
+            return $result;
+        }
+        if (str_contains($col, 'float') || str_contains($col, 'double')) {
+            return [
+                'type' => TableSchemaInterface::TYPE_FLOAT,
+                'length' => $length,
+                'precision' => $precision,
+                'unsigned' => $unsigned,
+            ];
+        }
+        if (str_contains($col, 'decimal')) {
+            return [
+                'type' => TableSchemaInterface::TYPE_DECIMAL,
+                'length' => $length,
+                'precision' => $precision,
+                'unsigned' => $unsigned,
+            ];
+        }
+
+        if (str_contains($col, 'json')) {
+            return ['type' => TableSchemaInterface::TYPE_JSON, 'length' => null];
+        }
+        if (in_array($col, TableSchemaInterface::GEOSPATIAL_TYPES, true)) {
+            // TODO how can srid be preserved? It doesn't come back
+            // in the output of show full columns from ...
+            return [
+                'type' => $col,
+                'length' => null,
+            ];
+        }
+
+        return ['type' => TableSchemaInterface::TYPE_STRING, 'length' => null];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function convertColumnDescription(TableSchema $schema, array $row): void
+    {
+        $field = $this->_convertColumn($row['Type']);
+        $default = $this->parseDefault($field['type'], $row);
+        $field += [
+            'null' => $row['Null'] === 'YES',
+            'default' => $default,
+            'collate' => $row['Collation'],
+            'comment' => $row['Comment'],
+        ];
+        if (isset($row['Extra']) && $row['Extra'] === 'auto_increment') {
+            $field['autoIncrement'] = true;
+        }
+        $schema->addColumn($row['Field'], $field);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function convertIndexDescription(TableSchema $schema, array $row): void
+    {
+        $type = null;
+        $columns = [];
+        $length = [];
+
+        $name = $row['Key_name'];
+        if ($name === 'PRIMARY') {
+            $name = TableSchema::CONSTRAINT_PRIMARY;
+            $type = TableSchema::CONSTRAINT_PRIMARY;
+        }
+
+        if (!empty($row['Column_name'])) {
+            $columns[] = $row['Column_name'];
+        }
+
+        if ($row['Index_type'] === 'FULLTEXT') {
+            $type = TableSchema::INDEX_FULLTEXT;
+        } elseif ((int)$row['Non_unique'] === 0 && $type !== 'primary') {
+            $type = TableSchema::CONSTRAINT_UNIQUE;
+        } elseif ($type !== 'primary') {
+            $type = TableSchema::INDEX_INDEX;
+        }
+
+        if (!empty($row['Sub_part'])) {
+            $length[$row['Column_name']] = $row['Sub_part'];
+        }
+        $isIndex = (
+            $type === TableSchema::INDEX_INDEX ||
+            $type === TableSchema::INDEX_FULLTEXT
+        );
+        if ($isIndex) {
+            $existing = $schema->getIndex($name);
+        } else {
+            $existing = $schema->getConstraint($name);
+        }
+
+        // MySQL multi column indexes come back as multiple rows.
+        if ($existing) {
+            $columns = array_merge($existing['columns'], $columns);
+            $length = array_merge($existing['length'], $length);
+        }
+        if ($isIndex) {
+            $schema->addIndex($name, [
+                'type' => $type,
+                'columns' => $columns,
+                'length' => $length,
+            ]);
+        } else {
+            $schema->addConstraint($name, [
+                'type' => $type,
+                'columns' => $columns,
+                'length' => $length,
+            ]);
+        }
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeForeignKeySql(string $tableName, array $config): array
+    {
+        $sql = 'SELECT * FROM information_schema.key_column_usage AS kcu
+            INNER JOIN information_schema.referential_constraints AS rc
+            ON (
+                kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
+                AND kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
+            )
+            WHERE kcu.TABLE_SCHEMA = ? AND kcu.TABLE_NAME = ? AND rc.TABLE_NAME = ?
+            ORDER BY kcu.ORDINAL_POSITION ASC';
+
+        return [$sql, [$config['database'], $tableName, $tableName]];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function convertForeignKeyDescription(TableSchema $schema, array $row): void
+    {
+        $data = [
+            'type' => TableSchema::CONSTRAINT_FOREIGN,
+            'columns' => [$row['COLUMN_NAME']],
+            'references' => [$row['REFERENCED_TABLE_NAME'], $row['REFERENCED_COLUMN_NAME']],
+            'update' => $this->_convertOnClause($row['UPDATE_RULE']),
+            'delete' => $this->_convertOnClause($row['DELETE_RULE']),
+        ];
+        $name = $row['CONSTRAINT_NAME'];
+        $schema->addConstraint($name, $data);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeForeignKeys(string $tableName): array
+    {
+        [$database, $name] = $this->splitTableName($tableName);
+        $sql = 'SELECT * FROM information_schema.key_column_usage AS kcu
+            INNER JOIN information_schema.referential_constraints AS rc
+            ON (
+                kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
+                AND kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
+            )
+            WHERE kcu.TABLE_SCHEMA = ? AND kcu.TABLE_NAME = ? AND rc.TABLE_NAME = ?
+            ORDER BY kcu.ORDINAL_POSITION ASC';
+        $statement = $this->_driver->execute($sql, [$database, $name, $name]);
+        $keys = [];
+        foreach ($statement->fetchAll('assoc') as $row) {
+            $name = $row['CONSTRAINT_NAME'];
+            if (!isset($keys[$name])) {
+                $keys[$name] = [
+                    'name' => $name,
+                    'type' => TableSchema::CONSTRAINT_FOREIGN,
+                    'columns' => [],
+                    'references' => [$row['REFERENCED_TABLE_NAME'], []],
+                    'update' => $this->_convertOnClause($row['UPDATE_RULE'] ?? ''),
+                    'delete' => $this->_convertOnClause($row['DELETE_RULE'] ?? ''),
+                    'length' => [],
+                ];
+            }
+            // Add the columns incrementally
+            $keys[$name]['columns'][] = $row['COLUMN_NAME'];
+            $keys[$name]['references'][1][] = $row['REFERENCED_COLUMN_NAME'];
+        }
+        foreach ($keys as $id => $key) {
+            if (count($key['references'][1]) === 1) {
+                $keys[$id]['references'][1] = $key['references'][1][0];
+            }
+        }
+
+        return array_values($keys);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeCheckConstraints(string $tableName): array
+    {
+        if (!$this->_driver->supports(DriverFeatureEnum::CHECK_CONSTRAINTS)) {
+            return [];
+        }
+
+        [$schema, $name] = $this->splitTablename($tableName);
+        $sql = <<_driver->execute($sql, [$schema, $name]);
+        foreach ($statement->fetchAll('assoc') as $row) {
+            $constraints[] = [
+                'name' => $row['name'],
+                'type' => TableSchema::CONSTRAINT_CHECK,
+                'expression' => $row['expression'],
+            ];
+        }
+
+        return $constraints;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function truncateTableSql(TableSchema $schema): array
+    {
+        return [sprintf('TRUNCATE TABLE `%s`', $schema->name())];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function createTableSql(TableSchema $schema, array $columns, array $constraints, array $indexes): array
+    {
+        $content = implode(",\n", array_merge($columns, $constraints, $indexes));
+        $temporary = $schema->isTemporary() ? ' TEMPORARY ' : ' ';
+        $content = sprintf("CREATE%sTABLE `%s` (\n%s\n)", $temporary, $schema->name(), $content);
+        $options = $schema->getOptions();
+        if (isset($options['engine'])) {
+            $content .= sprintf(' ENGINE=%s', $options['engine']);
+        }
+        if (isset($options['charset'])) {
+            $content .= sprintf(' DEFAULT CHARSET=%s', $options['charset']);
+        }
+        if (isset($options['collate'])) {
+            $content .= sprintf(' COLLATE=%s', $options['collate']);
+        }
+
+        return [$content];
+    }
+
+    /**
+     * Create a SQL snippet for a column based on the array shape
+     * that `describeColumns()` creates.
+     *
+     * @param array $column The column metadata
+     * @return string Generated SQL fragment for a column
+     */
+    public function columnDefinitionSql(array $column): string
+    {
+        $name = $column['name'];
+        $column += [
+            'length' => null,
+        ];
+
+        $out = $this->_driver->quoteIdentifier($name);
+        $nativeJson = $this->_driver->supports(DriverFeatureEnum::JSON);
+
+        $typeMap = [
+            TableSchemaInterface::TYPE_TINYINTEGER => ' TINYINT',
+            TableSchemaInterface::TYPE_SMALLINTEGER => ' SMALLINT',
+            TableSchemaInterface::TYPE_INTEGER => ' INTEGER',
+            TableSchemaInterface::TYPE_BIGINTEGER => ' BIGINT',
+            TableSchemaInterface::TYPE_BINARY_UUID => ' BINARY(16)',
+            TableSchemaInterface::TYPE_BOOLEAN => ' BOOLEAN',
+            TableSchemaInterface::TYPE_FLOAT => ' FLOAT',
+            TableSchemaInterface::TYPE_DECIMAL => ' DECIMAL',
+            TableSchemaInterface::TYPE_DATE => ' DATE',
+            TableSchemaInterface::TYPE_TIME => ' TIME',
+            TableSchemaInterface::TYPE_DATETIME => ' DATETIME',
+            TableSchemaInterface::TYPE_DATETIME_FRACTIONAL => ' DATETIME',
+            TableSchemaInterface::TYPE_TIMESTAMP => ' TIMESTAMP',
+            TableSchemaInterface::TYPE_TIMESTAMP_FRACTIONAL => ' TIMESTAMP',
+            TableSchemaInterface::TYPE_TIMESTAMP_TIMEZONE => ' TIMESTAMP',
+            TableSchemaInterface::TYPE_CHAR => ' CHAR',
+            TableSchemaInterface::TYPE_UUID => ' CHAR(36)',
+            TableSchemaInterface::TYPE_NATIVE_UUID => ' UUID',
+            TableSchemaInterface::TYPE_JSON => $nativeJson ? ' JSON' : ' LONGTEXT',
+            TableSchemaInterface::TYPE_GEOMETRY => ' GEOMETRY',
+            TableSchemaInterface::TYPE_POINT => ' POINT',
+            TableSchemaInterface::TYPE_LINESTRING => ' LINESTRING',
+            TableSchemaInterface::TYPE_POLYGON => ' POLYGON',
+            TableSchemaInterface::TYPE_BIT => ' BIT',
+        ];
+        $specialMap = [
+            'string' => true,
+            'text' => true,
+            'char' => true,
+            'binary' => true,
+        ];
+        if (isset($typeMap[$column['type']])) {
+            $out .= $typeMap[$column['type']];
+        }
+        if (isset($specialMap[$column['type']])) {
+            switch ($column['type']) {
+                case TableSchemaInterface::TYPE_STRING:
+                    $out .= ' VARCHAR';
+                    if (!isset($column['length'])) {
+                        $column['length'] = 255;
+                    }
+                    break;
+                case TableSchemaInterface::TYPE_TEXT:
+                    $isKnownLength = in_array($column['length'], TableSchema::$columnLengths);
+                    if (empty($column['length']) || !$isKnownLength) {
+                        $out .= ' TEXT';
+                        break;
+                    }
+
+                    $length = array_search($column['length'], TableSchema::$columnLengths);
+                    assert(is_string($length));
+                    $out .= ' ' . strtoupper($length) . 'TEXT';
+
+                    break;
+                case TableSchemaInterface::TYPE_BINARY:
+                    $isKnownLength = in_array($column['length'], TableSchema::$columnLengths);
+                    if ($isKnownLength) {
+                        $length = array_search($column['length'], TableSchema::$columnLengths);
+                        assert(is_string($length));
+                        unset($column['length']);
+                        $out .= ' ' . strtoupper($length) . 'BLOB';
+                        break;
+                    }
+
+                    if (empty($column['length'])) {
+                        $out .= ' BLOB';
+                        break;
+                    }
+
+                    if (!empty($column['fixed'])) {
+                        $out .= ' BINARY';
+                    } else {
+                        $out .= ' VARBINARY';
+                    }
+                    break;
+            }
+        }
+        $hasLength = [
+            TableSchemaInterface::TYPE_INTEGER,
+            TableSchemaInterface::TYPE_CHAR,
+            TableSchemaInterface::TYPE_SMALLINTEGER,
+            TableSchemaInterface::TYPE_TINYINTEGER,
+            TableSchemaInterface::TYPE_STRING,
+            TableSchemaInterface::TYPE_BINARY,
+            TableSchemaInterface::TYPE_BIT,
+        ];
+        if (!isset($typeMap[$column['type']]) && !isset($specialMap[$column['type']])) {
+            $out .= ' ' . strtoupper($column['type']);
+            $hasLength[] = $column['type'];
+        }
+        if (in_array($column['type'], $hasLength, true) && isset($column['length'])) {
+            $out .= '(' . $column['length'] . ')';
+        }
+
+        $lengthAndPrecisionTypes = [
+            TableSchemaInterface::TYPE_FLOAT,
+            TableSchemaInterface::TYPE_DECIMAL,
+        ];
+        if (in_array($column['type'], $lengthAndPrecisionTypes, true) && isset($column['length'])) {
+            if (isset($column['precision'])) {
+                $out .= '(' . (int)$column['length'] . ',' . (int)$column['precision'] . ')';
+            } else {
+                $out .= '(' . (int)$column['length'] . ')';
+            }
+        }
+
+        $precisionTypes = [
+            TableSchemaInterface::TYPE_DATETIME_FRACTIONAL,
+            TableSchemaInterface::TYPE_TIMESTAMP_FRACTIONAL,
+        ];
+        if (in_array($column['type'], $precisionTypes, true) && isset($column['precision'])) {
+            $out .= '(' . (int)$column['precision'] . ')';
+        }
+
+        $hasUnsigned = [
+            TableSchemaInterface::TYPE_TINYINTEGER,
+            TableSchemaInterface::TYPE_SMALLINTEGER,
+            TableSchemaInterface::TYPE_INTEGER,
+            TableSchemaInterface::TYPE_BIGINTEGER,
+            TableSchemaInterface::TYPE_FLOAT,
+            TableSchemaInterface::TYPE_DECIMAL,
+        ];
+        if (
+            in_array($column['type'], $hasUnsigned, true) &&
+            isset($column['unsigned']) &&
+            $column['unsigned'] === true
+        ) {
+            $out .= ' UNSIGNED';
+        }
+
+        $hasCollate = [
+            TableSchemaInterface::TYPE_TEXT,
+            TableSchemaInterface::TYPE_CHAR,
+            TableSchemaInterface::TYPE_STRING,
+            TableSchemaInterface::TYPE_UUID,
+        ];
+        if (in_array($column['type'], $hasCollate, true) && isset($column['collate']) && $column['collate'] !== '') {
+            $out .= ' COLLATE ' . $column['collate'];
+        }
+
+        if (isset($column['null']) && $column['null'] === false) {
+            $out .= ' NOT NULL';
+        }
+
+        if (isset($column['autoIncrement']) && $column['autoIncrement']) {
+            $out .= ' AUTO_INCREMENT';
+            unset($column['default']);
+        }
+
+        $timestampTypes = [
+            TableSchemaInterface::TYPE_TIMESTAMP,
+            TableSchemaInterface::TYPE_TIMESTAMP_FRACTIONAL,
+            TableSchemaInterface::TYPE_TIMESTAMP_TIMEZONE,
+        ];
+        if (isset($column['null']) && $column['null'] === true && in_array($column['type'], $timestampTypes, true)) {
+            $out .= ' NULL';
+            unset($column['default']);
+        }
+        if (isset($column['srid']) && in_array($column['type'], TableSchemaInterface::GEOSPATIAL_TYPES)) {
+            $out .= " SRID {$column['srid']}";
+        }
+
+        $defaultExpressionTypes = array_merge(
+            TableSchemaInterface::GEOSPATIAL_TYPES,
+            [TableSchemaInterface::TYPE_BINARY, TableSchemaInterface::TYPE_TEXT, TableSchemaInterface::TYPE_JSON],
+        );
+        if (in_array($column['type'], $defaultExpressionTypes) && isset($column['default'])) {
+            // Geospatial, blob and text types need to be wrapped in () to create an expression.
+            $out .= ' DEFAULT (' . $this->_driver->schemaValue($column['default']) . ')';
+            unset($column['default']);
+        }
+
+        $dateTimeTypes = [
+            TableSchemaInterface::TYPE_DATETIME,
+            TableSchemaInterface::TYPE_DATETIME_FRACTIONAL,
+            TableSchemaInterface::TYPE_TIMESTAMP,
+            TableSchemaInterface::TYPE_TIMESTAMP_FRACTIONAL,
+            TableSchemaInterface::TYPE_TIMESTAMP_TIMEZONE,
+        ];
+        if (
+            isset($column['default']) &&
+            in_array($column['type'], $dateTimeTypes) &&
+            is_string($column['default']) &&
+            str_contains(strtolower($column['default']), 'current_timestamp')
+        ) {
+            $out .= ' DEFAULT CURRENT_TIMESTAMP';
+            if (isset($column['precision'])) {
+                $out .= '(' . $column['precision'] . ')';
+            }
+            unset($column['default']);
+        }
+        if (isset($column['default'])) {
+            $out .= ' DEFAULT ' . $this->_driver->schemaValue($column['default']);
+            unset($column['default']);
+        }
+        if (isset($column['comment']) && $column['comment'] !== '') {
+            // Always quote comments as strings to prevent SQL syntax errors with numeric comments
+            // See: https://github.com/cakephp/migrations/issues/889
+            $out .= ' COMMENT ' . $this->_driver->quote((string)$column['comment']);
+        }
+        if (isset($column['onUpdate']) && $column['onUpdate'] !== '') {
+            $out .= ' ON UPDATE ' . $column['onUpdate'];
+        }
+
+        return $out;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function columnSql(TableSchema $schema, string $name): string
+    {
+        $data = $schema->getColumn($name);
+        assert($data !== null);
+
+        // TODO deprecate Type defined schema mappings?
+        $sql = $this->_getTypeSpecificColumnSql($data['type'], $schema, $name);
+        if ($sql !== null) {
+            return $sql;
+        }
+        $data['name'] = $name;
+
+        $autoIncrementTypes = [
+            TableSchemaInterface::TYPE_TINYINTEGER,
+            TableSchemaInterface::TYPE_SMALLINTEGER,
+            TableSchemaInterface::TYPE_INTEGER,
+            TableSchemaInterface::TYPE_BIGINTEGER,
+        ];
+        if (
+            in_array($data['type'], $autoIncrementTypes, true) &&
+            $schema->getPrimaryKey() === [$name] &&
+            $name === 'id'
+        ) {
+            $data['autoIncrement'] = true;
+        }
+
+        return $this->columnDefinitionSql($data);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function constraintSql(TableSchema $schema, string $name): string
+    {
+        $data = $schema->getConstraint($name);
+        assert($data !== null);
+        if ($data['type'] === TableSchema::CONSTRAINT_PRIMARY) {
+            $columns = array_map(
+                $this->_driver->quoteIdentifier(...),
+                $data['columns'],
+            );
+
+            return sprintf('PRIMARY KEY (%s)', implode(', ', $columns));
+        }
+
+        $out = '';
+        if ($data['type'] === TableSchema::CONSTRAINT_UNIQUE) {
+            $out = 'UNIQUE KEY ';
+        } elseif ($data['type'] === TableSchema::CONSTRAINT_FOREIGN) {
+            $out = 'CONSTRAINT ';
+        } elseif ($data['type'] === TableSchema::CONSTRAINT_CHECK) {
+            return 'CONSTRAINT ' . $this->_driver->quoteIdentifier($name) . ' CHECK (' . $data['expression'] . ')';
+        }
+        $out .= $this->_driver->quoteIdentifier($name);
+
+        return $this->_keySql($out, $data);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function addConstraintSql(TableSchema $schema): array
+    {
+        $sqlPattern = 'ALTER TABLE %s ADD %s;';
+        $sql = [];
+
+        foreach ($schema->constraints() as $name) {
+            $constraint = $schema->getConstraint($name);
+            assert($constraint !== null);
+            if ($constraint['type'] === TableSchema::CONSTRAINT_FOREIGN) {
+                $tableName = $this->_driver->quoteIdentifier($schema->name());
+                $sql[] = sprintf($sqlPattern, $tableName, $this->constraintSql($schema, $name));
+            }
+        }
+
+        return $sql;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function dropConstraintSql(TableSchema $schema): array
+    {
+        $sqlPattern = 'ALTER TABLE %s DROP FOREIGN KEY %s;';
+        $sql = [];
+
+        foreach ($schema->constraints() as $name) {
+            $constraint = $schema->getConstraint($name);
+            assert($constraint !== null);
+            if ($constraint['type'] === TableSchema::CONSTRAINT_FOREIGN) {
+                $tableName = $this->_driver->quoteIdentifier($schema->name());
+                $constraintName = $this->_driver->quoteIdentifier($name);
+                $sql[] = sprintf($sqlPattern, $tableName, $constraintName);
+            }
+        }
+
+        return $sql;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function indexSql(TableSchema $schema, string $name): string
+    {
+        $data = $schema->getIndex($name);
+        assert($data !== null);
+        $out = '';
+        if ($data['type'] === TableSchema::INDEX_INDEX) {
+            $out = 'KEY ';
+        }
+        if ($data['type'] === TableSchema::INDEX_FULLTEXT) {
+            $out = 'FULLTEXT KEY ';
+        }
+        $out .= $this->_driver->quoteIdentifier($name);
+
+        return $this->_keySql($out, $data);
+    }
+
+    /**
+     * Helper method for generating key SQL snippets.
+     *
+     * @param string $prefix The key prefix
+     * @param array $data Key data.
+     * @return string
+     */
+    protected function _keySql(string $prefix, array $data): string
+    {
+        $columns = array_map(
+            $this->_driver->quoteIdentifier(...),
+            $data['columns'],
+        );
+        foreach ($data['columns'] as $i => $column) {
+            if (isset($data['length'][$column])) {
+                $columns[$i] .= sprintf('(%d)', $data['length'][$column]);
+            }
+        }
+        if ($data['type'] === TableSchema::CONSTRAINT_FOREIGN) {
+            return $prefix . sprintf(
+                ' FOREIGN KEY (%s) REFERENCES %s (%s) ON UPDATE %s ON DELETE %s',
+                implode(', ', $columns),
+                $this->_driver->quoteIdentifier($data['references'][0]),
+                $this->_convertConstraintColumns($data['references'][1]),
+                $this->_foreignOnClause($data['update']),
+                $this->_foreignOnClause($data['delete']),
+            );
+        }
+
+        return $prefix . ' (' . implode(', ', $columns) . ')';
+    }
+}
diff --git a/src/Database/Schema/PostgresSchema.php b/src/Database/Schema/PostgresSchema.php
deleted file mode 100644
index 3ad54f7294f..00000000000
--- a/src/Database/Schema/PostgresSchema.php
+++ /dev/null
@@ -1,595 +0,0 @@
- $col, 'length' => null];
-        }
-        if (strpos($col, 'timestamp') !== false) {
-            return ['type' => TableSchema::TYPE_TIMESTAMP, 'length' => null];
-        }
-        if (strpos($col, 'time') !== false) {
-            return ['type' => TableSchema::TYPE_TIME, 'length' => null];
-        }
-        if ($col === 'serial' || $col === 'integer') {
-            return ['type' => TableSchema::TYPE_INTEGER, 'length' => 10];
-        }
-        if ($col === 'bigserial' || $col === 'bigint') {
-            return ['type' => TableSchema::TYPE_BIGINTEGER, 'length' => 20];
-        }
-        if ($col === 'smallint') {
-            return ['type' => TableSchema::TYPE_SMALLINTEGER, 'length' => 5];
-        }
-        if ($col === 'inet') {
-            return ['type' => TableSchema::TYPE_STRING, 'length' => 39];
-        }
-        if ($col === 'uuid') {
-            return ['type' => TableSchema::TYPE_UUID, 'length' => null];
-        }
-        if ($col === 'char' || $col === 'character') {
-            return ['type' => TableSchema::TYPE_STRING, 'fixed' => true, 'length' => $length];
-        }
-        // money is 'string' as it includes arbitrary text content
-        // before the number value.
-        if (strpos($col, 'char') !== false ||
-            strpos($col, 'money') !== false
-        ) {
-            return ['type' => TableSchema::TYPE_STRING, 'length' => $length];
-        }
-        if (strpos($col, 'text') !== false) {
-            return ['type' => TableSchema::TYPE_TEXT, 'length' => null];
-        }
-        if ($col === 'bytea') {
-            return ['type' => TableSchema::TYPE_BINARY, 'length' => null];
-        }
-        if ($col === 'real' || strpos($col, 'double') !== false) {
-            return ['type' => TableSchema::TYPE_FLOAT, 'length' => null];
-        }
-        if (strpos($col, 'numeric') !== false ||
-            strpos($col, 'decimal') !== false
-        ) {
-            return ['type' => TableSchema::TYPE_DECIMAL, 'length' => null];
-        }
-
-        if (strpos($col, 'json') !== false) {
-            return ['type' => TableSchema::TYPE_JSON, 'length' => null];
-        }
-
-        return ['type' => TableSchema::TYPE_STRING, 'length' => null];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function convertColumnDescription(TableSchema $schema, $row)
-    {
-        $field = $this->_convertColumn($row['type']);
-
-        if ($field['type'] === TableSchema::TYPE_BOOLEAN) {
-            if ($row['default'] === 'true') {
-                $row['default'] = 1;
-            }
-            if ($row['default'] === 'false') {
-                $row['default'] = 0;
-            }
-        }
-        if (!empty($row['has_serial'])) {
-            $field['autoIncrement'] = true;
-        }
-
-        $field += [
-            'default' => $this->_defaultValue($row['default']),
-            'null' => $row['null'] === 'YES',
-            'collate' => $row['collation_name'],
-            'comment' => $row['comment']
-        ];
-        $field['length'] = $row['char_length'] ?: $field['length'];
-
-        if ($field['type'] === 'numeric' || $field['type'] === 'decimal') {
-            $field['length'] = $row['column_precision'];
-            $field['precision'] = $row['column_scale'] ?: null;
-        }
-        $schema->addColumn($row['name'], $field);
-    }
-
-    /**
-     * Manipulate the default value.
-     *
-     * Postgres includes sequence data and casting information in default values.
-     * We need to remove those.
-     *
-     * @param string|null $default The default value.
-     * @return string|null
-     */
-    protected function _defaultValue($default)
-    {
-        if (is_numeric($default) || $default === null) {
-            return $default;
-        }
-        // Sequences
-        if (strpos($default, 'nextval') === 0) {
-            return null;
-        }
-
-        // Remove quotes and postgres casts
-        return preg_replace(
-            "/^'(.*)'(?:::.*)$/",
-            '$1',
-            $default
-        );
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function describeIndexSql($tableName, $config)
-    {
-        $sql = 'SELECT
-        c2.relname,
-        a.attname,
-        i.indisprimary,
-        i.indisunique
-        FROM pg_catalog.pg_namespace n
-        INNER JOIN pg_catalog.pg_class c ON (n.oid = c.relnamespace)
-        INNER JOIN pg_catalog.pg_index i ON (c.oid = i.indrelid)
-        INNER JOIN pg_catalog.pg_class c2 ON (c2.oid = i.indexrelid)
-        INNER JOIN pg_catalog.pg_attribute a ON (a.attrelid = c.oid AND i.indrelid::regclass = a.attrelid::regclass)
-        WHERE n.nspname = ?
-        AND a.attnum = ANY(i.indkey)
-        AND c.relname = ?
-        ORDER BY i.indisprimary DESC, i.indisunique DESC, c.relname, a.attnum';
-
-        $schema = 'public';
-        if (!empty($config['schema'])) {
-            $schema = $config['schema'];
-        }
-
-        return [$sql, [$schema, $tableName]];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function convertIndexDescription(TableSchema $schema, $row)
-    {
-        $type = TableSchema::INDEX_INDEX;
-        $name = $row['relname'];
-        if ($row['indisprimary']) {
-            $name = $type = TableSchema::CONSTRAINT_PRIMARY;
-        }
-        if ($row['indisunique'] && $type === TableSchema::INDEX_INDEX) {
-            $type = TableSchema::CONSTRAINT_UNIQUE;
-        }
-        if ($type === TableSchema::CONSTRAINT_PRIMARY || $type === TableSchema::CONSTRAINT_UNIQUE) {
-            $this->_convertConstraint($schema, $name, $type, $row);
-
-            return;
-        }
-        $index = $schema->getIndex($name);
-        if (!$index) {
-            $index = [
-                'type' => $type,
-                'columns' => []
-            ];
-        }
-        $index['columns'][] = $row['attname'];
-        $schema->addIndex($name, $index);
-    }
-
-    /**
-     * Add/update a constraint into the schema object.
-     *
-     * @param \Cake\Database\Schema\TableSchema $schema The table to update.
-     * @param string $name The index name.
-     * @param string $type The index type.
-     * @param array $row The metadata record to update with.
-     * @return void
-     */
-    protected function _convertConstraint($schema, $name, $type, $row)
-    {
-        $constraint = $schema->getConstraint($name);
-        if (!$constraint) {
-            $constraint = [
-                'type' => $type,
-                'columns' => []
-            ];
-        }
-        $constraint['columns'][] = $row['attname'];
-        $schema->addConstraint($name, $constraint);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function describeForeignKeySql($tableName, $config)
-    {
-        $sql = 'SELECT
-        c.conname AS name,
-        c.contype AS type,
-        a.attname AS column_name,
-        c.confmatchtype AS match_type,
-        c.confupdtype AS on_update,
-        c.confdeltype AS on_delete,
-        c.confrelid::regclass AS references_table,
-        ab.attname AS references_field
-        FROM pg_catalog.pg_namespace n
-        INNER JOIN pg_catalog.pg_class cl ON (n.oid = cl.relnamespace)
-        INNER JOIN pg_catalog.pg_constraint c ON (n.oid = c.connamespace)
-        INNER JOIN pg_catalog.pg_attribute a ON (a.attrelid = cl.oid AND c.conrelid = a.attrelid AND a.attnum = ANY(c.conkey))
-        INNER JOIN pg_catalog.pg_attribute ab ON (a.attrelid = cl.oid AND c.confrelid = ab.attrelid AND ab.attnum = ANY(c.confkey))
-        WHERE n.nspname = ?
-        AND cl.relname = ?
-        ORDER BY name, a.attnum, ab.attnum DESC';
-
-        $schema = empty($config['schema']) ? 'public' : $config['schema'];
-
-        return [$sql, [$schema, $tableName]];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function convertForeignKeyDescription(TableSchema $schema, $row)
-    {
-        $data = [
-            'type' => TableSchema::CONSTRAINT_FOREIGN,
-            'columns' => $row['column_name'],
-            'references' => [$row['references_table'], $row['references_field']],
-            'update' => $this->_convertOnClause($row['on_update']),
-            'delete' => $this->_convertOnClause($row['on_delete']),
-        ];
-        $schema->addConstraint($row['name'], $data);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    protected function _convertOnClause($clause)
-    {
-        if ($clause === 'r') {
-            return TableSchema::ACTION_RESTRICT;
-        }
-        if ($clause === 'a') {
-            return TableSchema::ACTION_NO_ACTION;
-        }
-        if ($clause === 'c') {
-            return TableSchema::ACTION_CASCADE;
-        }
-
-        return TableSchema::ACTION_SET_NULL;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function columnSql(TableSchema $schema, $name)
-    {
-        $data = $schema->getColumn($name);
-        $out = $this->_driver->quoteIdentifier($name);
-        $typeMap = [
-            TableSchema::TYPE_TINYINTEGER => ' SMALLINT',
-            TableSchema::TYPE_SMALLINTEGER => ' SMALLINT',
-            TableSchema::TYPE_BINARY => ' BYTEA',
-            TableSchema::TYPE_BOOLEAN => ' BOOLEAN',
-            TableSchema::TYPE_FLOAT => ' FLOAT',
-            TableSchema::TYPE_DECIMAL => ' DECIMAL',
-            TableSchema::TYPE_DATE => ' DATE',
-            TableSchema::TYPE_TIME => ' TIME',
-            TableSchema::TYPE_DATETIME => ' TIMESTAMP',
-            TableSchema::TYPE_TIMESTAMP => ' TIMESTAMP',
-            TableSchema::TYPE_UUID => ' UUID',
-            TableSchema::TYPE_JSON => ' JSONB'
-        ];
-
-        if (isset($typeMap[$data['type']])) {
-            $out .= $typeMap[$data['type']];
-        }
-
-        if ($data['type'] === TableSchema::TYPE_INTEGER || $data['type'] === TableSchema::TYPE_BIGINTEGER) {
-            $type = $data['type'] === TableSchema::TYPE_INTEGER ? ' INTEGER' : ' BIGINT';
-            if ([$name] === $schema->primaryKey() || $data['autoIncrement'] === true) {
-                $type = $data['type'] === TableSchema::TYPE_INTEGER ? ' SERIAL' : ' BIGSERIAL';
-                unset($data['null'], $data['default']);
-            }
-            $out .= $type;
-        }
-
-        if ($data['type'] === TableSchema::TYPE_TEXT && $data['length'] !== TableSchema::LENGTH_TINY) {
-            $out .= ' TEXT';
-        }
-
-        if ($data['type'] === TableSchema::TYPE_STRING ||
-            ($data['type'] === TableSchema::TYPE_TEXT && $data['length'] === TableSchema::LENGTH_TINY)
-        ) {
-            $isFixed = !empty($data['fixed']);
-            $type = ' VARCHAR';
-            if ($isFixed) {
-                $type = ' CHAR';
-            }
-            $out .= $type;
-            if (isset($data['length']) && $data['length'] != 36) {
-                $out .= '(' . (int)$data['length'] . ')';
-            }
-        }
-
-        $hasCollate = [TableSchema::TYPE_TEXT, TableSchema::TYPE_STRING];
-        if (in_array($data['type'], $hasCollate, true) && isset($data['collate']) && $data['collate'] !== '') {
-            $out .= ' COLLATE "' . $data['collate'] . '"';
-        }
-
-        if ($data['type'] === TableSchema::TYPE_FLOAT && isset($data['precision'])) {
-            $out .= '(' . (int)$data['precision'] . ')';
-        }
-
-        if ($data['type'] === TableSchema::TYPE_DECIMAL &&
-            (isset($data['length']) || isset($data['precision']))
-        ) {
-            $out .= '(' . (int)$data['length'] . ',' . (int)$data['precision'] . ')';
-        }
-
-        if (isset($data['null']) && $data['null'] === false) {
-            $out .= ' NOT NULL';
-        }
-
-        if (isset($data['default']) &&
-            in_array($data['type'], [TableSchema::TYPE_TIMESTAMP, TableSchema::TYPE_DATETIME]) &&
-            strtolower($data['default']) === 'current_timestamp'
-        ) {
-            $out .= ' DEFAULT CURRENT_TIMESTAMP';
-        } elseif (isset($data['default'])) {
-            $defaultValue = $data['default'];
-            if ($data['type'] === 'boolean') {
-                $defaultValue = (bool)$defaultValue;
-            }
-            $out .= ' DEFAULT ' . $this->_driver->schemaValue($defaultValue);
-        } elseif (isset($data['null']) && $data['null'] !== false) {
-            $out .= ' DEFAULT NULL';
-        }
-
-        return $out;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function addConstraintSql(TableSchema $schema)
-    {
-        $sqlPattern = 'ALTER TABLE %s ADD %s;';
-        $sql = [];
-
-        foreach ($schema->constraints() as $name) {
-            $constraint = $schema->getConstraint($name);
-            if ($constraint['type'] === TableSchema::CONSTRAINT_FOREIGN) {
-                $tableName = $this->_driver->quoteIdentifier($schema->name());
-                $sql[] = sprintf($sqlPattern, $tableName, $this->constraintSql($schema, $name));
-            }
-        }
-
-        return $sql;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function dropConstraintSql(TableSchema $schema)
-    {
-        $sqlPattern = 'ALTER TABLE %s DROP CONSTRAINT %s;';
-        $sql = [];
-
-        foreach ($schema->constraints() as $name) {
-            $constraint = $schema->getConstraint($name);
-            if ($constraint['type'] === TableSchema::CONSTRAINT_FOREIGN) {
-                $tableName = $this->_driver->quoteIdentifier($schema->name());
-                $constraintName = $this->_driver->quoteIdentifier($name);
-                $sql[] = sprintf($sqlPattern, $tableName, $constraintName);
-            }
-        }
-
-        return $sql;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function indexSql(TableSchema $schema, $name)
-    {
-        $data = $schema->getIndex($name);
-        $columns = array_map(
-            [$this->_driver, 'quoteIdentifier'],
-            $data['columns']
-        );
-
-        return sprintf(
-            'CREATE INDEX %s ON %s (%s)',
-            $this->_driver->quoteIdentifier($name),
-            $this->_driver->quoteIdentifier($schema->name()),
-            implode(', ', $columns)
-        );
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function constraintSql(TableSchema $schema, $name)
-    {
-        $data = $schema->getConstraint($name);
-        $out = 'CONSTRAINT ' . $this->_driver->quoteIdentifier($name);
-        if ($data['type'] === TableSchema::CONSTRAINT_PRIMARY) {
-            $out = 'PRIMARY KEY';
-        }
-        if ($data['type'] === TableSchema::CONSTRAINT_UNIQUE) {
-            $out .= ' UNIQUE';
-        }
-
-        return $this->_keySql($out, $data);
-    }
-
-    /**
-     * Helper method for generating key SQL snippets.
-     *
-     * @param string $prefix The key prefix
-     * @param array $data Key data.
-     * @return string
-     */
-    protected function _keySql($prefix, $data)
-    {
-        $columns = array_map(
-            [$this->_driver, 'quoteIdentifier'],
-            $data['columns']
-        );
-        if ($data['type'] === TableSchema::CONSTRAINT_FOREIGN) {
-            return $prefix . sprintf(
-                ' FOREIGN KEY (%s) REFERENCES %s (%s) ON UPDATE %s ON DELETE %s DEFERRABLE INITIALLY IMMEDIATE',
-                implode(', ', $columns),
-                $this->_driver->quoteIdentifier($data['references'][0]),
-                $this->_convertConstraintColumns($data['references'][1]),
-                $this->_foreignOnClause($data['update']),
-                $this->_foreignOnClause($data['delete'])
-            );
-        }
-
-        return $prefix . ' (' . implode(', ', $columns) . ')';
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function createTableSql(TableSchema $schema, $columns, $constraints, $indexes)
-    {
-        $content = array_merge($columns, $constraints);
-        $content = implode(",\n", array_filter($content));
-        $tableName = $this->_driver->quoteIdentifier($schema->name());
-        $temporary = $schema->isTemporary() ? ' TEMPORARY ' : ' ';
-        $out = [];
-        $out[] = sprintf("CREATE%sTABLE %s (\n%s\n)", $temporary, $tableName, $content);
-        foreach ($indexes as $index) {
-            $out[] = $index;
-        }
-        foreach ($schema->columns() as $column) {
-            $columnData = $schema->getColumn($column);
-            if (isset($columnData['comment'])) {
-                $out[] = sprintf(
-                    'COMMENT ON COLUMN %s.%s IS %s',
-                    $tableName,
-                    $this->_driver->quoteIdentifier($column),
-                    $this->_driver->schemaValue($columnData['comment'])
-                );
-            }
-        }
-
-        return $out;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function truncateTableSql(TableSchema $schema)
-    {
-        $name = $this->_driver->quoteIdentifier($schema->name());
-
-        return [
-            sprintf('TRUNCATE %s RESTART IDENTITY CASCADE', $name)
-        ];
-    }
-
-    /**
-     * Generate the SQL to drop a table.
-     *
-     * @param \Cake\Database\Schema\TableSchema $schema Table instance
-     * @return array SQL statements to drop a table.
-     */
-    public function dropTableSql(TableSchema $schema)
-    {
-        $sql = sprintf(
-            'DROP TABLE %s CASCADE',
-            $this->_driver->quoteIdentifier($schema->name())
-        );
-
-        return [$sql];
-    }
-}
diff --git a/src/Database/Schema/PostgresSchemaDialect.php b/src/Database/Schema/PostgresSchemaDialect.php
new file mode 100644
index 00000000000..e0ff7d08f6d
--- /dev/null
+++ b/src/Database/Schema/PostgresSchemaDialect.php
@@ -0,0 +1,1104 @@
+ $config The connection configuration to use for
+     *    getting tables from.
+     * @return array An array of (sql, params) to execute.
+     */
+    public function listTablesSql(array $config): array
+    {
+        $sql = 'SELECT table_name as name FROM information_schema.tables
+                WHERE table_schema = ? ORDER BY name';
+        $schema = $config['schema'] ?? 'public';
+
+        return [$sql, [$schema]];
+    }
+
+    /**
+     * Generate the SQL to list the tables, excluding all views.
+     *
+     * @param array $config The connection configuration to use for
+     *    getting tables from.
+     * @return array An array of (sql, params) to execute.
+     */
+    public function listTablesWithoutViewsSql(array $config): array
+    {
+        $sql = 'SELECT table_name as name FROM information_schema.tables
+                WHERE table_schema = ? AND table_type = \'BASE TABLE\' ORDER BY name';
+        $schema = $config['schema'] ?? 'public';
+
+        return [$sql, [$schema]];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeColumnSql(string $tableName, array $config): array
+    {
+        $sql = $this->describeColumnQuery();
+        $schema = $config['schema'] ?? 'public';
+
+        return [$sql, [$tableName, $schema, $config['database']]];
+    }
+
+    /**
+     * Helper method for creating SQL to describe columns in a table.
+     *
+     * @return string SQL to reflect columns
+     */
+    private function describeColumnQuery(): string
+    {
+        return 'SELECT DISTINCT table_schema AS schema,
+            column_name AS name,
+            data_type AS type,
+            udt_name,
+            is_identity,
+            is_nullable AS null,
+            column_default AS default,
+            character_maximum_length AS char_length,
+            c.collation_name,
+            d.description as comment,
+            ordinal_position,
+            c.datetime_precision,
+            c.numeric_precision as column_precision,
+            c.numeric_scale as column_scale,
+            c.identity_generation,
+            pg_get_serial_sequence(attr.attrelid::regclass::text, attr.attname) IS NOT NULL AS has_serial
+        FROM information_schema.columns c
+        INNER JOIN pg_catalog.pg_namespace ns ON (ns.nspname = table_schema)
+        INNER JOIN pg_catalog.pg_class cl ON (cl.relnamespace = ns.oid AND cl.relname = table_name)
+        LEFT JOIN pg_catalog.pg_index i ON (i.indrelid = cl.oid AND i.indkey[0] = c.ordinal_position)
+        LEFT JOIN pg_catalog.pg_description d on (cl.oid = d.objoid AND d.objsubid = c.ordinal_position)
+        LEFT JOIN pg_catalog.pg_attribute attr ON (cl.oid = attr.attrelid AND column_name = attr.attname)
+        WHERE table_name = ? AND table_schema = ? AND table_catalog = ?
+        ORDER BY ordinal_position';
+    }
+
+    /**
+     * Describes PostGIS specific column information.
+     *
+     * @return array The column information.
+     */
+    private function describePostgisColumns(string $postgisType, string $table, string $schema, string $catalog): array
+    {
+        $sql = <<_driver->execute($sql, [$table, $schema, $catalog])->fetchAll('assoc');
+
+        return array_combine(array_column($columns, 'name'), $columns);
+    }
+
+    /**
+     * Convert a column definition to the abstract types.
+     *
+     * The returned type will be a type that
+     * Cake\Database\TypeFactory can handle.
+     *
+     * @param string $column The column type + length
+     * @throws \Cake\Database\Exception\DatabaseException when column cannot be parsed.
+     * @return array Array of column information.
+     */
+    protected function _convertColumn(string $column): array
+    {
+        preg_match('/([a-z\s]+)(?:\(([0-9,]+)\))?/i', $column, $matches);
+        if (!$matches) {
+            throw new DatabaseException(sprintf('Unable to parse column type from `%s`', $column));
+        }
+
+        $col = strtolower($matches[1]);
+        $length = null;
+        $precision = null;
+        $scale = null;
+        if (isset($matches[2])) {
+            $length = (int)$matches[2];
+        }
+
+        $type = $this->_applyTypeSpecificColumnConversion(
+            $col,
+            compact('length', 'precision', 'scale'),
+        );
+        if ($type !== null) {
+            return $type;
+        }
+
+        if (in_array($col, ['date', 'time', 'boolean', 'inet', 'cidr', 'macaddr', 'citext', 'interval'], true)) {
+            return ['type' => $col, 'length' => null];
+        }
+        if (in_array($col, ['timestamptz', 'timestamp with time zone'], true)) {
+            return ['type' => TableSchemaInterface::TYPE_TIMESTAMP_TIMEZONE, 'length' => null];
+        }
+        if (str_contains($col, 'timestamp')) {
+            return ['type' => TableSchemaInterface::TYPE_TIMESTAMP_FRACTIONAL, 'length' => null];
+        }
+        if (str_contains($col, 'time')) {
+            return ['type' => TableSchemaInterface::TYPE_TIME, 'length' => null];
+        }
+        if ($col === 'serial' || $col === 'integer') {
+            return ['type' => TableSchemaInterface::TYPE_INTEGER, 'length' => 10];
+        }
+        if ($col === 'bigserial' || $col === 'bigint') {
+            return ['type' => TableSchemaInterface::TYPE_BIGINTEGER, 'length' => 20];
+        }
+        if ($col === 'smallint') {
+            return ['type' => TableSchemaInterface::TYPE_SMALLINTEGER, 'length' => 5];
+        }
+        if ($col === 'uuid') {
+            return ['type' => TableSchemaInterface::TYPE_UUID, 'length' => null];
+        }
+        if ($col === 'char') {
+            return ['type' => TableSchemaInterface::TYPE_CHAR, 'length' => $length];
+        }
+        if (str_contains($col, 'character')) {
+            return ['type' => TableSchemaInterface::TYPE_STRING, 'length' => $length];
+        }
+        // money is 'string' as it includes arbitrary text content
+        // before the number value.
+        if (str_contains($col, 'money') || $col === 'string') {
+            return ['type' => TableSchemaInterface::TYPE_STRING, 'length' => $length];
+        }
+        if (str_contains($col, 'text')) {
+            return ['type' => TableSchemaInterface::TYPE_TEXT, 'length' => null];
+        }
+        if ($col === 'bytea') {
+            return ['type' => TableSchemaInterface::TYPE_BINARY, 'length' => null];
+        }
+        if ($col === 'real' || str_contains($col, 'double')) {
+            return ['type' => TableSchemaInterface::TYPE_FLOAT, 'length' => null];
+        }
+        if (str_contains($col, 'numeric') || str_contains($col, 'decimal')) {
+            return ['type' => TableSchemaInterface::TYPE_DECIMAL, 'length' => null];
+        }
+        if (str_contains($col, 'json')) {
+            return ['type' => TableSchemaInterface::TYPE_JSON, 'length' => null];
+        }
+
+        if (in_array($col, ['geometry', 'geography'], true)) {
+            return ['type' => $col, 'length' => null];
+        }
+
+        $length = is_numeric($length) ? $length : null;
+
+        return ['type' => TableSchemaInterface::TYPE_STRING, 'length' => $length];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function convertColumnDescription(TableSchema $schema, array $row): void
+    {
+        $field = $this->_convertColumn($row['type']);
+
+        if ($field['type'] === TableSchemaInterface::TYPE_BOOLEAN) {
+            if ($row['default'] === 'true') {
+                $row['default'] = 1;
+            }
+            if ($row['default'] === 'false') {
+                $row['default'] = 0;
+            }
+        }
+        if (!empty($row['has_serial'])) {
+            $field['autoIncrement'] = true;
+        }
+
+        $field += [
+            'default' => $this->_defaultValue($row['default']),
+            'null' => $row['null'] === 'YES',
+            'collate' => $row['collation_name'],
+            'comment' => $row['comment'],
+        ];
+        $field['length'] = $row['char_length'] ?: $field['length'];
+
+        if ($field['type'] === 'numeric' || $field['type'] === 'decimal') {
+            $field['length'] = $row['column_precision'];
+            $field['precision'] = $row['column_scale'] ?: null;
+        }
+
+        if ($field['type'] === TableSchemaInterface::TYPE_TIMESTAMP_FRACTIONAL) {
+            $field['precision'] = $row['datetime_precision'];
+            if ($field['precision'] === 0) {
+                $field['type'] = TableSchemaInterface::TYPE_TIMESTAMP;
+            }
+        }
+
+        if ($field['type'] === TableSchemaInterface::TYPE_TIMESTAMP_TIMEZONE) {
+            $field['precision'] = $row['datetime_precision'];
+        }
+
+        $schema->addColumn($row['name'], $field);
+    }
+
+    /**
+     * Split a tablename into a tuple of schema, table
+     * If the table does not have a schema name included, the connection
+     * schema will be used.
+     *
+     * @param string $tableName The table name to split
+     * @param array $config Additional configuration data
+     * @return array A tuple of [schema, tablename]
+     */
+    private function splitTablename(string $tableName, array $config = []): array
+    {
+        if (str_contains($tableName, '.')) {
+            return explode('.', $tableName);
+        }
+        $driverConfig = $this->_driver->config();
+        $schema = $config['schema'] ?? $driverConfig['schema'] ?? 'public';
+
+        return [$schema, $tableName];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeColumns(string $tableName): array
+    {
+        $config = $this->_driver->config();
+        [$schema, $name] = $this->splitTablename($tableName);
+
+        $sql = $this->describeColumnQuery();
+        $rows = $this->_driver->execute($sql, [$name, $schema, $config['database']])->fetchAll('assoc');
+
+        $postgisColumns = [];
+        $udtTypes = array_column($rows, 'udt_name');
+        foreach (['geometry', 'geography'] as $postgisType) {
+            if (in_array($postgisType, $udtTypes)) {
+                $postgisColumns += $this->describePostgisColumns($postgisType, $name, $schema, $config['database']);
+            }
+        }
+
+        $columns = [];
+        foreach ($rows as $row) {
+            $type = $row['type'];
+            if ($type === 'USER-DEFINED') {
+                $type = $row['udt_name'];
+            }
+            $field = $this->_convertColumn($type);
+            if ($field['type'] === TableSchemaInterface::TYPE_BOOLEAN) {
+                if ($row['default'] === 'true') {
+                    $row['default'] = 1;
+                } elseif ($row['default'] === 'false') {
+                    $row['default'] = 0;
+                }
+            }
+            if (!empty($row['has_serial'])) {
+                $field['autoIncrement'] = true;
+            }
+
+            $field += [
+                'name' => $row['name'],
+                'default' => $this->_defaultValue($row['default']),
+                'null' => $row['null'] === 'YES',
+                'collate' => $row['collation_name'],
+                'comment' => $row['comment'],
+            ];
+            $field['length'] = $row['char_length'] ?: $field['length'];
+
+            if ($field['type'] === 'numeric' || $field['type'] === 'decimal') {
+                $field['length'] = $row['column_precision'];
+                $field['precision'] = $row['column_scale'] ?: null;
+            }
+
+            if ($field['type'] === TableSchemaInterface::TYPE_TIMESTAMP_FRACTIONAL) {
+                $field['precision'] = $row['datetime_precision'];
+                if ($field['precision'] === 0) {
+                    $field['type'] = TableSchemaInterface::TYPE_TIMESTAMP;
+                }
+            }
+
+            if ($field['type'] === TableSchemaInterface::TYPE_TIMESTAMP_TIMEZONE) {
+                $field['precision'] = $row['datetime_precision'];
+            }
+            if (isset($row['identity_generation']) && $row['identity_generation']) {
+                $field['generated'] = $row['identity_generation'];
+            }
+
+            // Add PostGIS metadata for geometry/geography columns
+            if (isset($postgisColumns[$row['name']])) {
+                $field['geometryType'] = ucfirst(strtolower($postgisColumns[$row['name']]['type']));
+                $field['srid'] = $postgisColumns[$row['name']]['srid'];
+            }
+
+            $columns[] = $field;
+        }
+
+        return $columns;
+    }
+
+    /**
+     * Manipulate the default value.
+     *
+     * Postgres includes sequence data and casting information in default values.
+     * We need to remove those.
+     *
+     * @param string|int|null $default The default value.
+     * @return string|int|null
+     */
+    protected function _defaultValue(string|int|null $default): string|int|null
+    {
+        if (is_numeric($default) || $default === null) {
+            return $default;
+        }
+        // Sequences
+        if (str_starts_with($default, 'nextval')) {
+            return null;
+        }
+
+        if (str_starts_with($default, 'NULL::')) {
+            return null;
+        }
+
+        // Remove quotes and postgres casts
+        return preg_replace(
+            "/^'(.*)'(?:::.*)$/",
+            '$1',
+            $default,
+        );
+    }
+
+    /**
+     * Get the query to describe indexes
+     *
+     * @return string
+     */
+    private function describeIndexQuery(): string
+    {
+        return 'SELECT
+        c2.relname,
+        a.attname,
+        i.indisprimary,
+        i.indisunique,
+        i.indnkeyatts,
+        am.amname
+        FROM pg_catalog.pg_namespace n
+        INNER JOIN pg_catalog.pg_class c ON (n.oid = c.relnamespace)
+        INNER JOIN pg_catalog.pg_index i ON (c.oid = i.indrelid)
+        INNER JOIN pg_catalog.pg_class c2 ON (c2.oid = i.indexrelid)
+        INNER JOIN pg_catalog.pg_attribute a ON (a.attrelid = c.oid AND i.indrelid::regclass = a.attrelid::regclass)
+        INNER JOIN pg_catalog.pg_am am ON (c2.relam = am.oid)
+        WHERE n.nspname = ?
+        AND a.attnum = ANY(i.indkey)
+        AND c.relname = ?
+        ORDER BY i.indisprimary DESC, i.indisunique DESC, c.relname, a.attnum';
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeIndexSql(string $tableName, array $config): array
+    {
+        $sql = $this->describeIndexQuery();
+        [$schema, $name] = $this->splitTablename($tableName, $config);
+
+        return [$sql, [$schema, $name]];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function convertIndexDescription(TableSchema $schema, array $row): void
+    {
+        $type = TableSchema::INDEX_INDEX;
+        $name = $row['relname'];
+        if ($row['indisprimary']) {
+            $name = TableSchema::CONSTRAINT_PRIMARY;
+            $type = TableSchema::CONSTRAINT_PRIMARY;
+        }
+        if ($row['indisunique'] && $type === TableSchema::INDEX_INDEX) {
+            $type = TableSchema::CONSTRAINT_UNIQUE;
+        }
+        if ($type === TableSchema::CONSTRAINT_PRIMARY || $type === TableSchema::CONSTRAINT_UNIQUE) {
+            $this->_convertConstraint($schema, $name, $type, $row);
+
+            return;
+        }
+        $index = $schema->getIndex($name);
+        if (!$index) {
+            $index = [
+                'type' => $type,
+                'columns' => [],
+            ];
+            // Include access method for non-btree indexes
+            $accessMethod = $row['amname'] ?? 'btree';
+            if ($accessMethod !== 'btree') {
+                $index['accessMethod'] = $accessMethod;
+            }
+        }
+        $index['columns'][] = $row['attname'];
+        $schema->addIndex($name, $index);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeIndexes(string $tableName): array
+    {
+        [$schema, $name] = $this->splitTablename($tableName);
+        $sql = $this->describeIndexQuery();
+
+        $indexes = [];
+        $statement = $this->_driver->execute($sql, [$schema, $name]);
+        foreach ($statement->fetchAll('assoc') as $row) {
+            $type = TableSchema::INDEX_INDEX;
+            $name = $row['relname'];
+            $constraint = null;
+            $includeColumnIndex = $row['indnkeyatts'];
+            if ($row['indisprimary']) {
+                $constraint = $name;
+                $name = TableSchema::CONSTRAINT_PRIMARY;
+                $type = TableSchema::CONSTRAINT_PRIMARY;
+            }
+            if ($row['indisunique'] && $type === TableSchema::INDEX_INDEX) {
+                $type = TableSchema::CONSTRAINT_UNIQUE;
+            }
+            if (!isset($indexes[$name])) {
+                $indexes[$name] = [
+                    'name' => $name,
+                    'type' => $type,
+                    'columns' => [],
+                    'length' => [],
+                ];
+                // Include access method for non-btree indexes
+                $accessMethod = $row['amname'] ?? 'btree';
+                if ($accessMethod !== 'btree') {
+                    $indexes[$name]['accessMethod'] = $accessMethod;
+                }
+            }
+            if ($constraint) {
+                $indexes[$name]['constraint'] = $constraint;
+            }
+            if (count($indexes[$name]['columns']) < $includeColumnIndex) {
+                $indexes[$name]['columns'][] = $row['attname'];
+            } else {
+                $indexes[$name]['include'][] = $row['attname'];
+            }
+        }
+
+        return array_values($indexes);
+    }
+
+    /**
+     * Add/update a constraint into the schema object.
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema The table to update.
+     * @param string $name The index name.
+     * @param string $type The index type.
+     * @param array $row The metadata record to update with.
+     * @return void
+     */
+    protected function _convertConstraint(TableSchema $schema, string $name, string $type, array $row): void
+    {
+        $constraint = $schema->getConstraint($name);
+        if (!$constraint) {
+            $constraint = [
+                'type' => $type,
+                'columns' => [],
+            ];
+        }
+        $constraint['columns'][] = $row['attname'];
+        $schema->addConstraint($name, $constraint);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeForeignKeySql(string $tableName, array $config): array
+    {
+        $sql = $this->describeForeignKeyQuery();
+        [$schema, $name] = $this->splitTablename($tableName, $config);
+
+        return [$sql, [$schema, $name]];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function convertForeignKeyDescription(TableSchema $schema, array $row): void
+    {
+        $data = [
+            'type' => TableSchema::CONSTRAINT_FOREIGN,
+            'columns' => $row['column_name'],
+            'references' => [$row['references_table'], $row['references_field']],
+            'update' => $this->_convertOnClause($row['on_update']),
+            'delete' => $this->_convertOnClause($row['on_delete']),
+            'deferrable' => $this->convertDeferrable($row),
+        ];
+        $schema->addConstraint($row['name'], $data);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeForeignKeys(string $tableName): array
+    {
+        [$schema, $name] = $this->splitTablename($tableName);
+        $sql = $this->describeForeignKeyQuery();
+        $keys = [];
+        $statement = $this->_driver->execute($sql, [$schema, $name]);
+        foreach ($statement->fetchAll('assoc') as $row) {
+            $name = $row['name'];
+            if (!isset($keys[$name])) {
+                $keys[$name] = [
+                    'name' => $name,
+                    'type' => TableSchema::CONSTRAINT_FOREIGN,
+                    'columns' => [],
+                    'references' => [$row['references_table'], []],
+                    'update' => $this->_convertOnClause($row['on_update']),
+                    'delete' => $this->_convertOnClause($row['on_delete']),
+                    'deferrable' => $this->convertDeferrable($row),
+                ];
+            }
+            // column indexes start at 1
+            $columnOrder = $row['column_order'] - 1;
+            $referencedColumnOrder = $row['references_field_order'] - 1;
+
+            $keys[$name]['columns'][$columnOrder] = $row['column_name'];
+            $keys[$name]['references'][1][$referencedColumnOrder] = $row['references_field'];
+        }
+        foreach ($keys as $id => $key) {
+            // references.1 is the referenced columns. Backwards compat
+            // requires a single column to be a string, but multiple to be an array.
+            if (count($key['references'][1]) === 1) {
+                $keys[$id]['references'][1] = $key['references'][1][0];
+            }
+        }
+
+        return array_values($keys);
+    }
+
+    /**
+     * Get the query to describe foreign keys
+     *
+     * @return string
+     */
+    private function describeForeignKeyQuery(): string
+    {
+        // phpcs:disable Generic.Files.LineLength
+        $sql = 'SELECT
+        c.conname AS name,
+        c.contype AS type,
+        a.attname AS column_name,
+        array_position(c.conkey, a.attnum) AS column_order,
+        c.confmatchtype AS match_type,
+        c.confupdtype AS on_update,
+        c.confdeltype AS on_delete,
+        c.confrelid::regclass AS references_table,
+        ab.attname AS references_field,
+        array_position(c.confkey, ab.attnum) AS references_field_order,
+        c.condeferrable AS deferrable,
+        c.condeferred AS initially_deferred
+        FROM pg_catalog.pg_namespace n
+        INNER JOIN pg_catalog.pg_class cl ON (n.oid = cl.relnamespace)
+        INNER JOIN pg_catalog.pg_constraint c ON (n.oid = c.connamespace)
+        INNER JOIN pg_catalog.pg_attribute a ON (a.attrelid = cl.oid AND c.conrelid = a.attrelid AND a.attnum = ANY(c.conkey))
+        INNER JOIN pg_catalog.pg_attribute ab ON (a.attrelid = cl.oid AND c.confrelid = ab.attrelid AND ab.attnum = ANY(c.confkey))
+        WHERE n.nspname = ?
+        AND cl.relname = ?
+        ORDER BY name, column_order ASC, references_field_order ASC';
+        // phpcs:enable Generic.Files.LineLength
+
+        return $sql;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeCheckConstraints(string $tableName): array
+    {
+        [$schema, $name] = $this->splitTablename($tableName);
+        $sql = 'SELECT
+        con.conname AS name,
+        pg_get_constraintdef(con.oid) AS expression
+        FROM pg_catalog.pg_constraint AS con
+        INNER JOIN pg_catalog.pg_namespace AS ns ON (ns.oid = con.connamespace)
+        INNER JOIN pg_catalog.pg_class AS cls ON (cls.oid = con.conrelid)
+        WHERE ns.nspname = ? AND cls.relname = ? AND con.contype = \'c\'';
+
+        $results = [];
+        $statement = $this->_driver->execute($sql, [$schema, $name]);
+        foreach ($statement->fetchAll('assoc') as $row) {
+            $expression = preg_replace('/^CHECK \(\((.*)\)\)$/i', '$1', $row['expression']);
+            $results[] = [
+                'name' => $row['name'],
+                'type' => TableSchema::CONSTRAINT_CHECK,
+                'expression' => $expression,
+            ];
+        }
+
+        return $results;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeOptions(string $tableName): array
+    {
+        return [];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function _convertOnClause(string $clause): string
+    {
+        if ($clause === 'r') {
+            return TableSchema::ACTION_RESTRICT;
+        }
+        if ($clause === 'a') {
+            return TableSchema::ACTION_NO_ACTION;
+        }
+        if ($clause === 'c') {
+            return TableSchema::ACTION_CASCADE;
+        }
+
+        return TableSchema::ACTION_SET_NULL;
+    }
+
+    /**
+     * Convert deferrable option from the postgres metadata into a string
+     *
+     * @param array $row The row to convert.
+     * @return string|null The deferrable value or null if not deferrable.
+     */
+    protected function convertDeferrable(array $row): ?string
+    {
+        if (!isset($row['deferrable'])) {
+            return null;
+        }
+        if (!$row['deferrable']) {
+            return ForeignKey::NOT_DEFERRED;
+        }
+        if (isset($row['initially_deferred']) && $row['initially_deferred']) {
+            return ForeignKey::DEFERRED;
+        }
+
+        return ForeignKey::IMMEDIATE;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function columnSql(TableSchema $schema, string $name): string
+    {
+        $data = $schema->getColumn($name);
+        assert($data !== null);
+        $data['name'] = $name;
+
+        $sql = $this->_getTypeSpecificColumnSql($data['type'], $schema, $name);
+        if ($sql !== null) {
+            return $sql;
+        }
+        $autoIncrementTypes = [
+            TableSchemaInterface::TYPE_TINYINTEGER,
+            TableSchemaInterface::TYPE_SMALLINTEGER,
+            TableSchemaInterface::TYPE_INTEGER,
+            TableSchemaInterface::TYPE_BIGINTEGER,
+        ];
+        $primaryKey = $schema->getPrimaryKey();
+        if (
+            in_array($data['type'], $autoIncrementTypes, true) &&
+            $primaryKey === [$name] && $name === 'id'
+        ) {
+            $data['autoIncrement'] = true;
+        }
+
+        return $this->columnDefinitionSql($data);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function columnDefinitionSql(array $column): string
+    {
+        $name = $column['name'];
+        $column += [
+            'length' => null,
+            'precision' => null,
+        ];
+        $out = $this->_driver->quoteIdentifier($name);
+        $typeMap = [
+            TableSchemaInterface::TYPE_TINYINTEGER => ' SMALLINT',
+            TableSchemaInterface::TYPE_SMALLINTEGER => ' SMALLINT',
+            TableSchemaInterface::TYPE_INTEGER => ' INT',
+            TableSchemaInterface::TYPE_BIGINTEGER => ' BIGINT',
+            TableSchemaInterface::TYPE_BINARY => ' BYTEA',
+            TableSchemaInterface::TYPE_BINARY_UUID => ' UUID',
+            TableSchemaInterface::TYPE_BOOLEAN => ' BOOLEAN',
+            TableSchemaInterface::TYPE_FLOAT => ' FLOAT',
+            TableSchemaInterface::TYPE_DECIMAL => ' DECIMAL',
+            TableSchemaInterface::TYPE_DATE => ' DATE',
+            TableSchemaInterface::TYPE_TIME => ' TIME',
+            TableSchemaInterface::TYPE_DATETIME => ' TIMESTAMP',
+            TableSchemaInterface::TYPE_DATETIME_FRACTIONAL => ' TIMESTAMP',
+            TableSchemaInterface::TYPE_TIMESTAMP => ' TIMESTAMP',
+            TableSchemaInterface::TYPE_TIMESTAMP_FRACTIONAL => ' TIMESTAMP',
+            TableSchemaInterface::TYPE_TIMESTAMP_TIMEZONE => ' TIMESTAMPTZ',
+            TableSchemaInterface::TYPE_UUID => ' UUID',
+            TableSchemaInterface::TYPE_NATIVE_UUID => ' UUID',
+            TableSchemaInterface::TYPE_CHAR => ' CHAR',
+            TableSchemaInterface::TYPE_CITEXT => ' CITEXT',
+            TableSchemaInterface::TYPE_JSON => ' JSONB',
+            TableSchemaInterface::TYPE_INTERVAL => ' INTERVAL',
+            TableSchemaInterface::TYPE_GEOMETRY => ' GEOGRAPHY(GEOMETRY, %s)',
+            TableSchemaInterface::TYPE_POINT => ' GEOGRAPHY(POINT, %s)',
+            TableSchemaInterface::TYPE_LINESTRING => ' GEOGRAPHY(LINESTRING, %s)',
+            TableSchemaInterface::TYPE_POLYGON => ' GEOGRAPHY(POLYGON, %s)',
+            TableSchemaInterface::TYPE_CIDR => ' CIDR',
+            TableSchemaInterface::TYPE_INET => ' INET',
+            TableSchemaInterface::TYPE_MACADDR => ' MACADDR',
+        ];
+
+        $autoIncrementTypes = [
+            TableSchemaInterface::TYPE_TINYINTEGER,
+            TableSchemaInterface::TYPE_SMALLINTEGER,
+            TableSchemaInterface::TYPE_INTEGER,
+            TableSchemaInterface::TYPE_BIGINTEGER,
+        ];
+        $autoIncrement = (bool)($column['autoIncrement'] ?? false);
+        $isAutoincrement = (
+            in_array($column['type'], $autoIncrementTypes, true) &&
+            $autoIncrement
+        );
+        $version = $this->_driver->version();
+        $identityVersion = version_compare($version, '10.0', '>=');
+
+        if ($isAutoincrement && !$identityVersion) {
+            $typeMap[$column['type']] = str_replace('INT', 'SERIAL', $typeMap[$column['type']]);
+            unset($column['default']);
+        }
+
+        $foundType = false;
+        if (isset($typeMap[$column['type']])) {
+            $out .= $typeMap[$column['type']];
+            $foundType = true;
+        }
+
+        $hasLength = [
+            TableSchemaInterface::TYPE_CHAR,
+            TableSchemaInterface::TYPE_STRING,
+        ];
+        if ($column['type'] === TableSchemaInterface::TYPE_TEXT && $column['length'] !== TableSchema::LENGTH_TINY) {
+            $out .= ' TEXT';
+            $foundType = true;
+        } elseif (
+            $column['type'] === TableSchemaInterface::TYPE_STRING ||
+            (
+                $column['type'] === TableSchemaInterface::TYPE_TEXT &&
+                $column['length'] === TableSchema::LENGTH_TINY
+            )
+        ) {
+            $out .= ' VARCHAR';
+            $hasLength[] = $column['type'];
+            $foundType = true;
+        }
+
+        if (!$foundType) {
+            $out .= ' ' . strtoupper($column['type']);
+            $hasLength[] = $column['type'];
+        }
+
+        if (in_array($column['type'], $hasLength, true) && !empty($column['length'])) {
+            $out .= '(' . $column['length'] . ')';
+        }
+
+        $hasCollate = [
+            TableSchemaInterface::TYPE_TEXT,
+            TableSchemaInterface::TYPE_STRING,
+            TableSchemaInterface::TYPE_CHAR,
+        ];
+        if (in_array($column['type'], $hasCollate, true) && isset($column['collate']) && $column['collate'] !== '') {
+            $out .= ' COLLATE "' . $column['collate'] . '"';
+        }
+
+        $hasPrecision = [
+            TableSchemaInterface::TYPE_FLOAT,
+            TableSchemaInterface::TYPE_DATETIME,
+            TableSchemaInterface::TYPE_DATETIME_FRACTIONAL,
+            TableSchemaInterface::TYPE_TIMESTAMP,
+            TableSchemaInterface::TYPE_TIMESTAMP_FRACTIONAL,
+            TableSchemaInterface::TYPE_TIMESTAMP_TIMEZONE,
+        ];
+        if (in_array($column['type'], $hasPrecision) && isset($column['precision'])) {
+            $out .= '(' . $column['precision'] . ')';
+        }
+
+        if (
+            $column['type'] === TableSchemaInterface::TYPE_DECIMAL &&
+            (
+                isset($column['length']) ||
+                isset($column['precision'])
+            )
+        ) {
+            $out .= '(' . $column['length'] . ',' . (int)$column['precision'] . ')';
+        }
+        if (in_array($column['type'], TableSchemaInterface::GEOSPATIAL_TYPES)) {
+            $out = sprintf($out, $column['srid'] ?? self::DEFAULT_SRID);
+        }
+
+        if (isset($column['null']) && $column['null'] === false) {
+            $out .= ' NOT NULL';
+        }
+
+        if ($isAutoincrement && $identityVersion) {
+            $generated = $column['generated'] ?? static::GENERATED_BY_DEFAULT;
+            $out .= ' GENERATED ' . $generated . ' AS IDENTITY';
+        }
+
+        $datetimeTypes = [
+            TableSchemaInterface::TYPE_DATETIME,
+            TableSchemaInterface::TYPE_DATETIME_FRACTIONAL,
+            TableSchemaInterface::TYPE_TIMESTAMP,
+            TableSchemaInterface::TYPE_TIMESTAMP_FRACTIONAL,
+            TableSchemaInterface::TYPE_TIMESTAMP_TIMEZONE,
+        ];
+        if (
+            isset($column['default']) &&
+            in_array($column['type'], $datetimeTypes) &&
+            is_string($column['default']) &&
+            strtolower($column['default']) === 'current_timestamp'
+        ) {
+            $out .= ' DEFAULT CURRENT_TIMESTAMP';
+        } elseif (isset($column['default'])) {
+            $defaultValue = $column['default'];
+            if ($column['type'] === 'boolean') {
+                $defaultValue = (bool)$defaultValue;
+            }
+            $out .= ' DEFAULT ' . $this->_driver->schemaValue($defaultValue);
+        } elseif (isset($column['null']) && $column['null'] !== false) {
+            $out .= ' DEFAULT NULL';
+        }
+
+        return $out;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function addConstraintSql(TableSchema $schema): array
+    {
+        $sqlPattern = 'ALTER TABLE %s ADD %s;';
+        $sql = [];
+
+        foreach ($schema->constraints() as $name) {
+            $constraint = $schema->getConstraint($name);
+            assert($constraint !== null);
+            if ($constraint['type'] === TableSchema::CONSTRAINT_FOREIGN) {
+                $tableName = $this->_driver->quoteIdentifier($schema->name());
+                $sql[] = sprintf($sqlPattern, $tableName, $this->constraintSql($schema, $name));
+            }
+        }
+
+        return $sql;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function dropConstraintSql(TableSchema $schema): array
+    {
+        $sqlPattern = 'ALTER TABLE %s DROP CONSTRAINT %s;';
+        $sql = [];
+
+        foreach ($schema->constraints() as $name) {
+            $constraint = $schema->getConstraint($name);
+            assert($constraint !== null);
+            if ($constraint['type'] === TableSchema::CONSTRAINT_FOREIGN) {
+                $tableName = $this->_driver->quoteIdentifier($schema->name());
+                $constraintName = $this->_driver->quoteIdentifier($name);
+                $sql[] = sprintf($sqlPattern, $tableName, $constraintName);
+            }
+        }
+
+        return $sql;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function indexSql(TableSchema $schema, string $name): string
+    {
+        $index = $schema->index($name);
+        $columns = array_map(
+            $this->_driver->quoteIdentifier(...),
+            (array)$index->getColumns(),
+        );
+
+        // Build USING clause for non-btree access methods (gin, gist, spgist, brin, hash)
+        $using = '';
+        $accessMethod = $index->getAccessMethod();
+        if ($accessMethod !== null) {
+            $using = ' USING ' . $accessMethod;
+        }
+
+        $include = '';
+        $includes = $index->getInclude();
+        if ($includes) {
+            $included = array_map(
+                $this->_driver->quoteIdentifier(...),
+                $includes,
+            );
+            $include = sprintf(' INCLUDE (%s)', implode(', ', $included));
+        }
+
+        return sprintf(
+            'CREATE INDEX %s ON %s%s (%s)%s',
+            $this->_driver->quoteIdentifier($name),
+            $this->_driver->quoteIdentifier($schema->name()),
+            $using,
+            implode(', ', $columns),
+            $include,
+        );
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function constraintSql(TableSchema $schema, string $name): string
+    {
+        $data = $schema->getConstraint($name);
+        assert($data !== null);
+        $out = 'CONSTRAINT ' . $this->_driver->quoteIdentifier($name);
+        if ($data['type'] === TableSchema::CONSTRAINT_PRIMARY) {
+            $out = 'PRIMARY KEY';
+        } elseif ($data['type'] === TableSchema::CONSTRAINT_UNIQUE) {
+            $out .= ' UNIQUE';
+        } elseif ($data['type'] === TableSchema::CONSTRAINT_CHECK) {
+            return $out . ' CHECK (' . $data['expression'] . ')';
+        }
+
+        return $this->_keySql($out, $data);
+    }
+
+    /**
+     * Helper method for generating key SQL snippets.
+     *
+     * @param string $prefix The key prefix
+     * @param array $data Key data.
+     * @return string
+     */
+    protected function _keySql(string $prefix, array $data): string
+    {
+        $columns = array_map(
+            $this->_driver->quoteIdentifier(...),
+            $data['columns'],
+        );
+        if ($data['type'] === TableSchema::CONSTRAINT_FOREIGN) {
+            return $prefix . sprintf(
+                ' FOREIGN KEY (%s) REFERENCES %s (%s) ON UPDATE %s ON DELETE %s %s',
+                implode(', ', $columns),
+                $this->_driver->quoteIdentifier($data['references'][0]),
+                $this->_convertConstraintColumns($data['references'][1]),
+                $this->_foreignOnClause($data['update']),
+                $this->_foreignOnClause($data['delete']),
+                // Historically CakePHP used 'DEFERRABLE INITIALLY IMEDIATE, and this maintains backwards compat.
+                $data['deferrable'] ?? ForeignKey::IMMEDIATE,
+            );
+        }
+
+        return $prefix . ' (' . implode(', ', $columns) . ')';
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function createTableSql(TableSchema $schema, array $columns, array $constraints, array $indexes): array
+    {
+        $content = array_merge($columns, $constraints);
+        $content = implode(",\n", array_filter($content));
+        $tableName = $this->_driver->quoteIdentifier($schema->name());
+        $dbSchema = $this->_driver->schema();
+        if ($dbSchema !== 'public') {
+            $tableName = $this->_driver->quoteIdentifier($dbSchema) . '.' . $tableName;
+        }
+        $temporary = $schema->isTemporary() ? ' TEMPORARY ' : ' ';
+        $out = [];
+        $out[] = sprintf("CREATE%sTABLE %s (\n%s\n)", $temporary, $tableName, $content);
+        foreach ($indexes as $index) {
+            $out[] = $index;
+        }
+        foreach ($schema->columns() as $column) {
+            $columnData = $schema->getColumn($column);
+            if (isset($columnData['comment'])) {
+                $out[] = sprintf(
+                    'COMMENT ON COLUMN %s.%s IS %s',
+                    $tableName,
+                    $this->_driver->quoteIdentifier($column),
+                    $this->_driver->schemaValue($columnData['comment']),
+                );
+            }
+        }
+
+        return $out;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function truncateTableSql(TableSchema $schema): array
+    {
+        $name = $this->_driver->quoteIdentifier($schema->name());
+
+        return [
+            sprintf('TRUNCATE %s RESTART IDENTITY CASCADE', $name),
+        ];
+    }
+
+    /**
+     * Generate the SQL to drop a table.
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema Table instance
+     * @return array SQL statements to drop a table.
+     */
+    public function dropTableSql(TableSchema $schema): array
+    {
+        $sql = sprintf(
+            'DROP TABLE %s CASCADE',
+            $this->_driver->quoteIdentifier($schema->name()),
+        );
+
+        return [$sql];
+    }
+}
diff --git a/src/Database/Schema/SchemaDialect.php b/src/Database/Schema/SchemaDialect.php
new file mode 100644
index 00000000000..bd6980420f8
--- /dev/null
+++ b/src/Database/Schema/SchemaDialect.php
@@ -0,0 +1,760 @@
+ listTablesWithoutViewsSql(array $config) Generate the SQL to list the tables, excluding all views.
+ */
+abstract class SchemaDialect
+{
+    /**
+     * The driver instance being used.
+     *
+     * @var \Cake\Database\Driver
+     */
+    protected Driver $_driver;
+
+    /**
+     * Constructor
+     *
+     * This constructor will connect the driver so that methods like columnSql() and others
+     * will fail when the driver has not been connected.
+     *
+     * @param \Cake\Database\Driver $driver The driver to use.
+     */
+    public function __construct(Driver $driver)
+    {
+        $driver->connect();
+        $this->_driver = $driver;
+    }
+
+    /**
+     * Generate an ON clause for a foreign key.
+     *
+     * @param string $on The on clause
+     * @return string
+     */
+    protected function _foreignOnClause(string $on): string
+    {
+        if ($on === TableSchema::ACTION_SET_NULL) {
+            return 'SET NULL';
+        }
+        if ($on === TableSchema::ACTION_SET_DEFAULT) {
+            return 'SET DEFAULT';
+        }
+        if ($on === TableSchema::ACTION_CASCADE) {
+            return 'CASCADE';
+        }
+        if ($on === TableSchema::ACTION_RESTRICT) {
+            return 'RESTRICT';
+        }
+        if ($on === TableSchema::ACTION_NO_ACTION) {
+            return 'NO ACTION';
+        }
+
+        throw new InvalidArgumentException('Invalid value for "on": ' . $on);
+    }
+
+    /**
+     * Convert string on clauses to the abstract ones.
+     *
+     * @param string $clause The on clause to convert.
+     * @return string
+     */
+    protected function _convertOnClause(string $clause): string
+    {
+        if ($clause === 'CASCADE' || $clause === 'RESTRICT') {
+            return strtolower($clause);
+        }
+        if ($clause === 'NO ACTION') {
+            return TableSchema::ACTION_NO_ACTION;
+        }
+
+        return TableSchema::ACTION_SET_NULL;
+    }
+
+    /**
+     * Convert foreign key constraints references to a valid
+     * stringified list
+     *
+     * @param array|string $references The referenced columns of a foreign key constraint statement
+     * @return string
+     */
+    protected function _convertConstraintColumns(array|string $references): string
+    {
+        if (is_string($references)) {
+            return $this->_driver->quoteIdentifier($references);
+        }
+
+        return implode(', ', array_map(
+            $this->_driver->quoteIdentifier(...),
+            $references,
+        ));
+    }
+
+    /**
+     * Tries to use a matching database type to generate the SQL
+     * fragment for a single column in a table.
+     *
+     * @param string $columnType The column type.
+     * @param \Cake\Database\Schema\TableSchemaInterface $schema The table schema instance the column is in.
+     * @param string $column The name of the column.
+     * @return string|null An SQL fragment, or `null` in case no corresponding type was found or the type didn't provide
+     *  custom column SQL.
+     */
+    protected function _getTypeSpecificColumnSql(
+        string $columnType,
+        TableSchemaInterface $schema,
+        string $column,
+    ): ?string {
+        if (!TypeFactory::getMapped($columnType)) {
+            return null;
+        }
+
+        $type = TypeFactory::build($columnType);
+        if (!($type instanceof ColumnSchemaAwareInterface)) {
+            return null;
+        }
+
+        return $type->getColumnSql($schema, $column, $this->_driver);
+    }
+
+    /**
+     * Tries to use a matching database type to convert a SQL column
+     * definition to an abstract type definition.
+     *
+     * @param string $columnType The column type.
+     * @param array $definition The column definition.
+     * @return array|null Array of column information, or `null`
+     *  in case no corresponding type was found or the type didn't provide custom column information.
+     */
+    protected function _applyTypeSpecificColumnConversion(string $columnType, array $definition): ?array
+    {
+        if (!TypeFactory::getMapped($columnType)) {
+            return null;
+        }
+
+        $type = TypeFactory::build($columnType);
+        if (!($type instanceof ColumnSchemaAwareInterface)) {
+            return null;
+        }
+
+        return $type->convertColumnDefinition($definition, $this->_driver);
+    }
+
+    /**
+     * Generate the SQL to drop a table.
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema Schema instance
+     * @return array SQL statements to drop a table.
+     */
+    public function dropTableSql(TableSchema $schema): array
+    {
+        $sql = sprintf(
+            'DROP TABLE %s',
+            $this->_driver->quoteIdentifier($schema->name()),
+        );
+
+        return [$sql];
+    }
+
+    /**
+     * Generate the SQL to list the tables.
+     *
+     * @param array $config The connection configuration to use for
+     *    getting tables from.
+     * @return array An array of (sql, params) to execute.
+     * @deprecated 5.2.0 Use `listTables()` instead.
+     */
+    abstract public function listTablesSql(array $config): array;
+
+    /**
+     * Generate the SQL to describe a table.
+     *
+     * @param string $tableName The table name to get information on.
+     * @param array $config The connection configuration.
+     * @return array An array of (sql, params) to execute.
+     * @deprecated 5.2.0 Use `describeColumns()` instead.
+     */
+    abstract public function describeColumnSql(string $tableName, array $config): array;
+
+    /**
+     * Generate the SQL to describe the indexes in a table.
+     *
+     * @param string $tableName The table name to get information on.
+     * @param array $config The connection configuration.
+     * @return array An array of (sql, params) to execute.
+     * @deprecated 5.2.0 Use `describeIndexes()` instead.
+     */
+    abstract public function describeIndexSql(string $tableName, array $config): array;
+
+    /**
+     * Generate the SQL to describe the foreign keys in a table.
+     *
+     * @param string $tableName The table name to get information on.
+     * @param array $config The connection configuration.
+     * @return array An array of (sql, params) to execute.
+     * @deprecated 5.2.0 Use `describeForeignKeys()` instead.
+     */
+    abstract public function describeForeignKeySql(string $tableName, array $config): array;
+
+    /**
+     * Generate the SQL to describe table options
+     *
+     * @param string $tableName Table name.
+     * @param array $config The connection configuration.
+     * @return array SQL statements to get options for a table.
+     * @deprecated 5.2.0 Use `describeOptions()` instead.
+     */
+    public function describeOptionsSql(string $tableName, array $config): array
+    {
+        return ['', ''];
+    }
+
+    /**
+     * Convert field description results into abstract schema fields.
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema The table object to append fields to.
+     * @param array $row The row data from `describeColumnSql`.
+     * @return void
+     * @deprecated 5.2.0 Use `describeColumns()` instead.
+     */
+    abstract public function convertColumnDescription(TableSchema $schema, array $row): void;
+
+    /**
+     * Convert an index description results into abstract schema indexes or constraints.
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema The table object to append
+     *    an index or constraint to.
+     * @param array $row The row data from `describeIndexSql`.
+     * @return void
+     * @deprecated 5.2.0 Use `describeIndexes()` instead.
+     */
+    abstract public function convertIndexDescription(TableSchema $schema, array $row): void;
+
+    /**
+     * Convert a foreign key description into constraints on the Table object.
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema The table object to append
+     *    a constraint to.
+     * @param array $row The row data from `describeForeignKeySql`.
+     * @return void
+     * @deprecated 5.2.0 Use `describeForeignKeys()` instead.
+     */
+    abstract public function convertForeignKeyDescription(TableSchema $schema, array $row): void;
+
+    /**
+     * Convert options data into table options.
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema Table instance.
+     * @param array $row The row of data.
+     * @return void
+     * @deprecated 5.2.0 Use `describeOptions()` instead.
+     */
+    public function convertOptionsDescription(TableSchema $schema, array $row): void
+    {
+    }
+
+    /**
+     * Generate the SQL to create a table.
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema Table instance.
+     * @param array $columns The columns to go inside the table.
+     * @param array $constraints The constraints for the table.
+     * @param array $indexes The indexes for the table.
+     * @return array SQL statements to create a table.
+     */
+    abstract public function createTableSql(
+        TableSchema $schema,
+        array $columns,
+        array $constraints,
+        array $indexes,
+    ): array;
+
+    /**
+     * Generate the SQL fragment for a single column in a table.
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema The table instance the column is in.
+     * @param string $name The name of the column.
+     * @return string SQL fragment.
+     */
+    abstract public function columnSql(TableSchema $schema, string $name): string;
+
+    /**
+     * Generate the SQL queries needed to add foreign key constraints to the table
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema The table instance the foreign key constraints are.
+     * @return array SQL fragment.
+     */
+    abstract public function addConstraintSql(TableSchema $schema): array;
+
+    /**
+     * Generate the SQL queries needed to drop foreign key constraints from the table
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema The table instance the foreign key constraints are.
+     * @return array SQL fragment.
+     */
+    abstract public function dropConstraintSql(TableSchema $schema): array;
+
+    /**
+     * Generate the SQL fragments for defining table constraints.
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema The table instance the column is in.
+     * @param string $name The name of the column.
+     * @return string SQL fragment.
+     */
+    abstract public function constraintSql(TableSchema $schema, string $name): string;
+
+    /**
+     * Generate the SQL fragment for a single index in a table.
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema The table object the column is in.
+     * @param string $name The name of the column.
+     * @return string SQL fragment.
+     */
+    abstract public function indexSql(TableSchema $schema, string $name): string;
+
+    /**
+     * Generate the SQL to truncate a table.
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema Table instance.
+     * @return array SQL statements to truncate a table.
+     */
+    abstract public function truncateTableSql(TableSchema $schema): array;
+
+    /**
+     * Create a SQL snippet for a column based on the array shape
+     * that `describeColumns()` creates.
+     *
+     * @param array $column The column metadata
+     * @return string Generated SQL fragment for a column
+     */
+    public function columnDefinitionSql(array $column): string
+    {
+        deprecationWarning(
+            '5.2.0',
+            'SchemaDialect subclasses need to implement `columnDefinitionSql` before 6.0.0',
+        );
+        $table = new TableSchema('placeholder');
+        $table->addColumn($column['name'], $column);
+
+        return $this->columnSql($table, $column['name']);
+    }
+
+    /**
+     * Get the list of tables, excluding any views, available in the current connection.
+     *
+     * @return array The list of tables in the connected database/schema.
+     */
+    public function listTablesWithoutViews(): array
+    {
+        [$sql, $params] = $this->listTablesWithoutViewsSql($this->_driver->config());
+        $result = [];
+        $statement = $this->_driver->execute($sql, $params);
+        while ($row = $statement->fetch()) {
+            $result[] = $row[0];
+        }
+
+        return $result;
+    }
+
+    /**
+     * Get the list of tables and views available in the current connection.
+     *
+     * @param string|null $schema The schema to get the tables for. If null the default schema is used.
+     * @return array The list of tables and views in the connected database/schema.
+     */
+    public function listTables(?string $schema = null): array
+    {
+        $config = $this->_driver->config();
+        if ($schema !== null) {
+            $config['schema'] = $schema;
+            // Set database for MySQL
+            $config['database'] = $schema;
+        }
+        [$sql, $params] = $this->listTablesSql($config);
+        $result = [];
+        $statement = $this->_driver->execute($sql, $params);
+        while ($row = $statement->fetch()) {
+            $result[] = $row[0];
+        }
+
+        return $result;
+    }
+
+    /**
+     * Get the column metadata for a table.
+     *
+     * The name can include a database schema name in the form 'schema.table'.
+     *
+     * @param string $name The name of the table to describe.
+     * @return \Cake\Database\Schema\TableSchemaInterface Object with column metadata.
+     * @throws \Cake\Database\Exception\DatabaseException when table cannot be described.
+     */
+    public function describe(string $name): TableSchemaInterface
+    {
+        $tableName = $name;
+        if (str_contains($name, '.')) {
+            $tableName = explode('.', $name)[1];
+        }
+        $table = $this->_driver->newTableSchema($tableName);
+        foreach ($this->describeColumns($name) as $column) {
+            $table->addColumn($column['name'], $column);
+        }
+        foreach ($this->describeIndexes($name) as $index) {
+            if (in_array($index['type'], [TableSchema::CONSTRAINT_UNIQUE, TableSchema::CONSTRAINT_PRIMARY])) {
+                $table->addConstraint($index['name'], $index);
+            } else {
+                $table->addIndex($index['name'], $index);
+            }
+        }
+        foreach ($this->describeForeignKeys($name) as $key) {
+            $table->addConstraint($key['name'], $key);
+        }
+        foreach ($this->describeCheckConstraints($name) as $key) {
+            $table->addConstraint($key['name'], $key);
+        }
+        $options = $this->describeOptions($name);
+        if ($options) {
+            $table->setOptions($options);
+        }
+        if ($table->columns() === []) {
+            throw new DatabaseException(sprintf('Cannot describe %s. It has 0 columns.', $name));
+        }
+
+        return $table;
+    }
+
+    /**
+     * Get a list of column metadata as a array
+     *
+     * Each item in the array will contain the following:
+     *
+     * - name : the name of the column.
+     * - type : the abstract type of the column.
+     * - length : the length of the column.
+     * - default : the default value of the column or null.
+     * - null : boolean indicating whether the column can be null.
+     * - comment : the column comment or null.
+     *
+     * Additionaly the `autoIncrement` key will be set for columns that are a primary key.
+     *
+     * @param string $tableName The name of the table to describe columns on.
+     * @return array
+     */
+    public function describeColumns(string $tableName): array
+    {
+        deprecationWarning(
+            '5.2.0',
+            'SchemaDialect subclasses need to implement `describeColumns` before 6.0.0',
+        );
+        $config = $this->_driver->config();
+        if (str_contains($tableName, '.')) {
+            [$config['schema'], $tableName] = explode('.', $tableName);
+        }
+        /** @var \Cake\Database\Schema\TableSchema $table */
+        $table = $this->_driver->newTableSchema($tableName);
+
+        [$sql, $params] = $this->describeColumnSql($tableName, $config);
+        $statement = $this->_driver->execute($sql, $params);
+        foreach ($statement->fetchAll('assoc') as $row) {
+            $this->convertColumnDescription($table, $row);
+        }
+        $columns = [];
+        foreach ($table->columns() as $columnName) {
+            $column = $table->getColumn($columnName);
+            $column['name'] = $columnName;
+            $columns[] = $column;
+        }
+
+        return $columns;
+    }
+
+    /**
+     * Get a list of constraint metadata as a array
+     *
+     * Each item in the array will contain the following:
+     *
+     * - name : The name of the constraint
+     * - type : the type of the constraint. Generally `foreign`.
+     * - columns : the columns in the constraint on the.
+     * - references : A list of the table + all columns in the referenced table
+     * - update : The update action or null
+     * - delete : The delete action or null
+     *
+     * @param string $tableName The name of the table to describe foreign keys on.
+     * @return array
+     */
+    public function describeForeignKeys(string $tableName): array
+    {
+        deprecationWarning(
+            '5.2.0',
+            'SchemaDialect subclasses need to implement `describeForeignKeys` before 6.0.0',
+        );
+        $config = $this->_driver->config();
+        if (str_contains($tableName, '.')) {
+            [$config['schema'], $tableName] = explode('.', $tableName);
+        }
+        /** @var \Cake\Database\Schema\TableSchema $table */
+        $table = $this->_driver->newTableSchema($tableName);
+        // Add the columns because TableSchema needs them.
+        foreach ($this->describeColumns($tableName) as $column) {
+            $table->addColumn($column['name'], $column);
+        }
+
+        [$sql, $params] = $this->describeForeignKeySql($tableName, $config);
+        $statement = $this->_driver->execute($sql, $params);
+        foreach ($statement->fetchAll('assoc') as $row) {
+            $this->convertForeignKeyDescription($table, $row);
+        }
+        $keys = [];
+        foreach ($table->constraints() as $name) {
+            $key = $table->getConstraint($name);
+            $key['name'] = $name;
+            $keys[] = $key;
+        }
+
+        return $keys;
+    }
+
+    /**
+     * Get a list of index metadata as a array
+     *
+     * Each item in the array will contain the following:
+     *
+     * - name : the name of the index.
+     * - type : the type of the index. One of `unique`, `index`, `primary`.
+     * - columns : the columns in the index.
+     * - length : the length of the index if applicable.
+     *
+     * @param string $tableName The name of the table to describe indexes on.
+     * @return array
+     */
+    public function describeIndexes(string $tableName): array
+    {
+        deprecationWarning(
+            '5.2.0',
+            'SchemaDialect subclasses need to implement `describeIndexes` before 6.0.0',
+        );
+        $config = $this->_driver->config();
+        if (str_contains($tableName, '.')) {
+            [$config['schema'], $tableName] = explode('.', $tableName);
+        }
+        /** @var \Cake\Database\Schema\TableSchema $table */
+        $table = $this->_driver->newTableSchema($tableName);
+        // Add the columns because TableSchema needs them.
+        foreach ($this->describeColumns($tableName) as $column) {
+            $table->addColumn($column['name'], $column);
+        }
+
+        [$sql, $params] = $this->describeIndexSql($tableName, $config);
+        $statement = $this->_driver->execute($sql, $params);
+        foreach ($statement->fetchAll('assoc') as $row) {
+            $this->convertIndexDescription($table, $row);
+        }
+        $indexes = [];
+        foreach ($table->indexes() as $name) {
+            $index = $table->getIndex($name);
+            $index['name'] = $name;
+            $indexes[] = $index;
+        }
+
+        return $indexes;
+    }
+
+    /**
+     * Get platform specific options
+     *
+     * No keys are guaranteed to be present as they are database driver dependent.
+     *
+     * @param string $tableName The name of the table to describe options on.
+     * @return array
+     */
+    public function describeOptions(string $tableName): array
+    {
+        deprecationWarning(
+            '5.2.0',
+            'SchemaDialect subclasses need to implement `describeOptions` before 6.0.0',
+        );
+        $config = $this->_driver->config();
+        if (str_contains($tableName, '.')) {
+            [$config['schema'], $tableName] = explode('.', $tableName);
+        }
+        /** @var \Cake\Database\Schema\TableSchema $table */
+        $table = $this->_driver->newTableSchema($tableName);
+
+        [$sql, $params] = $this->describeOptionsSql($tableName, $config);
+        if ($sql) {
+            $statement = $this->_driver->execute($sql, $params);
+            foreach ($statement->fetchAll('assoc') as $row) {
+                $this->convertOptionsDescription($table, $row);
+            }
+        }
+
+        return $table->getOptions();
+    }
+
+    /**
+     * Get a list of check constraint metadata as an array.
+     *
+     * Each item in the array will contain the following keys:
+     *
+     * - name - The name of the constraint.
+     * - expression - The check constraint expression as a SQL fragment.
+     *
+     * @param string $tableName The name of the table to describe options on.
+     * @return array
+     */
+    public function describeCheckConstraints(string $tableName): array
+    {
+        return [];
+    }
+
+    /**
+     * Check if a table has a column with a given name.
+     *
+     * @param string $tableName The name of the table
+     * @param string $columnName The name of the column
+     * @return bool
+     */
+    public function hasColumn(string $tableName, string $columnName): bool
+    {
+        try {
+            $columns = $this->describeColumns($tableName);
+        } catch (PDOException | DatabaseException) {
+            return false;
+        }
+        foreach ($columns as $column) {
+            if ($column['name'] === $columnName) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Check if a table exists
+     *
+     * @param string $tableName The name of the table
+     * @param string|null $schema The schema look for table in. If null the default schema is used.
+     * @return bool
+     */
+    public function hasTable(string $tableName, ?string $schema = null): bool
+    {
+        $tables = $this->listTables($schema);
+
+        return in_array($tableName, $tables, true);
+    }
+
+    /**
+     * Check if a table has an index with a given name.
+     *
+     * @param string $tableName The name of the table
+     * @param array $columns The columns in the index. Specific
+     *   ordering matters.
+     * @param string $name The name of the index to match on. Can be used alone,
+     *   or with $columns to match indexes more precisely.
+     * @return bool
+     */
+    public function hasIndex(string $tableName, array $columns = [], ?string $name = null): bool
+    {
+        try {
+            $indexes = $this->describeIndexes($tableName);
+        } catch (QueryException) {
+            return false;
+        }
+        $found = null;
+        foreach ($indexes as $index) {
+            if ($columns && $index['columns'] === $columns) {
+                $found = $index;
+                break;
+            }
+            if ($columns === [] && $name !== null) {
+                if ($index['name'] === $name) {
+                    $found = $index;
+                    break;
+                }
+                if (isset($index['constraint']) && $index['constraint'] === $name) {
+                    $found = $index;
+                    break;
+                }
+            }
+        }
+        // Both columns and name provided, both must match;
+        if ($columns && $found && $name !== null && $found['name'] !== $name) {
+            return false;
+        }
+
+        return $found !== null;
+    }
+
+    /**
+     * Check if a table has a foreign key with a given name.
+     *
+     * @param string $tableName The name of the table
+     * @param array $columns The columns in the foreign key. Specific
+     *   ordering matters.
+     * @param string $name The name of the foreign key to match on. Can be used alone,
+     *   or with $columns to match keys more precisely.
+     * @return bool
+     */
+    public function hasForeignKey(string $tableName, array $columns = [], ?string $name = null): bool
+    {
+        try {
+            $keys = $this->describeForeignKeys($tableName);
+        } catch (QueryException) {
+            return false;
+        }
+        $found = null;
+        foreach ($keys as $key) {
+            if ($columns && $key['columns'] === $columns) {
+                $found = $key;
+                break;
+            }
+            if (!$columns && $name !== null && $key['name'] === $name) {
+                $found = $key;
+                break;
+            }
+        }
+        // Both columns and name provided, both must match;
+        if ($found !== null && $name !== null && $found['name'] !== $name) {
+            return false;
+        }
+
+        return $found !== null;
+    }
+}
diff --git a/src/Database/Schema/SqlGeneratorInterface.php b/src/Database/Schema/SqlGeneratorInterface.php
index f71111dfadf..9fbd5919f8a 100644
--- a/src/Database/Schema/SqlGeneratorInterface.php
+++ b/src/Database/Schema/SqlGeneratorInterface.php
@@ -1,16 +1,18 @@
  TableSchema::TYPE_BIGINTEGER, 'length' => $length, 'unsigned' => $unsigned];
-        }
-        if ($col == 'smallint') {
-            return ['type' => TableSchema::TYPE_SMALLINTEGER, 'length' => $length, 'unsigned' => $unsigned];
-        }
-        if ($col == 'tinyint') {
-            return ['type' => TableSchema::TYPE_TINYINTEGER, 'length' => $length, 'unsigned' => $unsigned];
-        }
-        if (strpos($col, 'int') !== false) {
-            return ['type' => TableSchema::TYPE_INTEGER, 'length' => $length, 'unsigned' => $unsigned];
-        }
-        if (strpos($col, 'decimal') !== false) {
-            return ['type' => TableSchema::TYPE_DECIMAL, 'length' => null, 'unsigned' => $unsigned];
-        }
-        if (in_array($col, ['float', 'real', 'double'])) {
-            return ['type' => TableSchema::TYPE_FLOAT, 'length' => null, 'unsigned' => $unsigned];
-        }
-
-        if (strpos($col, 'boolean') !== false) {
-            return ['type' => TableSchema::TYPE_BOOLEAN, 'length' => null];
-        }
-
-        if ($col === 'char' && $length === 36) {
-            return ['type' => TableSchema::TYPE_UUID, 'length' => null];
-        }
-        if ($col === 'char') {
-            return ['type' => TableSchema::TYPE_STRING, 'fixed' => true, 'length' => $length];
-        }
-        if (strpos($col, 'char') !== false) {
-            return ['type' => TableSchema::TYPE_STRING, 'length' => $length];
-        }
-
-        if (in_array($col, ['blob', 'clob'])) {
-            return ['type' => TableSchema::TYPE_BINARY, 'length' => null];
-        }
-        if (in_array($col, ['date', 'time', 'timestamp', 'datetime'])) {
-            return ['type' => $col, 'length' => null];
-        }
-
-        return ['type' => TableSchema::TYPE_TEXT, 'length' => null];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function listTablesSql($config)
-    {
-        return [
-            'SELECT name FROM sqlite_master WHERE type="table" ' .
-            'AND name != "sqlite_sequence" ORDER BY name',
-            []
-        ];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function describeColumnSql($tableName, $config)
-    {
-        $sql = sprintf(
-            'PRAGMA table_info(%s)',
-            $this->_driver->quoteIdentifier($tableName)
-        );
-
-        return [$sql, []];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function convertColumnDescription(TableSchema $schema, $row)
-    {
-        $field = $this->_convertColumn($row['type']);
-        $field += [
-            'null' => !$row['notnull'],
-            'default' => $this->_defaultValue($row['dflt_value']),
-        ];
-        $primary = $schema->getConstraint('primary');
-
-        if ($row['pk'] && empty($primary)) {
-            $field['null'] = false;
-            $field['autoIncrement'] = true;
-        }
-
-        // SQLite does not support autoincrement on composite keys.
-        if ($row['pk'] && !empty($primary)) {
-            $existingColumn = $primary['columns'][0];
-            $schema->addColumn($existingColumn, ['autoIncrement' => null] + $schema->getColumn($existingColumn));
-        }
-
-        $schema->addColumn($row['name'], $field);
-        if ($row['pk']) {
-            $constraint = (array)$schema->getConstraint('primary') + [
-                'type' => TableSchema::CONSTRAINT_PRIMARY,
-                'columns' => []
-            ];
-            $constraint['columns'] = array_merge($constraint['columns'], [$row['name']]);
-            $schema->addConstraint('primary', $constraint);
-        }
-    }
-
-    /**
-     * Manipulate the default value.
-     *
-     * Sqlite includes quotes and bared NULLs in default values.
-     * We need to remove those.
-     *
-     * @param string|null $default The default value.
-     * @return string|null
-     */
-    protected function _defaultValue($default)
-    {
-        if ($default === 'NULL') {
-            return null;
-        }
-
-        // Remove quotes
-        if (preg_match("/^'(.*)'$/", $default, $matches)) {
-            return str_replace("''", "'", $matches[1]);
-        }
-
-        return $default;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function describeIndexSql($tableName, $config)
-    {
-        $sql = sprintf(
-            'PRAGMA index_list(%s)',
-            $this->_driver->quoteIdentifier($tableName)
-        );
-
-        return [$sql, []];
-    }
-
-    /**
-     * {@inheritDoc}
-     *
-     * Since SQLite does not have a way to get metadata about all indexes at once,
-     * additional queries are done here. Sqlite constraint names are not
-     * stable, and the names for constraints will not match those used to create
-     * the table. This is a limitation in Sqlite's metadata features.
-     *
-     */
-    public function convertIndexDescription(TableSchema $schema, $row)
-    {
-        $sql = sprintf(
-            'PRAGMA index_info(%s)',
-            $this->_driver->quoteIdentifier($row['name'])
-        );
-        $statement = $this->_driver->prepare($sql);
-        $statement->execute();
-        $columns = [];
-        foreach ($statement->fetchAll('assoc') as $column) {
-            $columns[] = $column['name'];
-        }
-        $statement->closeCursor();
-        if ($row['unique']) {
-            $schema->addConstraint($row['name'], [
-                'type' => TableSchema::CONSTRAINT_UNIQUE,
-                'columns' => $columns
-            ]);
-        } else {
-            $schema->addIndex($row['name'], [
-                'type' => TableSchema::INDEX_INDEX,
-                'columns' => $columns
-            ]);
-        }
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function describeForeignKeySql($tableName, $config)
-    {
-        $sql = sprintf('PRAGMA foreign_key_list(%s)', $this->_driver->quoteIdentifier($tableName));
-
-        return [$sql, []];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function convertForeignKeyDescription(TableSchema $schema, $row)
-    {
-        $name = $row['from'] . '_fk';
-
-        $update = isset($row['on_update']) ? $row['on_update'] : '';
-        $delete = isset($row['on_delete']) ? $row['on_delete'] : '';
-        $data = [
-            'type' => TableSchema::CONSTRAINT_FOREIGN,
-            'columns' => [$row['from']],
-            'references' => [$row['table'], $row['to']],
-            'update' => $this->_convertOnClause($update),
-            'delete' => $this->_convertOnClause($delete),
-        ];
-
-        if (isset($this->_constraintsIdMap[$schema->name()][$row['id']])) {
-            $name = $this->_constraintsIdMap[$schema->name()][$row['id']];
-        } else {
-            $this->_constraintsIdMap[$schema->name()][$row['id']] = $name;
-        }
-
-        $schema->addConstraint($name, $data);
-    }
-
-    /**
-     * {@inheritDoc}
-     *
-     * @throws \Cake\Database\Exception when the column type is unknown
-     */
-    public function columnSql(TableSchema $schema, $name)
-    {
-        $data = $schema->getColumn($name);
-        $typeMap = [
-            TableSchema::TYPE_UUID => ' CHAR(36)',
-            TableSchema::TYPE_TINYINTEGER => ' TINYINT',
-            TableSchema::TYPE_SMALLINTEGER => ' SMALLINT',
-            TableSchema::TYPE_INTEGER => ' INTEGER',
-            TableSchema::TYPE_BIGINTEGER => ' BIGINT',
-            TableSchema::TYPE_BOOLEAN => ' BOOLEAN',
-            TableSchema::TYPE_BINARY => ' BLOB',
-            TableSchema::TYPE_FLOAT => ' FLOAT',
-            TableSchema::TYPE_DECIMAL => ' DECIMAL',
-            TableSchema::TYPE_DATE => ' DATE',
-            TableSchema::TYPE_TIME => ' TIME',
-            TableSchema::TYPE_DATETIME => ' DATETIME',
-            TableSchema::TYPE_TIMESTAMP => ' TIMESTAMP',
-            TableSchema::TYPE_JSON => ' TEXT'
-        ];
-
-        $out = $this->_driver->quoteIdentifier($name);
-        $hasUnsigned = [
-            TableSchema::TYPE_TINYINTEGER,
-            TableSchema::TYPE_SMALLINTEGER,
-            TableSchema::TYPE_INTEGER,
-            TableSchema::TYPE_BIGINTEGER,
-            TableSchema::TYPE_FLOAT,
-            TableSchema::TYPE_DECIMAL
-        ];
-
-        if (in_array($data['type'], $hasUnsigned, true) &&
-            isset($data['unsigned']) && $data['unsigned'] === true
-        ) {
-            if ($data['type'] !== TableSchema::TYPE_INTEGER || [$name] !== (array)$schema->primaryKey()) {
-                $out .= ' UNSIGNED';
-            }
-        }
-
-        if (isset($typeMap[$data['type']])) {
-            $out .= $typeMap[$data['type']];
-        }
-
-        if ($data['type'] === TableSchema::TYPE_TEXT && $data['length'] !== TableSchema::LENGTH_TINY) {
-            $out .= ' TEXT';
-        }
-
-        if ($data['type'] === TableSchema::TYPE_STRING ||
-            ($data['type'] === TableSchema::TYPE_TEXT && $data['length'] === TableSchema::LENGTH_TINY)
-        ) {
-            $out .= ' VARCHAR';
-
-            if (isset($data['length'])) {
-                $out .= '(' . (int)$data['length'] . ')';
-            }
-        }
-
-        $integerTypes = [
-            TableSchema::TYPE_TINYINTEGER,
-            TableSchema::TYPE_SMALLINTEGER,
-            TableSchema::TYPE_INTEGER,
-        ];
-        if (in_array($data['type'], $integerTypes, true) &&
-            isset($data['length']) && [$name] !== (array)$schema->primaryKey()
-        ) {
-                $out .= '(' . (int)$data['length'] . ')';
-        }
-
-        $hasPrecision = [TableSchema::TYPE_FLOAT, TableSchema::TYPE_DECIMAL];
-        if (in_array($data['type'], $hasPrecision, true) &&
-            (isset($data['length']) || isset($data['precision']))
-        ) {
-            $out .= '(' . (int)$data['length'] . ',' . (int)$data['precision'] . ')';
-        }
-
-        if (isset($data['null']) && $data['null'] === false) {
-            $out .= ' NOT NULL';
-        }
-
-        if ($data['type'] === TableSchema::TYPE_INTEGER && [$name] === (array)$schema->primaryKey()) {
-            $out .= ' PRIMARY KEY AUTOINCREMENT';
-        }
-
-        if (isset($data['null']) && $data['null'] === true && $data['type'] === TableSchema::TYPE_TIMESTAMP) {
-            $out .= ' DEFAULT NULL';
-        }
-        if (isset($data['default'])) {
-            $out .= ' DEFAULT ' . $this->_driver->schemaValue($data['default']);
-        }
-
-        return $out;
-    }
-
-    /**
-     * {@inheritDoc}
-     *
-     * Note integer primary keys will return ''. This is intentional as Sqlite requires
-     * that integer primary keys be defined in the column definition.
-     *
-     */
-    public function constraintSql(TableSchema $schema, $name)
-    {
-        $data = $schema->getConstraint($name);
-        if ($data['type'] === TableSchema::CONSTRAINT_PRIMARY &&
-            count($data['columns']) === 1 &&
-            $schema->getColumn($data['columns'][0])['type'] === TableSchema::TYPE_INTEGER
-        ) {
-            return '';
-        }
-        $clause = '';
-        $type = '';
-        if ($data['type'] === TableSchema::CONSTRAINT_PRIMARY) {
-            $type = 'PRIMARY KEY';
-        }
-        if ($data['type'] === TableSchema::CONSTRAINT_UNIQUE) {
-            $type = 'UNIQUE';
-        }
-        if ($data['type'] === TableSchema::CONSTRAINT_FOREIGN) {
-            $type = 'FOREIGN KEY';
-
-            $clause = sprintf(
-                ' REFERENCES %s (%s) ON UPDATE %s ON DELETE %s',
-                $this->_driver->quoteIdentifier($data['references'][0]),
-                $this->_convertConstraintColumns($data['references'][1]),
-                $this->_foreignOnClause($data['update']),
-                $this->_foreignOnClause($data['delete'])
-            );
-        }
-        $columns = array_map(
-            [$this->_driver, 'quoteIdentifier'],
-            $data['columns']
-        );
-
-        return sprintf(
-            'CONSTRAINT %s %s (%s)%s',
-            $this->_driver->quoteIdentifier($name),
-            $type,
-            implode(', ', $columns),
-            $clause
-        );
-    }
-
-    /**
-     * {@inheritDoc}
-     *
-     * SQLite can not properly handle adding a constraint to an existing table.
-     * This method is no-op
-     */
-    public function addConstraintSql(TableSchema $schema)
-    {
-        return [];
-    }
-
-    /**
-     * {@inheritDoc}
-     *
-     * SQLite can not properly handle dropping a constraint to an existing table.
-     * This method is no-op
-     */
-    public function dropConstraintSql(TableSchema $schema)
-    {
-        return [];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function indexSql(TableSchema $schema, $name)
-    {
-        $data = $schema->getIndex($name);
-        $columns = array_map(
-            [$this->_driver, 'quoteIdentifier'],
-            $data['columns']
-        );
-
-        return sprintf(
-            'CREATE INDEX %s ON %s (%s)',
-            $this->_driver->quoteIdentifier($name),
-            $this->_driver->quoteIdentifier($schema->name()),
-            implode(', ', $columns)
-        );
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function createTableSql(TableSchema $schema, $columns, $constraints, $indexes)
-    {
-        $lines = array_merge($columns, $constraints);
-        $content = implode(",\n", array_filter($lines));
-        $temporary = $schema->isTemporary() ? ' TEMPORARY ' : ' ';
-        $table = sprintf("CREATE%sTABLE \"%s\" (\n%s\n)", $temporary, $schema->name(), $content);
-        $out = [$table];
-        foreach ($indexes as $index) {
-            $out[] = $index;
-        }
-
-        return $out;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function truncateTableSql(TableSchema $schema)
-    {
-        $name = $schema->name();
-        $sql = [];
-        if ($this->hasSequences()) {
-            $sql[] = sprintf('DELETE FROM sqlite_sequence WHERE name="%s"', $name);
-        }
-
-        $sql[] = sprintf('DELETE FROM "%s"', $name);
-
-        return $sql;
-    }
-
-    /**
-     * Returns whether there is any table in this connection to SQLite containing
-     * sequences
-     *
-     * @return bool
-     */
-    public function hasSequences()
-    {
-        $result = $this->_driver
-            ->prepare('SELECT 1 FROM sqlite_master WHERE name = "sqlite_sequence"');
-        $result->execute();
-        $this->_hasSequences = (bool)$result->rowCount();
-        $result->closeCursor();
-
-        return $this->_hasSequences;
-    }
-}
diff --git a/src/Database/Schema/SqliteSchemaDialect.php b/src/Database/Schema/SqliteSchemaDialect.php
new file mode 100644
index 00000000000..76aa4e23422
--- /dev/null
+++ b/src/Database/Schema/SqliteSchemaDialect.php
@@ -0,0 +1,1077 @@
+ Array of column information.
+     */
+    protected function _convertColumn(string $column): array
+    {
+        if ($column === '') {
+            return ['type' => TableSchemaInterface::TYPE_TEXT, 'length' => null];
+        }
+
+        preg_match('/(unsigned)?\s*([a-z]+)(?:\(([0-9,]+)\))?/i', $column, $matches);
+        if (!$matches) {
+            throw new DatabaseException(sprintf('Unable to parse column type from `%s`', $column));
+        }
+
+        $unsigned = false;
+        if (strtolower($matches[1]) === 'unsigned') {
+            $unsigned = true;
+        }
+
+        $col = strtolower($matches[2]);
+        $length = null;
+        $precision = null;
+        $scale = null;
+        if (isset($matches[3])) {
+            $length = $matches[3];
+            if (str_contains($length, ',')) {
+                [$length, $precision] = explode(',', $length);
+            }
+            $length = (int)$length;
+            $precision = (int)$precision;
+        }
+
+        $type = $this->_applyTypeSpecificColumnConversion(
+            $col,
+            compact('length', 'precision', 'scale'),
+        );
+        if ($type !== null) {
+            return $type;
+        }
+
+        if ($col === 'bigint') {
+            return ['type' => TableSchemaInterface::TYPE_BIGINTEGER, 'length' => $length, 'unsigned' => $unsigned];
+        }
+        if ($col === 'smallint') {
+            return ['type' => TableSchemaInterface::TYPE_SMALLINTEGER, 'length' => $length, 'unsigned' => $unsigned];
+        }
+        if ($col === 'tinyint') {
+            return ['type' => TableSchemaInterface::TYPE_TINYINTEGER, 'length' => $length, 'unsigned' => $unsigned];
+        }
+        if (str_contains($col, 'int') && $col !== 'point') {
+            return ['type' => TableSchemaInterface::TYPE_INTEGER, 'length' => $length, 'unsigned' => $unsigned];
+        }
+        if (str_contains($col, 'decimal')) {
+            return [
+                'type' => TableSchemaInterface::TYPE_DECIMAL,
+                'length' => $length,
+                'precision' => $precision,
+                'unsigned' => $unsigned,
+            ];
+        }
+        if (in_array($col, ['float', 'real', 'double'], true)) {
+            return [
+                'type' => TableSchemaInterface::TYPE_FLOAT,
+                'length' => $length,
+                'precision' => $precision,
+                'unsigned' => $unsigned,
+            ];
+        }
+
+        if (str_contains($col, 'boolean')) {
+            return ['type' => TableSchemaInterface::TYPE_BOOLEAN, 'length' => null];
+        }
+
+        if (($col === 'binary' && $length === 16) || strtolower($column) === 'uuid_blob') {
+            return ['type' => TableSchemaInterface::TYPE_BINARY_UUID, 'length' => null];
+        }
+        if (($col === 'char' && $length === 36) || $col === 'uuid') {
+            return ['type' => TableSchemaInterface::TYPE_UUID, 'length' => null];
+        }
+        if ($col === 'char') {
+            return ['type' => TableSchemaInterface::TYPE_CHAR, 'length' => $length];
+        }
+        if (str_contains($col, 'char')) {
+            return ['type' => TableSchemaInterface::TYPE_STRING, 'length' => $length];
+        }
+
+        if (in_array($col, ['blob', 'clob', 'binary', 'varbinary'], true)) {
+            return ['type' => TableSchemaInterface::TYPE_BINARY, 'length' => $length];
+        }
+
+        $datetimeTypes = [
+            'date',
+            'time',
+            'timestamp',
+            'timestampfractional',
+            'timestamptimezone',
+            'datetime',
+            'datetimefractional',
+        ];
+        if (in_array($col, $datetimeTypes, true)) {
+            return ['type' => $col, 'length' => null];
+        }
+
+        if (
+            Configure::read('ORM.mapJsonTypeForSqlite') === true &&
+            (
+                str_contains($col, TableSchemaInterface::TYPE_JSON) &&
+                !str_contains($col, 'jsonb')
+            )
+        ) {
+            return ['type' => TableSchemaInterface::TYPE_JSON, 'length' => null];
+        }
+
+        if (in_array($col, TableSchemaInterface::GEOSPATIAL_TYPES, true)) {
+            // TODO how can srid be preserved? It doesn't come back
+            // in the output of show full columns from ...
+            return [
+                'type' => $col,
+                'length' => null,
+            ];
+        }
+
+        return ['type' => TableSchemaInterface::TYPE_TEXT, 'length' => null];
+    }
+
+    /**
+     * Generate the SQL to list the tables and views.
+     *
+     * @param array $config The connection configuration to use for
+     *    getting tables from.
+     * @return array An array of (sql, params) to execute.
+     */
+    public function listTablesSql(array $config): array
+    {
+        return [
+            'SELECT name FROM sqlite_master ' .
+            'WHERE (type="table" OR type="view") ' .
+            'AND name != "sqlite_sequence" ORDER BY name',
+            [],
+        ];
+    }
+
+    /**
+     * Generate the SQL to list the tables, excluding all views.
+     *
+     * @param array $config The connection configuration to use for
+     *    getting tables from.
+     * @return array An array of (sql, params) to execute.
+     */
+    public function listTablesWithoutViewsSql(array $config): array
+    {
+        return [
+            'SELECT name FROM sqlite_master WHERE type="table" ' .
+            'AND name != "sqlite_sequence" ORDER BY name',
+            [],
+        ];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeColumnSql(string $tableName, array $config): array
+    {
+        $sql = $this->describeColumnQuery($tableName);
+
+        return [$sql, []];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function convertColumnDescription(TableSchema $schema, array $row): void
+    {
+        $field = $this->_convertColumn($row['type']);
+        $field += [
+            'null' => !$row['notnull'],
+            'default' => $this->_defaultValue($row['dflt_value'], $row['type']),
+        ];
+        $primary = $schema->getConstraint('primary');
+
+        if ($row['pk'] && empty($primary)) {
+            $field['null'] = false;
+            $field['autoIncrement'] = true;
+        }
+
+        // SQLite does not support autoincrement on composite keys.
+        if ($row['pk'] && !empty($primary)) {
+            $existingColumn = $primary['columns'][0];
+            $schema->addColumn($existingColumn, ['autoIncrement' => null] + $schema->getColumn($existingColumn));
+        }
+
+        $schema->addColumn($row['name'], $field);
+        if ($row['pk']) {
+            $constraint = (array)$schema->getConstraint('primary') + [
+                'type' => TableSchema::CONSTRAINT_PRIMARY,
+                'columns' => [],
+            ];
+            $constraint['columns'] = array_merge($constraint['columns'], [$row['name']]);
+            $schema->addConstraint('primary', $constraint);
+        }
+    }
+
+    /**
+     * Helper method for creating SQL to describe columns in a table.
+     *
+     * @param string $tableName The table to describe.
+     * @return string SQL to reflect columns
+     */
+    private function describeColumnQuery(string $tableName): string
+    {
+        $pragma = 'table_xinfo';
+        if (version_compare($this->_driver->version(), '3.26.0', '<')) {
+            $pragma = 'table_info';
+        }
+
+        return sprintf(
+            'PRAGMA %s(%s)',
+            $pragma,
+            $this->_driver->quoteIdentifier($tableName),
+        );
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeColumns(string $tableName): array
+    {
+        if (str_contains($tableName, '.')) {
+            [, $tableName] = explode('.', $tableName);
+        }
+        $sql = $this->describeColumnQuery($tableName);
+        $columns = [];
+        $statement = $this->_driver->execute($sql);
+        $primary = [];
+        foreach ($statement->fetchAll('assoc') as $i => $row) {
+            $name = $row['name'];
+            $field = $this->_convertColumn($row['type']);
+            $field += [
+                'name' => $name,
+                'null' => !$row['notnull'],
+                'default' => $this->_defaultValue($row['dflt_value'], $row['type']),
+                'comment' => null,
+                'length' => null,
+            ];
+            if ($row['pk']) {
+                $primary[] = $i;
+            }
+            $columns[] = $field;
+        }
+        // If sqlite has a single primary column, it can be marked as autoIncrement
+        if (count($primary) == 1) {
+            $offset = $primary[0];
+            $columns[$offset]['autoIncrement'] = true;
+            $columns[$offset]['null'] = false;
+        }
+
+        return $columns;
+    }
+
+    /**
+     * Manipulate the default value.
+     *
+     * Sqlite includes quotes and bared NULLs in default values.
+     * We need to remove those.
+     *
+     * @param string|int|null $default The default value.
+     * @param string|null $type The column type.
+     * @return string|int|null
+     */
+    protected function _defaultValue(string|int|null $default, ?string $type = null): string|int|null
+    {
+        if ($default === 'NULL' || $default === null) {
+            return null;
+        }
+
+        if ($type !== null && strtolower($type) === TableSchemaInterface::TYPE_BOOLEAN) {
+            if ($default === '0' || $default === '1') {
+                return (int)$default;
+            }
+
+            return (int)filter_var($default, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
+        }
+
+        // Remove quotes
+        if (is_string($default) && preg_match("/^'(.*)'$/", $default, $matches)) {
+            return str_replace("''", "'", $matches[1]);
+        }
+
+        return $default;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeIndexSql(string $tableName, array $config): array
+    {
+        $sql = $this->describeIndexQuery($tableName);
+
+        return [$sql, []];
+    }
+
+    /**
+     * Generates a regular expression to match identifiers that may or
+     * may not be quoted with any of the supported quotes.
+     *
+     * @param string $identifier The identifier to match.
+     * @return string
+     */
+    protected function possiblyQuotedIdentifierRegex(string $identifier): string
+    {
+        // Trim all quoting characters from the provided identifier,
+        // and double all quotes up because that's how sqlite returns them.
+        $identifier = trim($identifier, '\'"`[]');
+        $identifier = str_replace(["'", '"', '`'], ["''", '""', '``'], $identifier);
+        $quoted = preg_quote($identifier, '/');
+
+        return "[\['\"`]?{$quoted}[\]'\"`]?";
+    }
+
+    /**
+     * Removes possible escape characters and surrounding quotes from
+     * identifiers.
+     *
+     * @param string $value The identifier to normalize.
+     * @return string
+     */
+    protected function normalizePossiblyQuotedIdentifier(string $value): string
+    {
+        $value = trim($value);
+
+        if (str_starts_with($value, '[') && str_ends_with($value, ']')) {
+            return mb_substr($value, 1, -1);
+        }
+
+        foreach (['`', "'", '"'] as $quote) {
+            if (str_starts_with($value, $quote) && str_ends_with($value, $quote)) {
+                $value = str_replace($quote . $quote, $quote, $value);
+
+                return mb_substr($value, 1, -1);
+            }
+        }
+
+        return $value;
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * Since SQLite does not have a way to get metadata about all indexes at once,
+     * additional queries are done here. Sqlite constraint names are not
+     * stable, and the names for constraints will not match those used to create
+     * the table. This is a limitation in Sqlite's metadata features.
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema The table object to append
+     *    an index or constraint to.
+     * @param array $row The row data from `describeIndexSql`.
+     * @return void
+     * @deprecated 5.2.0 Use `describeIndexes` instead.
+     */
+    public function convertIndexDescription(TableSchema $schema, array $row): void
+    {
+        // Skip auto-indexes created for non-ROWID primary keys.
+        if (($row['origin'] ?? null) === 'pk') {
+            return;
+        }
+
+        $sql = sprintf(
+            'PRAGMA index_info(%s)',
+            $this->_driver->quoteIdentifier($row['name']),
+        );
+        $statement = $this->_driver->execute($sql);
+        $columns = [];
+        foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $column) {
+            $columns[] = $column['name'];
+        }
+        if ($row['unique']) {
+            if (($row['origin'] ?? null) === 'u') {
+                $createTableSql = $this->getCreateTableSql($schema->name());
+                $name = $this->extractIndexName($createTableSql, 'UNIQUE', $columns);
+                if ($name !== null) {
+                    $row['name'] = $name;
+                }
+            }
+
+            $schema->addConstraint($row['name'], [
+                'type' => TableSchema::CONSTRAINT_UNIQUE,
+                'columns' => $columns,
+            ]);
+        } else {
+            $schema->addIndex($row['name'], [
+                'type' => TableSchema::INDEX_INDEX,
+                'columns' => $columns,
+            ]);
+        }
+    }
+
+    /**
+     * Helper method for creating SQL to reflect indexes in a table.
+     *
+     * @param string $tableName The table to get indexes from.
+     * @return string SQL to reflect indexes
+     */
+    private function describeIndexQuery(string $tableName): string
+    {
+        return sprintf(
+            'PRAGMA index_list(%s)',
+            $this->_driver->quoteIdentifier($tableName),
+        );
+    }
+
+    /**
+     * Try to extract the original constraint name from table sql.
+     *
+     * @param string $tableSql The create table statement
+     * @param string $type The type of index/constraint
+     * @param array $columns The columns in the index.
+     * @return string|null The name of the unique index if it could be inferred.
+     */
+    private function extractIndexName(string $tableSql, string $type, array $columns): ?string
+    {
+        $columnsPattern = implode(
+            '\s*,\s*',
+            array_map(
+                fn($column) => '(?:' . $this->possiblyQuotedIdentifierRegex($column) . ')',
+                $columns,
+            ),
+        );
+
+        $regex = "/CONSTRAINT\s*(?.+?)\s*{$type}\s*\(\s*{$columnsPattern}\s*\)/i";
+        if (preg_match($regex, $tableSql, $matches)) {
+            return $this->normalizePossiblyQuotedIdentifier($matches['name']);
+        }
+
+        return null;
+    }
+
+    /**
+     * Try to extract the deferrable clause from the table SQL.
+     *
+     * @param string $tableSql The create table statement
+     * @param array $columns The columns in the index.
+     * @return string|null The name of the unique index if it could be inferred.
+     */
+    private function extractDeferrable(string $tableSql, array $columns): ?string
+    {
+        $columnsPattern = implode(
+            '\s*,\s*',
+            array_map(
+                fn($column) => '(?:' . $this->possiblyQuotedIdentifierRegex($column) . ')',
+                $columns,
+            ),
+        );
+        $regex = "/CONSTRAINT\s*(?.+?)\s*FOREIGN\s+KEY\s*\(\s*{$columnsPattern}\s*\).*?' .
+            '(?((?:NOT\s+)?DEFERRABLE)?(?:\s+INITIALLY\s+(DEFERRED|IMMEDIATE)))?/i";
+
+        if (preg_match($regex, $tableSql, $matches)) {
+            return match ($matches['deferable']) {
+                'NOT DEFERRABLE' => ForeignKey::NOT_DEFERRED,
+                'DEFERRABLE INITIALLY DEFERRED' => ForeignKey::DEFERRED,
+                'DEFERRABLE INITIALLY IMMEDIATE' => ForeignKey::IMMEDIATE,
+                default => null,
+            };
+        }
+
+        return null;
+    }
+
+    /**
+     * Get the normalized SQL query used to create a table.
+     *
+     * @param string $tableName The tablename
+     * @return string
+     */
+    private function getCreateTableSql(string $tableName): string
+    {
+        $masterSql = "SELECT sql FROM sqlite_master WHERE \"type\" = 'table' AND \"name\" = ?";
+        $statement = $this->_driver->execute($masterSql, [$tableName]);
+        $result = $statement->fetchColumn(0);
+
+        return $result ?: '';
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeIndexes(string $tableName): array
+    {
+        if (str_contains($tableName, '.')) {
+            [, $tableName] = explode('.', $tableName);
+        }
+        $sql = $this->describeIndexQuery($tableName);
+        $statement = $this->_driver->execute($sql);
+        $indexes = [];
+        $createTableSql = $this->getCreateTableSql($tableName);
+
+        $foundPrimary = false;
+        foreach ($statement->fetchAll('assoc') as $row) {
+            $indexName = $row['name'];
+            $indexSql = sprintf(
+                'PRAGMA index_info(%s)',
+                $this->_driver->quoteIdentifier($indexName),
+            );
+            $columns = [];
+            $indexData = $this->_driver->execute($indexSql)->fetchAll('assoc');
+            foreach ($indexData as $indexItem) {
+                $columns[] = $indexItem['name'];
+            }
+
+            $indexType = TableSchema::INDEX_INDEX;
+            if ($row['unique']) {
+                $indexType = TableSchema::CONSTRAINT_UNIQUE;
+            }
+            if (($row['origin'] ?? null) === 'pk') {
+                $indexType = TableSchema::CONSTRAINT_PRIMARY;
+                $foundPrimary = true;
+            }
+            if ($indexType == TableSchema::CONSTRAINT_UNIQUE) {
+                $name = $this->extractIndexName($createTableSql, 'UNIQUE', $columns);
+                if ($name !== null) {
+                    $indexName = $name;
+                }
+            }
+
+            $indexes[$indexName] = [
+                'name' => $indexName,
+                'type' => $indexType,
+                'columns' => $columns,
+                'length' => [],
+            ];
+        }
+        // Primary keys aren't always available from the index_info pragma
+        // instead we have to read the columns again.
+        if (!$foundPrimary) {
+            $sql = $this->describeColumnQuery($tableName);
+            $statement = $this->_driver->execute($sql);
+            foreach ($statement->fetchAll('assoc') as $row) {
+                if (!$row['pk']) {
+                    continue;
+                }
+                if (!isset($indexes['primary'])) {
+                    $indexes['primary'] = [
+                        'name' => 'primary',
+                        'type' => TableSchema::CONSTRAINT_PRIMARY,
+                        'columns' => [],
+                        'length' => [],
+                    ];
+                }
+                $indexes['primary']['columns'][] = $row['name'];
+            }
+        }
+
+        return array_values($indexes);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeForeignKeySql(string $tableName, array $config): array
+    {
+        $sql = sprintf(
+            'SELECT id FROM pragma_foreign_key_list(%s) GROUP BY id',
+            $this->_driver->quoteIdentifier($tableName),
+        );
+
+        return [$sql, []];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function convertForeignKeyDescription(TableSchema $schema, array $row): void
+    {
+        $sql = sprintf(
+            'SELECT * FROM pragma_foreign_key_list(%s) WHERE id = %d ORDER BY seq',
+            $this->_driver->quoteIdentifier($schema->name()),
+            $row['id'],
+        );
+        $statement = $this->_driver->prepare($sql);
+        $statement->execute();
+
+        $data = [
+            'type' => TableSchema::CONSTRAINT_FOREIGN,
+            'columns' => [],
+            'references' => [],
+        ];
+
+        $foreignKey = null;
+        foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $foreignKey) {
+            $data['columns'][] = $foreignKey['from'];
+            $data['references'][] = $foreignKey['to'];
+        }
+
+        if (count($data['references']) === 1) {
+            $data['references'] = [$foreignKey['table'], $data['references'][0]];
+        } else {
+            $data['references'] = [$foreignKey['table'], $data['references']];
+        }
+        $data['update'] = $this->_convertOnClause($foreignKey['on_update'] ?? '');
+        $data['delete'] = $this->_convertOnClause($foreignKey['on_delete'] ?? '');
+
+        $name = implode('_', $data['columns']) . '_' . $row['id'] . '_fk';
+
+        $schema->addConstraint($name, $data);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeForeignKeys(string $tableName): array
+    {
+        if (str_contains($tableName, '.')) {
+            [, $tableName] = explode('.', $tableName);
+        }
+
+        $keys = [];
+        $sql = sprintf('PRAGMA foreign_key_list(%s)', $this->_driver->quoteIdentifier($tableName));
+        $statement = $this->_driver->execute($sql);
+        foreach ($statement->fetchAll('assoc') as $row) {
+            $id = $row['id'];
+            if (!isset($keys[$id])) {
+                $keys[$id] = [
+                    'name' => $id,
+                    'type' => TableSchema::CONSTRAINT_FOREIGN,
+                    'columns' => [],
+                    'references' => [$row['table'], []],
+                    'update' => $this->_convertOnClause($row['on_update'] ?? ''),
+                    'delete' => $this->_convertOnClause($row['on_delete'] ?? ''),
+                    'deferrable' => null,
+                ];
+            }
+            $keys[$id]['columns'][$row['seq']] = $row['from'];
+            $keys[$id]['references'][1][$row['seq']] = $row['to'];
+        }
+
+        $createTableSql = $this->getCreateTableSql($tableName);
+        foreach ($keys as $id => $data) {
+            // sqlite doesn't provide a simple way to get foreign key names, but we
+            // can extract them from the normalized create table sql.
+            $name = $this->extractIndexName($createTableSql, 'FOREIGN\s*KEY', $data['columns']);
+            if ($name === null) {
+                $name = implode('_', $data['columns']) . '_' . $id . '_fk';
+            }
+            $keys[$id]['name'] = $name;
+
+            // Collapse single columns to a string.
+            // Long term this should go away, as we can narrow the types on `references`
+            if (count($data['references'][1]) === 1) {
+                $keys[$id]['references'][1] = $data['references'][1][0];
+            }
+
+            // sqlite doesn't provide a simple way to get foreign key names, but we
+            // can extract them from the normalized create table sql.
+            $keys[$id]['deferrable'] = $this->extractDeferrable($createTableSql, $data['columns']);
+        }
+
+        return array_values($keys);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeCheckConstraints(string $tableName): array
+    {
+        $constraints = [];
+        $createSql = $this->getCreateTableSql($tableName);
+
+        // Parse CHECK constraints from CREATE TABLE statement
+        // Match CONSTRAINT name CHECK (expression) or just CHECK (expression)
+        $pattern = '/(?:CONSTRAINT\s+([^\s]+)\s+)?CHECK\s*\(([^)]+(?:\([^)]*\)[^)]*)*)\)/is';
+
+        if (preg_match_all($pattern, $createSql, $matches, PREG_SET_ORDER)) {
+            foreach ($matches as $index => $match) {
+                $name = !empty($match[1])
+                    ? trim($match[1], '"`[]')
+                    : 'check_' . $index;
+                $expression = trim($match[2]);
+
+                $constraints[] = [
+                    'name' => $name,
+                    'type' => TableSchema::CONSTRAINT_CHECK,
+                    'expression' => $expression,
+                ];
+            }
+        }
+
+        return $constraints;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeOptions(string $tableName): array
+    {
+        return [];
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema The table instance the column is in.
+     * @param string $name The name of the column.
+     * @return string SQL fragment.
+     * @throws \Cake\Database\Exception\DatabaseException when the column type is unknown
+     */
+    public function columnSql(TableSchema $schema, string $name): string
+    {
+        $data = $schema->getColumn($name);
+        assert($data !== null);
+
+        $sql = $this->_getTypeSpecificColumnSql($data['type'], $schema, $name);
+        if ($sql !== null) {
+            return $sql;
+        }
+
+        $data['name'] = $name;
+        $autoIncrementTypes = [
+            TableSchemaInterface::TYPE_TINYINTEGER,
+            TableSchemaInterface::TYPE_SMALLINTEGER,
+            TableSchemaInterface::TYPE_INTEGER,
+            TableSchemaInterface::TYPE_BIGINTEGER,
+        ];
+        $primaryKey = $schema->getPrimaryKey();
+        if (
+            in_array($data['type'], $autoIncrementTypes, true) &&
+            $primaryKey === [$name]
+        ) {
+            $data['autoIncrement'] = true;
+        }
+        // Composite autoincrement columns are not supported.
+        if (count($primaryKey) > 1) {
+            unset($data['autoIncrement']);
+        }
+
+        return $this->columnDefinitionSql($data);
+    }
+
+    /**
+     * Create a SQL snippet for a column based on the array shape
+     * that `describeColumns()` creates.
+     *
+     * @param array $column The column metadata
+     * @return string Generated SQL fragment for a column
+     */
+    public function columnDefinitionSql(array $column): string
+    {
+        $name = $column['name'];
+        $column += [
+            'length' => null,
+            'precision' => null,
+        ];
+        $typeMap = [
+            TableSchemaInterface::TYPE_BINARY_UUID => ' BINARY(16)',
+            TableSchemaInterface::TYPE_BINARY => ' BLOB',
+            TableSchemaInterface::TYPE_UUID => ' CHAR(36)',
+            TableSchemaInterface::TYPE_CHAR => ' CHAR',
+            TableSchemaInterface::TYPE_STRING => ' VARCHAR',
+            TableSchemaInterface::TYPE_TINYINTEGER => ' TINYINT',
+            TableSchemaInterface::TYPE_SMALLINTEGER => ' SMALLINT',
+            TableSchemaInterface::TYPE_INTEGER => ' INTEGER',
+            TableSchemaInterface::TYPE_BIGINTEGER => ' BIGINT',
+            TableSchemaInterface::TYPE_BOOLEAN => ' BOOLEAN',
+            TableSchemaInterface::TYPE_FLOAT => ' FLOAT',
+            TableSchemaInterface::TYPE_DECIMAL => ' DECIMAL',
+            TableSchemaInterface::TYPE_DATE => ' DATE',
+            TableSchemaInterface::TYPE_TIME => ' TIME',
+            TableSchemaInterface::TYPE_DATETIME => ' DATETIME',
+            TableSchemaInterface::TYPE_DATETIME_FRACTIONAL => ' DATETIMEFRACTIONAL',
+            TableSchemaInterface::TYPE_TIMESTAMP => ' TIMESTAMP',
+            TableSchemaInterface::TYPE_TIMESTAMP_FRACTIONAL => ' TIMESTAMPFRACTIONAL',
+            TableSchemaInterface::TYPE_TIMESTAMP_TIMEZONE => ' TIMESTAMPTIMEZONE',
+            TableSchemaInterface::TYPE_JSON => ' TEXT',
+            TableSchemaInterface::TYPE_GEOMETRY => ' GEOMETRY_TEXT',
+            TableSchemaInterface::TYPE_POINT => ' POINT_TEXT',
+            TableSchemaInterface::TYPE_LINESTRING => ' LINESTRING_TEXT',
+            TableSchemaInterface::TYPE_POLYGON => ' POLYGON_TEXT',
+        ];
+
+        $out = $this->_driver->quoteIdentifier($name);
+        $hasUnsigned = [
+            TableSchemaInterface::TYPE_TINYINTEGER,
+            TableSchemaInterface::TYPE_SMALLINTEGER,
+            TableSchemaInterface::TYPE_INTEGER,
+            TableSchemaInterface::TYPE_BIGINTEGER,
+            TableSchemaInterface::TYPE_FLOAT,
+            TableSchemaInterface::TYPE_DECIMAL,
+        ];
+
+        $autoIncrement = (bool)($column['autoIncrement'] ?? false);
+        if (
+            !$autoIncrement &&
+            isset($column['unsigned']) && $column['unsigned'] === true &&
+            in_array($column['type'], $hasUnsigned, true)
+        ) {
+            $out .= ' UNSIGNED';
+        }
+
+        $foundType = false;
+        if (isset($typeMap[$column['type']])) {
+            $out .= $typeMap[$column['type']];
+            $foundType = true;
+        }
+
+        $hasLength = [
+            TableSchemaInterface::TYPE_BINARY,
+            TableSchemaInterface::TYPE_STRING,
+            TableSchemaInterface::TYPE_CHAR,
+            TableSchemaInterface::TYPE_TINYINTEGER,
+            TableSchemaInterface::TYPE_SMALLINTEGER,
+            TableSchemaInterface::TYPE_INTEGER,
+        ];
+        if ($column['type'] === TableSchemaInterface::TYPE_TEXT && $column['length'] !== TableSchema::LENGTH_TINY) {
+            $out .= ' TEXT';
+            $foundType = true;
+        } elseif (
+            $column['type'] === TableSchemaInterface::TYPE_TEXT &&
+            $column['length'] === TableSchema::LENGTH_TINY
+        ) {
+            $out .= ' VARCHAR';
+            $hasLength[] = $column['type'];
+            $foundType = true;
+        }
+        if (!$foundType) {
+            $out .= ' ' . strtoupper($column['type']);
+            $hasLength[] = $column['type'];
+        }
+
+        if (in_array($column['type'], $hasLength, true) && isset($column['length']) && !$autoIncrement) {
+            $out .= '(' . (int)$column['length'] . ')';
+        }
+
+        $hasPrecision = [TableSchemaInterface::TYPE_FLOAT, TableSchemaInterface::TYPE_DECIMAL];
+        if (
+            in_array($column['type'], $hasPrecision, true) &&
+            (
+                isset($column['length']) ||
+                isset($column['precision'])
+            )
+        ) {
+            $out .= '(' . (int)$column['length'] . ',' . (int)$column['precision'] . ')';
+        }
+
+        if (isset($column['null']) && $column['null'] === false) {
+            $out .= ' NOT NULL';
+        }
+
+        if ($column['type'] === TableSchemaInterface::TYPE_INTEGER && $autoIncrement) {
+            $out .= ' PRIMARY KEY AUTOINCREMENT';
+            unset($column['default']);
+        }
+
+        $timestampTypes = [
+            TableSchemaInterface::TYPE_DATETIME,
+            TableSchemaInterface::TYPE_DATETIME_FRACTIONAL,
+            TableSchemaInterface::TYPE_TIMESTAMP,
+            TableSchemaInterface::TYPE_TIMESTAMP_FRACTIONAL,
+            TableSchemaInterface::TYPE_TIMESTAMP_TIMEZONE,
+        ];
+        if (isset($column['null']) && $column['null'] === true && in_array($column['type'], $timestampTypes, true)) {
+            $out .= ' DEFAULT NULL';
+        }
+        if (isset($column['default'])) {
+            $out .= ' DEFAULT ' . $this->_driver->schemaValue($column['default']);
+        }
+        if (isset($column['comment']) && $column['comment']) {
+            $out .= " /* {$column['comment']} */";
+        }
+
+        return $out;
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * Note integer primary keys will return ''. This is intentional as Sqlite requires
+     * that integer primary keys be defined in the column definition.
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema The table instance the column is in.
+     * @param string $name The name of the column.
+     * @return string SQL fragment.
+     */
+    public function constraintSql(TableSchema $schema, string $name): string
+    {
+        $data = $schema->getConstraint($name);
+        assert($data !== null, 'Data does not exist');
+
+        $columns = '';
+        if (isset($data['columns'])) {
+            $column = $schema->getColumn($data['columns'][0]);
+            assert($column !== null, 'Data does not exist');
+
+            if (
+                $data['type'] === TableSchema::CONSTRAINT_PRIMARY &&
+                count($data['columns']) === 1 &&
+                $column['type'] === TableSchemaInterface::TYPE_INTEGER
+            ) {
+                return '';
+            }
+
+            $aliased = array_map(
+                $this->_driver->quoteIdentifier(...),
+                $data['columns'],
+            );
+            $columns = implode(', ', $aliased);
+        }
+
+        $clause = '';
+        $type = '';
+        if ($data['type'] === TableSchema::CONSTRAINT_PRIMARY) {
+            $type = 'PRIMARY KEY';
+        } elseif ($data['type'] === TableSchema::CONSTRAINT_UNIQUE) {
+            $type = 'UNIQUE';
+        } elseif ($data['type'] === TableSchema::CONSTRAINT_FOREIGN) {
+            $type = 'FOREIGN KEY';
+
+            $clause = rtrim(sprintf(
+                ' REFERENCES %s (%s) ON UPDATE %s ON DELETE %s %s',
+                $this->_driver->quoteIdentifier($data['references'][0]),
+                $this->_convertConstraintColumns($data['references'][1]),
+                $this->_foreignOnClause($data['update']),
+                $this->_foreignOnClause($data['delete']),
+                $data['deferrable'] ?? null,
+            ));
+        } elseif ($data['type'] === TableSchema::CONSTRAINT_CHECK) {
+            $type = 'CHECK';
+            $columns = $data['expression'];
+        }
+
+        return sprintf(
+            'CONSTRAINT %s %s (%s)%s',
+            $this->_driver->quoteIdentifier($name),
+            $type,
+            $columns,
+            $clause,
+        );
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * SQLite can not properly handle adding a constraint to an existing table.
+     * This method is no-op
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema The table instance the foreign key constraints are.
+     * @return array SQL fragment.
+     */
+    public function addConstraintSql(TableSchema $schema): array
+    {
+        return [];
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * SQLite can not properly handle dropping a constraint to an existing table.
+     * This method is no-op
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema The table instance the foreign key constraints are.
+     * @return array SQL fragment.
+     */
+    public function dropConstraintSql(TableSchema $schema): array
+    {
+        return [];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function indexSql(TableSchema $schema, string $name): string
+    {
+        $data = $schema->getIndex($name);
+        assert($data !== null);
+        $columns = array_map(
+            $this->_driver->quoteIdentifier(...),
+            $data['columns'],
+        );
+
+        return sprintf(
+            'CREATE INDEX %s ON %s (%s)',
+            $this->_driver->quoteIdentifier($name),
+            $this->_driver->quoteIdentifier($schema->name()),
+            implode(', ', $columns),
+        );
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function createTableSql(TableSchema $schema, array $columns, array $constraints, array $indexes): array
+    {
+        $lines = array_merge($columns, $constraints);
+        $content = implode(",\n", array_filter($lines));
+        $temporary = $schema->isTemporary() ? ' TEMPORARY ' : ' ';
+        $table = sprintf("CREATE%sTABLE \"%s\" (\n%s\n)", $temporary, $schema->name(), $content);
+        $out = [$table];
+        foreach ($indexes as $index) {
+            $out[] = $index;
+        }
+
+        return $out;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function truncateTableSql(TableSchema $schema): array
+    {
+        $name = $schema->name();
+        $sql = [];
+        if ($this->hasSequences()) {
+            $sql[] = sprintf('DELETE FROM sqlite_sequence WHERE name="%s"', $name);
+        }
+
+        $sql[] = sprintf('DELETE FROM "%s"', $name);
+
+        return $sql;
+    }
+
+    /**
+     * Returns whether there is any table in this connection to SQLite containing
+     * sequences
+     *
+     * @return bool
+     */
+    public function hasSequences(): bool
+    {
+        $result = $this->_driver->prepare(
+            'SELECT 1 FROM sqlite_master WHERE name = "sqlite_sequence"',
+        );
+        $result->execute();
+        $this->_hasSequences = (bool)$result->fetch();
+
+        return $this->_hasSequences;
+    }
+}
diff --git a/src/Database/Schema/SqlserverSchema.php b/src/Database/Schema/SqlserverSchema.php
deleted file mode 100644
index 88d7a675776..00000000000
--- a/src/Database/Schema/SqlserverSchema.php
+++ /dev/null
@@ -1,575 +0,0 @@
- $col, 'length' => null];
-        }
-        if (strpos($col, 'datetime') !== false) {
-            return ['type' => TableSchema::TYPE_TIMESTAMP, 'length' => null];
-        }
-
-        if ($col === 'tinyint') {
-            return ['type' => TableSchema::TYPE_TINYINTEGER, 'length' => $precision ?: 3];
-        }
-        if ($col === 'smallint') {
-            return ['type' => TableSchema::TYPE_SMALLINTEGER, 'length' => $precision ?: 5];
-        }
-        if ($col === 'int' || $col === 'integer') {
-            return ['type' => TableSchema::TYPE_INTEGER, 'length' => $precision ?: 10];
-        }
-        if ($col === 'bigint') {
-            return ['type' => TableSchema::TYPE_BIGINTEGER, 'length' => $precision ?: 20];
-        }
-        if ($col === 'bit') {
-            return ['type' => TableSchema::TYPE_BOOLEAN, 'length' => null];
-        }
-        if (strpos($col, 'numeric') !== false ||
-            strpos($col, 'money') !== false ||
-            strpos($col, 'decimal') !== false
-        ) {
-            return ['type' => TableSchema::TYPE_DECIMAL, 'length' => $precision, 'precision' => $scale];
-        }
-
-        if ($col === 'real' || $col === 'float') {
-            return ['type' => TableSchema::TYPE_FLOAT, 'length' => null];
-        }
-        // SqlServer schema reflection returns double length for unicode
-        // columns because internally it uses UTF16/UCS2
-        if ($col === 'nvarchar' || $col === 'nchar' || $col === 'ntext') {
-            $length = $length / 2;
-        }
-        if (strpos($col, 'varchar') !== false && $length < 0) {
-            return ['type' => TableSchema::TYPE_TEXT, 'length' => null];
-        }
-
-        if (strpos($col, 'varchar') !== false) {
-            return ['type' => TableSchema::TYPE_STRING, 'length' => $length ?: 255];
-        }
-
-        if (strpos($col, 'char') !== false) {
-            return ['type' => TableSchema::TYPE_STRING, 'fixed' => true, 'length' => $length];
-        }
-
-        if (strpos($col, 'text') !== false) {
-            return ['type' => TableSchema::TYPE_TEXT, 'length' => null];
-        }
-
-        if ($col === 'image' || strpos($col, 'binary')) {
-            return ['type' => TableSchema::TYPE_BINARY, 'length' => null];
-        }
-
-        if ($col === 'uniqueidentifier') {
-            return ['type' => TableSchema::TYPE_UUID];
-        }
-
-        return ['type' => TableSchema::TYPE_STRING, 'length' => null];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function convertColumnDescription(TableSchema $schema, $row)
-    {
-        $field = $this->_convertColumn(
-            $row['type'],
-            $row['char_length'],
-            $row['precision'],
-            $row['scale']
-        );
-        if (!empty($row['default'])) {
-            $row['default'] = trim($row['default'], '()');
-        }
-        if (!empty($row['autoincrement'])) {
-            $field['autoIncrement'] = true;
-        }
-        if ($field['type'] === TableSchema::TYPE_BOOLEAN) {
-            $row['default'] = (int)$row['default'];
-        }
-
-        $field += [
-            'null' => $row['null'] === '1',
-            'default' => $this->_defaultValue($row['default']),
-            'collate' => $row['collation_name'],
-        ];
-        $schema->addColumn($row['name'], $field);
-    }
-
-    /**
-     * Manipulate the default value.
-     *
-     * Sqlite includes quotes and bared NULLs in default values.
-     * We need to remove those.
-     *
-     * @param string|null $default The default value.
-     * @return string|null
-     */
-    protected function _defaultValue($default)
-    {
-        if ($default === 'NULL') {
-            return null;
-        }
-
-        // Remove quotes
-        if (preg_match("/^N?'(.*)'/", $default, $matches)) {
-            return str_replace("''", "'", $matches[1]);
-        }
-
-        return $default;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function describeIndexSql($tableName, $config)
-    {
-        $sql = "SELECT
-                I.[name] AS [index_name],
-                IC.[index_column_id] AS [index_order],
-                AC.[name] AS [column_name],
-                I.[is_unique], I.[is_primary_key],
-                I.[is_unique_constraint]
-            FROM sys.[tables] AS T
-            INNER JOIN sys.[schemas] S ON S.[schema_id] = T.[schema_id]
-            INNER JOIN sys.[indexes] I ON T.[object_id] = I.[object_id]
-            INNER JOIN sys.[index_columns] IC ON I.[object_id] = IC.[object_id] AND I.[index_id] = IC.[index_id]
-            INNER JOIN sys.[all_columns] AC ON T.[object_id] = AC.[object_id] AND IC.[column_id] = AC.[column_id]
-            WHERE T.[is_ms_shipped] = 0 AND I.[type_desc] <> 'HEAP' AND T.[name] = ? AND S.[name] = ?
-            ORDER BY I.[index_id], IC.[index_column_id]";
-
-        $schema = empty($config['schema']) ? static::DEFAULT_SCHEMA_NAME : $config['schema'];
-
-        return [$sql, [$tableName, $schema]];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function convertIndexDescription(TableSchema $schema, $row)
-    {
-        $type = Table::INDEX_INDEX;
-        $name = $row['index_name'];
-        if ($row['is_primary_key']) {
-            $name = $type = Table::CONSTRAINT_PRIMARY;
-        }
-        if ($row['is_unique_constraint'] && $type === Table::INDEX_INDEX) {
-            $type = Table::CONSTRAINT_UNIQUE;
-        }
-
-        if ($type === Table::INDEX_INDEX) {
-            $existing = $schema->getIndex($name);
-        } else {
-            $existing = $schema->getConstraint($name);
-        }
-
-        $columns = [$row['column_name']];
-        if (!empty($existing)) {
-            $columns = array_merge($existing['columns'], $columns);
-        }
-
-        if ($type === Table::CONSTRAINT_PRIMARY || $type === Table::CONSTRAINT_UNIQUE) {
-            $schema->addConstraint($name, [
-                'type' => $type,
-                'columns' => $columns
-            ]);
-
-            return;
-        }
-        $schema->addIndex($name, [
-            'type' => $type,
-            'columns' => $columns
-        ]);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function describeForeignKeySql($tableName, $config)
-    {
-        $sql = 'SELECT FK.[name] AS [foreign_key_name], FK.[delete_referential_action_desc] AS [delete_type],
-                FK.[update_referential_action_desc] AS [update_type], C.name AS [column], RT.name AS [reference_table],
-                RC.name AS [reference_column]
-            FROM sys.foreign_keys FK
-            INNER JOIN sys.foreign_key_columns FKC ON FKC.constraint_object_id = FK.object_id
-            INNER JOIN sys.tables T ON T.object_id = FKC.parent_object_id
-            INNER JOIN sys.tables RT ON RT.object_id = FKC.referenced_object_id
-            INNER JOIN sys.schemas S ON S.schema_id = T.schema_id AND S.schema_id = RT.schema_id
-            INNER JOIN sys.columns C ON C.column_id = FKC.parent_column_id AND C.object_id = FKC.parent_object_id
-            INNER JOIN sys.columns RC ON RC.column_id = FKC.referenced_column_id AND RC.object_id = FKC.referenced_object_id
-            WHERE FK.is_ms_shipped = 0 AND T.name = ? AND S.name = ?';
-
-        $schema = empty($config['schema']) ? static::DEFAULT_SCHEMA_NAME : $config['schema'];
-
-        return [$sql, [$tableName, $schema]];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function convertForeignKeyDescription(TableSchema $schema, $row)
-    {
-        $data = [
-            'type' => Table::CONSTRAINT_FOREIGN,
-            'columns' => [$row['column']],
-            'references' => [$row['reference_table'], $row['reference_column']],
-            'update' => $this->_convertOnClause($row['update_type']),
-            'delete' => $this->_convertOnClause($row['delete_type']),
-        ];
-        $name = $row['foreign_key_name'];
-        $schema->addConstraint($name, $data);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    protected function _foreignOnClause($on)
-    {
-        $parent = parent::_foreignOnClause($on);
-
-        return $parent === 'RESTRICT' ? parent::_foreignOnClause(Table::ACTION_SET_NULL) : $parent;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    protected function _convertOnClause($clause)
-    {
-        switch ($clause) {
-            case 'NO_ACTION':
-                return Table::ACTION_NO_ACTION;
-            case 'CASCADE':
-                return Table::ACTION_CASCADE;
-            case 'SET_NULL':
-                return Table::ACTION_SET_NULL;
-            case 'SET_DEFAULT':
-                return Table::ACTION_SET_DEFAULT;
-        }
-
-        return Table::ACTION_SET_NULL;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function columnSql(TableSchema $schema, $name)
-    {
-        $data = $schema->getColumn($name);
-        $out = $this->_driver->quoteIdentifier($name);
-        $typeMap = [
-            TableSchema::TYPE_TINYINTEGER => ' TINYINT',
-            TableSchema::TYPE_SMALLINTEGER => ' SMALLINT',
-            TableSchema::TYPE_INTEGER => ' INTEGER',
-            TableSchema::TYPE_BIGINTEGER => ' BIGINT',
-            TableSchema::TYPE_BOOLEAN => ' BIT',
-            TableSchema::TYPE_FLOAT => ' FLOAT',
-            TableSchema::TYPE_DECIMAL => ' DECIMAL',
-            TableSchema::TYPE_DATE => ' DATE',
-            TableSchema::TYPE_TIME => ' TIME',
-            TableSchema::TYPE_DATETIME => ' DATETIME',
-            TableSchema::TYPE_TIMESTAMP => ' DATETIME',
-            TableSchema::TYPE_UUID => ' UNIQUEIDENTIFIER',
-            TableSchema::TYPE_JSON => ' NVARCHAR(MAX)',
-        ];
-
-        if (isset($typeMap[$data['type']])) {
-            $out .= $typeMap[$data['type']];
-        }
-
-        if ($data['type'] === TableSchema::TYPE_INTEGER || $data['type'] === TableSchema::TYPE_BIGINTEGER) {
-            if ([$name] === $schema->primaryKey() || $data['autoIncrement'] === true) {
-                unset($data['null'], $data['default']);
-                $out .= ' IDENTITY(1, 1)';
-            }
-        }
-
-        if ($data['type'] === TableSchema::TYPE_TEXT && $data['length'] !== Table::LENGTH_TINY) {
-            $out .= ' NVARCHAR(MAX)';
-        }
-
-        if ($data['type'] === TableSchema::TYPE_BINARY) {
-            $out .= ' VARBINARY';
-
-            if ($data['length'] !== Table::LENGTH_TINY) {
-                $out .= '(MAX)';
-            } else {
-                $out .= sprintf('(%s)', Table::LENGTH_TINY);
-            }
-        }
-
-        if ($data['type'] === TableSchema::TYPE_STRING ||
-            ($data['type'] === TableSchema::TYPE_TEXT && $data['length'] === Table::LENGTH_TINY)
-        ) {
-            $type = ' NVARCHAR';
-
-            if (!empty($data['fixed'])) {
-                $type = ' NCHAR';
-            }
-
-            if (!isset($data['length'])) {
-                $data['length'] = 255;
-            }
-
-            $out .= sprintf('%s(%d)', $type, $data['length']);
-        }
-
-        $hasCollate = [TableSchema::TYPE_TEXT, TableSchema::TYPE_STRING];
-        if (in_array($data['type'], $hasCollate, true) && isset($data['collate']) && $data['collate'] !== '') {
-            $out .= ' COLLATE ' . $data['collate'];
-        }
-
-        if ($data['type'] === TableSchema::TYPE_FLOAT && isset($data['precision'])) {
-            $out .= '(' . (int)$data['precision'] . ')';
-        }
-
-        if ($data['type'] === TableSchema::TYPE_DECIMAL &&
-            (isset($data['length']) || isset($data['precision']))
-        ) {
-            $out .= '(' . (int)$data['length'] . ',' . (int)$data['precision'] . ')';
-        }
-
-        if (isset($data['null']) && $data['null'] === false) {
-            $out .= ' NOT NULL';
-        }
-
-        if (isset($data['default']) &&
-            in_array($data['type'], [TableSchema::TYPE_TIMESTAMP, TableSchema::TYPE_DATETIME]) &&
-            strtolower($data['default']) === 'current_timestamp'
-        ) {
-            $out .= ' DEFAULT CURRENT_TIMESTAMP';
-        } elseif (isset($data['default'])) {
-            $default = is_bool($data['default']) ? (int)$data['default'] : $this->_driver->schemaValue($data['default']);
-            $out .= ' DEFAULT ' . $default;
-        } elseif (isset($data['null']) && $data['null'] !== false) {
-            $out .= ' DEFAULT NULL';
-        }
-
-        return $out;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function addConstraintSql(TableSchema $schema)
-    {
-        $sqlPattern = 'ALTER TABLE %s ADD %s;';
-        $sql = [];
-
-        foreach ($schema->constraints() as $name) {
-            $constraint = $schema->getConstraint($name);
-            if ($constraint['type'] === Table::CONSTRAINT_FOREIGN) {
-                $tableName = $this->_driver->quoteIdentifier($schema->name());
-                $sql[] = sprintf($sqlPattern, $tableName, $this->constraintSql($schema, $name));
-            }
-        }
-
-        return $sql;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function dropConstraintSql(TableSchema $schema)
-    {
-        $sqlPattern = 'ALTER TABLE %s DROP CONSTRAINT %s;';
-        $sql = [];
-
-        foreach ($schema->constraints() as $name) {
-            $constraint = $schema->getConstraint($name);
-            if ($constraint['type'] === Table::CONSTRAINT_FOREIGN) {
-                $tableName = $this->_driver->quoteIdentifier($schema->name());
-                $constraintName = $this->_driver->quoteIdentifier($name);
-                $sql[] = sprintf($sqlPattern, $tableName, $constraintName);
-            }
-        }
-
-        return $sql;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function indexSql(TableSchema $schema, $name)
-    {
-        $data = $schema->getIndex($name);
-        $columns = array_map(
-            [$this->_driver, 'quoteIdentifier'],
-            $data['columns']
-        );
-
-        return sprintf(
-            'CREATE INDEX %s ON %s (%s)',
-            $this->_driver->quoteIdentifier($name),
-            $this->_driver->quoteIdentifier($schema->name()),
-            implode(', ', $columns)
-        );
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function constraintSql(TableSchema $schema, $name)
-    {
-        $data = $schema->getConstraint($name);
-        $out = 'CONSTRAINT ' . $this->_driver->quoteIdentifier($name);
-        if ($data['type'] === Table::CONSTRAINT_PRIMARY) {
-            $out = 'PRIMARY KEY';
-        }
-        if ($data['type'] === Table::CONSTRAINT_UNIQUE) {
-            $out .= ' UNIQUE';
-        }
-
-        return $this->_keySql($out, $data);
-    }
-
-    /**
-     * Helper method for generating key SQL snippets.
-     *
-     * @param string $prefix The key prefix
-     * @param array $data Key data.
-     * @return string
-     */
-    protected function _keySql($prefix, $data)
-    {
-        $columns = array_map(
-            [$this->_driver, 'quoteIdentifier'],
-            $data['columns']
-        );
-        if ($data['type'] === Table::CONSTRAINT_FOREIGN) {
-            return $prefix . sprintf(
-                ' FOREIGN KEY (%s) REFERENCES %s (%s) ON UPDATE %s ON DELETE %s',
-                implode(', ', $columns),
-                $this->_driver->quoteIdentifier($data['references'][0]),
-                $this->_convertConstraintColumns($data['references'][1]),
-                $this->_foreignOnClause($data['update']),
-                $this->_foreignOnClause($data['delete'])
-            );
-        }
-
-        return $prefix . ' (' . implode(', ', $columns) . ')';
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function createTableSql(TableSchema $schema, $columns, $constraints, $indexes)
-    {
-        $content = array_merge($columns, $constraints);
-        $content = implode(",\n", array_filter($content));
-        $tableName = $this->_driver->quoteIdentifier($schema->name());
-        $out = [];
-        $out[] = sprintf("CREATE TABLE %s (\n%s\n)", $tableName, $content);
-        foreach ($indexes as $index) {
-            $out[] = $index;
-        }
-
-        return $out;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function truncateTableSql(TableSchema $schema)
-    {
-        $name = $this->_driver->quoteIdentifier($schema->name());
-        $queries = [
-            sprintf('DELETE FROM %s', $name)
-        ];
-
-        // Restart identity sequences
-        $pk = $schema->primaryKey();
-        if (count($pk) === 1) {
-            $column = $schema->getColumn($pk[0]);
-            if (in_array($column['type'], ['integer', 'biginteger'])) {
-                $queries[] = sprintf(
-                    "DBCC CHECKIDENT('%s', RESEED, 0)",
-                    $schema->name()
-                );
-            }
-        }
-
-        return $queries;
-    }
-}
diff --git a/src/Database/Schema/SqlserverSchemaDialect.php b/src/Database/Schema/SqlserverSchemaDialect.php
new file mode 100644
index 00000000000..ac5910ddb38
--- /dev/null
+++ b/src/Database/Schema/SqlserverSchemaDialect.php
@@ -0,0 +1,971 @@
+ $config The connection configuration to use for
+     *    getting tables from.
+     * @return array An array of (sql, params) to execute.
+     */
+    public function listTablesSql(array $config): array
+    {
+        $sql = "SELECT TABLE_NAME
+            FROM INFORMATION_SCHEMA.TABLES
+            WHERE TABLE_SCHEMA = ?
+            AND (TABLE_TYPE = 'BASE TABLE' OR TABLE_TYPE = 'VIEW')
+            ORDER BY TABLE_NAME";
+        $schema = $config['schema'] ?? static::DEFAULT_SCHEMA_NAME;
+
+        return [$sql, [$schema]];
+    }
+
+    /**
+     * Generate the SQL to list the tables, excluding all views.
+     *
+     * @param array $config The connection configuration to use for
+     *    getting tables from.
+     * @return array An array of (sql, params) to execute.
+     */
+    public function listTablesWithoutViewsSql(array $config): array
+    {
+        $sql = "SELECT TABLE_NAME
+            FROM INFORMATION_SCHEMA.TABLES
+            WHERE TABLE_SCHEMA = ?
+            AND (TABLE_TYPE = 'BASE TABLE')
+            ORDER BY TABLE_NAME";
+        $schema = $config['schema'] ?? static::DEFAULT_SCHEMA_NAME;
+
+        return [$sql, [$schema]];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeColumnSql(string $tableName, array $config): array
+    {
+        $sql = $this->describeColumnQuery();
+        $schema = $config['schema'] ?? static::DEFAULT_SCHEMA_NAME;
+
+        return [$sql, [$tableName, $schema]];
+    }
+
+    /**
+     * Helper method for creating SQL to describe columns in a table.
+     *
+     * @return string SQL to reflect columns
+     */
+    private function describeColumnQuery(): string
+    {
+        return 'SELECT DISTINCT
+            AC.column_id AS [column_id],
+            AC.name AS [name],
+            TY.name AS [type],
+            AC.max_length AS [char_length],
+            AC.precision AS [precision],
+            AC.scale AS [scale],
+            AC.is_identity AS [autoincrement],
+            AC.is_nullable AS [null],
+            OBJECT_DEFINITION(AC.default_object_id) AS [default],
+            AC.collation_name AS [collation_name],
+            EP.[value] AS [comment]
+            FROM sys.[objects] T
+            INNER JOIN sys.[schemas] S ON S.[schema_id] = T.[schema_id]
+            INNER JOIN sys.[all_columns] AC ON T.[object_id] = AC.[object_id]
+            INNER JOIN sys.[types] TY ON TY.[user_type_id] = AC.[user_type_id]
+            LEFT JOIN sys.[extended_properties] as EP
+                ON T.[object_id] = EP.[major_id]
+                AND AC.[column_id] = EP.[minor_id]
+                AND EP.[name] = \'MS_Description\'
+            WHERE T.[name] = ? AND S.[name] = ?
+            ORDER BY column_id';
+    }
+
+    /**
+     * Convert a column definition to the abstract types.
+     *
+     * The returned type will be a type that
+     * Cake\Database\TypeFactory  can handle.
+     *
+     * @param string $col The column type
+     * @param int|null $length the column length
+     * @param int|null $precision The column precision
+     * @param int|null $scale The column scale
+     * @return array Array of column information.
+     * @link https://technet.microsoft.com/en-us/library/ms187752.aspx
+     */
+    protected function _convertColumn(
+        string $col,
+        ?int $length = null,
+        ?int $precision = null,
+        ?int $scale = null,
+    ): array {
+        $col = strtolower($col);
+
+        $type = $this->_applyTypeSpecificColumnConversion(
+            $col,
+            compact('length', 'precision', 'scale'),
+        );
+        if ($type !== null) {
+            return $type;
+        }
+
+        if (in_array($col, ['date', 'time'], true)) {
+            return ['type' => $col, 'length' => null];
+        }
+
+        if ($col === 'datetime') {
+            // datetime cannot parse more than 3 digits of precision and isn't accurate
+            return ['type' => TableSchemaInterface::TYPE_DATETIME, 'length' => null];
+        }
+        if (str_contains($col, 'datetime')) {
+            $typeName = TableSchemaInterface::TYPE_DATETIME;
+            if ($scale > 0) {
+                $typeName = TableSchemaInterface::TYPE_DATETIME_FRACTIONAL;
+            }
+
+            return ['type' => $typeName, 'length' => null, 'precision' => $scale];
+        }
+
+        if ($col === 'char') {
+            return ['type' => TableSchemaInterface::TYPE_CHAR, 'length' => $length];
+        }
+
+        if ($col === 'tinyint') {
+            return ['type' => TableSchemaInterface::TYPE_TINYINTEGER, 'length' => $precision ?: 3];
+        }
+        if ($col === 'smallint') {
+            return ['type' => TableSchemaInterface::TYPE_SMALLINTEGER, 'length' => $precision ?: 5];
+        }
+        if ($col === 'int' || $col === 'integer') {
+            return ['type' => TableSchemaInterface::TYPE_INTEGER, 'length' => $precision ?: 10];
+        }
+        if ($col === 'bigint') {
+            return ['type' => TableSchemaInterface::TYPE_BIGINTEGER, 'length' => $precision ?: 20];
+        }
+        if ($col === 'bit') {
+            return ['type' => TableSchemaInterface::TYPE_BOOLEAN, 'length' => null];
+        }
+        if (
+            str_contains($col, 'numeric') ||
+            str_contains($col, 'money') ||
+            str_contains($col, 'decimal')
+        ) {
+            return ['type' => TableSchemaInterface::TYPE_DECIMAL, 'length' => $precision, 'precision' => $scale];
+        }
+
+        if ($col === 'real' || $col === 'float') {
+            return ['type' => TableSchemaInterface::TYPE_FLOAT, 'length' => null];
+        }
+        // SqlServer schema reflection returns double length for unicode
+        // columns because internally it uses UTF16/UCS2
+        if (in_array($col, ['nvarchar', 'nchar', 'ntext'], true)) {
+            $length /= 2;
+        }
+        if (str_contains($col, 'varchar') && $length < 0) {
+            return ['type' => TableSchemaInterface::TYPE_TEXT, 'length' => null];
+        }
+
+        if (str_contains($col, 'varchar')) {
+            return ['type' => TableSchemaInterface::TYPE_STRING, 'length' => $length ?: 255];
+        }
+
+        if (str_contains($col, 'char')) {
+            return ['type' => TableSchemaInterface::TYPE_CHAR, 'length' => $length];
+        }
+
+        if (str_contains($col, 'text')) {
+            return ['type' => TableSchemaInterface::TYPE_TEXT, 'length' => null];
+        }
+
+        if ($col === 'image' || str_contains($col, 'binary')) {
+            // -1 is the value for MAX which we treat as a 'long' binary
+            if ($length === -1) {
+                $length = TableSchema::LENGTH_LONG;
+            }
+
+            return ['type' => TableSchemaInterface::TYPE_BINARY, 'length' => $length];
+        }
+
+        if ($col === 'uniqueidentifier') {
+            return ['type' => TableSchemaInterface::TYPE_UUID];
+        }
+        if ($col === 'geometry') {
+            return ['type' => TableSchemaInterface::TYPE_GEOMETRY];
+        }
+        if ($col === 'geography') {
+            // SQLserver only has one generic geometry type that
+            // we map to point.
+            return ['type' => TableSchemaInterface::TYPE_POINT];
+        }
+
+        return ['type' => TableSchemaInterface::TYPE_STRING, 'length' => null];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function convertColumnDescription(TableSchema $schema, array $row): void
+    {
+        $field = $this->_convertColumn(
+            $row['type'],
+            $row['char_length'] !== null ? (int)$row['char_length'] : null,
+            $row['precision'] !== null ? (int)$row['precision'] : null,
+            $row['scale'] !== null ? (int)$row['scale'] : null,
+        );
+
+        if (!empty($row['autoincrement'])) {
+            $field['autoIncrement'] = true;
+        }
+
+        $field += [
+            'null' => $row['null'] === '1',
+            'default' => $this->_defaultValue($field['type'], $row['default']),
+            'collate' => $row['collation_name'],
+        ];
+        $schema->addColumn($row['name'], $field);
+    }
+
+    /**
+     * Split a tablename into a tuple of schema, table
+     * If the table does not have a schema name included, the connection
+     * schema will be used.
+     *
+     * @param string $tableName The table name to split
+     * @return array A tuple of [schema, tablename]
+     */
+    private function splitTablename(string $tableName): array
+    {
+        $config = $this->_driver->config();
+        $schema = $config['schema'] ?? static::DEFAULT_SCHEMA_NAME;
+        if (str_contains($tableName, '.')) {
+            return explode('.', $tableName);
+        }
+
+        return [$schema, $tableName];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeColumns(string $tableName): array
+    {
+        [$schema, $name] = $this->splitTablename($tableName);
+
+        $sql = $this->describeColumnQuery();
+        $statement = $this->_driver->execute($sql, [$name, $schema]);
+        $columns = [];
+        foreach ($statement->fetchAll('assoc') as $row) {
+            $field = $this->_convertColumn(
+                $row['type'],
+                $row['char_length'] !== null ? (int)$row['char_length'] : null,
+                $row['precision'] !== null ? (int)$row['precision'] : null,
+                $row['scale'] !== null ? (int)$row['scale'] : null,
+            );
+
+            if (!empty($row['autoincrement'])) {
+                $field['autoIncrement'] = true;
+            }
+
+            $field += [
+                'name' => $row['name'],
+                'null' => $row['null'] === '1',
+                'default' => $this->_defaultValue($field['type'], $row['default']),
+                'comment' => $row['comment'] ?? null,
+                'collate' => $row['collation_name'],
+            ];
+            $columns[] = $field;
+        }
+
+        return $columns;
+    }
+
+    /**
+     * Manipulate the default value.
+     *
+     * Removes () wrapping default values, extracts strings from
+     * N'' wrappers and collation text and converts NULL strings.
+     *
+     * @param string $type The schema type
+     * @param string|null $default The default value.
+     * @return string|int|null
+     */
+    protected function _defaultValue(string $type, ?string $default): string|int|null
+    {
+        if ($default === null) {
+            return null;
+        }
+
+        // remove () surrounding value (NULL) but leave () at the end of functions
+        // integers might have two ((0)) wrapping value
+        if (preg_match('/^\(+(.*?(\(\))?)\)+$/', $default, $matches)) {
+            $default = $matches[1];
+        }
+
+        if ($default === 'NULL') {
+            return null;
+        }
+
+        if ($type === TableSchemaInterface::TYPE_BOOLEAN) {
+            return (int)$default;
+        }
+
+        // Remove quotes
+        if (preg_match("/^\(?N?'(.*)'\)?/", $default, $matches)) {
+            return str_replace("''", "'", $matches[1]);
+        }
+
+        return $default;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeIndexSql(string $tableName, array $config): array
+    {
+        $sql = $this->describeIndexQuery();
+        $schema = $config['schema'] ?? static::DEFAULT_SCHEMA_NAME;
+
+        return [$sql, [$tableName, $schema]];
+    }
+
+    /**
+     * Get the query to describe indexes
+     *
+     * @return string
+     */
+    private function describeIndexQuery(): string
+    {
+        return "SELECT
+                I.[name] AS [index_name],
+                IC.[index_column_id] AS [index_order],
+                AC.[name] AS [column_name],
+                I.[is_unique], I.[is_primary_key],
+                I.[is_unique_constraint],
+                IC.[is_included_column]
+            FROM sys.[tables] AS T
+            INNER JOIN sys.[schemas] S ON S.[schema_id] = T.[schema_id]
+            INNER JOIN sys.[indexes] I ON T.[object_id] = I.[object_id]
+            INNER JOIN sys.[index_columns] IC ON I.[object_id] = IC.[object_id] AND I.[index_id] = IC.[index_id]
+            INNER JOIN sys.[all_columns] AC ON T.[object_id] = AC.[object_id] AND IC.[column_id] = AC.[column_id]
+            WHERE T.[is_ms_shipped] = 0 AND I.[type_desc] <> 'HEAP' AND T.[name] = ? AND S.[name] = ?
+            ORDER BY I.[index_id], IC.[index_column_id]";
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function convertIndexDescription(TableSchema $schema, array $row): void
+    {
+        $type = TableSchema::INDEX_INDEX;
+        $name = $row['index_name'];
+        if ($row['is_primary_key']) {
+            $name = TableSchema::CONSTRAINT_PRIMARY;
+            $type = TableSchema::CONSTRAINT_PRIMARY;
+        }
+        if (($row['is_unique'] || $row['is_unique_constraint']) && $type === TableSchema::INDEX_INDEX) {
+            $type = TableSchema::CONSTRAINT_UNIQUE;
+        }
+
+        if ($type === TableSchema::INDEX_INDEX) {
+            $existing = $schema->getIndex($name);
+        } else {
+            $existing = $schema->getConstraint($name);
+        }
+
+        $columns = [$row['column_name']];
+        if ($existing) {
+            $columns = array_merge($existing['columns'], $columns);
+        }
+
+        if ($type === TableSchema::CONSTRAINT_PRIMARY || $type === TableSchema::CONSTRAINT_UNIQUE) {
+            $schema->addConstraint($name, [
+                'type' => $type,
+                'columns' => $columns,
+            ]);
+
+            return;
+        }
+        $schema->addIndex($name, [
+            'type' => $type,
+            'columns' => $columns,
+        ]);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeIndexes(string $tableName): array
+    {
+        [$schema, $name] = $this->splitTablename($tableName);
+        $sql = $this->describeIndexQuery();
+        $indexes = [];
+        $statement = $this->_driver->execute($sql, [$name, $schema]);
+        foreach ($statement->fetchAll('assoc') as $row) {
+            $type = TableSchema::INDEX_INDEX;
+            $name = $row['index_name'];
+            $constraint = null;
+            if ($row['is_primary_key']) {
+                $constraint = $name;
+                $name = TableSchema::CONSTRAINT_PRIMARY;
+                $type = TableSchema::CONSTRAINT_PRIMARY;
+            }
+            if (($row['is_unique'] || $row['is_unique_constraint']) && $type === TableSchema::INDEX_INDEX) {
+                $type = TableSchema::CONSTRAINT_UNIQUE;
+            }
+
+            if (!isset($indexes[$name])) {
+                $indexes[$name] = [
+                    'name' => $name,
+                    'type' => $type,
+                    'columns' => [],
+                    'length' => [],
+                ];
+            }
+            if ($row['is_included_column']) {
+                $indexes[$name]['include'][] = $row['column_name'];
+            } else {
+                $indexes[$name]['columns'][] = $row['column_name'];
+            }
+            if ($constraint) {
+                $indexes[$name]['constraint'] = $constraint;
+            }
+        }
+
+        return array_values($indexes);
+    }
+
+    /**
+     * Get the query to describe foreign keys
+     *
+     * @return string
+     */
+    private function describeForeignKeyQuery(): string
+    {
+        // phpcs:disable Generic.Files.LineLength
+
+        return 'SELECT FK.[name] AS [foreign_key_name],
+            FK.[delete_referential_action_desc] AS [delete_type],
+            FK.[update_referential_action_desc] AS [update_type],
+            C.name AS [column],
+            RT.name AS [reference_table],
+            RC.name AS [reference_column]
+            FROM sys.foreign_keys FK
+            INNER JOIN sys.foreign_key_columns FKC ON FKC.constraint_object_id = FK.object_id
+            INNER JOIN sys.tables T ON T.object_id = FKC.parent_object_id
+            INNER JOIN sys.tables RT ON RT.object_id = FKC.referenced_object_id
+            INNER JOIN sys.schemas S ON S.schema_id = T.schema_id AND S.schema_id = RT.schema_id
+            INNER JOIN sys.columns C ON C.column_id = FKC.parent_column_id AND C.object_id = FKC.parent_object_id
+            INNER JOIN sys.columns RC ON RC.column_id = FKC.referenced_column_id AND RC.object_id = FKC.referenced_object_id
+            WHERE FK.is_ms_shipped = 0 AND T.name = ? AND S.name = ?
+            ORDER BY FKC.constraint_column_id';
+        // phpcs:enable Generic.Files.LineLength
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeForeignKeys(string $tableName): array
+    {
+        [$schema, $name] = $this->splitTablename($tableName);
+        $sql = $this->describeForeignKeyQuery();
+        $keys = [];
+        $statement = $this->_driver->execute($sql, [$name, $schema]);
+        foreach ($statement->fetchAll('assoc') as $row) {
+            $name = $row['foreign_key_name'];
+            if (!isset($keys[$name])) {
+                $keys[$name] = [
+                    'name' => $name,
+                    'type' => TableSchema::CONSTRAINT_FOREIGN,
+                    'columns' => [],
+                    'references' => [$row['reference_table'], []],
+                    'update' => $this->_convertOnClause($row['update_type']),
+                    'delete' => $this->_convertOnClause($row['delete_type']),
+                ];
+            }
+            $keys[$name]['columns'][] = $row['column'];
+            $keys[$name]['references'][1][] = $row['reference_column'];
+        }
+
+        foreach ($keys as $id => $key) {
+            // references.1 is the referenced columns. Backwards compat
+            // requires a single column to be a string, but multiple to be an array.
+            if (count($key['references'][1]) === 1) {
+                $keys[$id]['references'][1] = $key['references'][1][0];
+            }
+        }
+
+        return array_values($keys);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeForeignKeySql(string $tableName, array $config): array
+    {
+        $sql = $this->describeForeignKeyQuery();
+        $schema = $config['schema'] ?? static::DEFAULT_SCHEMA_NAME;
+
+        return [$sql, [$tableName, $schema]];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function convertForeignKeyDescription(TableSchema $schema, array $row): void
+    {
+        $data = [
+            'type' => TableSchema::CONSTRAINT_FOREIGN,
+            'columns' => [$row['column']],
+            'references' => [$row['reference_table'], $row['reference_column']],
+            'update' => $this->_convertOnClause($row['update_type']),
+            'delete' => $this->_convertOnClause($row['delete_type']),
+        ];
+        $name = $row['foreign_key_name'];
+        $schema->addConstraint($name, $data);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function describeOptions(string $tableName): array
+    {
+        return [];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function _foreignOnClause(string $on): string
+    {
+        $parent = parent::_foreignOnClause($on);
+
+        return $parent === 'RESTRICT' ? parent::_foreignOnClause(TableSchema::ACTION_NO_ACTION) : $parent;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function _convertOnClause(string $clause): string
+    {
+        return match ($clause) {
+            'NO_ACTION' => TableSchema::ACTION_NO_ACTION,
+            'CASCADE' => TableSchema::ACTION_CASCADE,
+            'SET_NULL' => TableSchema::ACTION_SET_NULL,
+            'SET_DEFAULT' => TableSchema::ACTION_SET_DEFAULT,
+            default => TableSchema::ACTION_SET_NULL,
+        };
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function columnSql(TableSchema $schema, string $name): string
+    {
+        $data = $schema->getColumn($name);
+        assert($data !== null);
+        $data['name'] = $name;
+
+        $sql = $this->_getTypeSpecificColumnSql($data['type'], $schema, $name);
+        if ($sql !== null) {
+            return $sql;
+        }
+        $autoIncrementTypes = [
+            TableSchemaInterface::TYPE_TINYINTEGER,
+            TableSchemaInterface::TYPE_SMALLINTEGER,
+            TableSchemaInterface::TYPE_INTEGER,
+            TableSchemaInterface::TYPE_BIGINTEGER,
+        ];
+        $primaryKey = $schema->getPrimaryKey();
+        if (
+            in_array($data['type'], $autoIncrementTypes, true) &&
+            $primaryKey === [$name] &&
+            $name === 'id'
+        ) {
+            $data['autoIncrement'] = true;
+        }
+
+        return $this->columnDefinitionSql($data);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function columnDefinitionSql(array $column): string
+    {
+        $name = $column['name'];
+        $column += [
+            'length' => null,
+            'precision' => null,
+        ];
+        $out = $this->_driver->quoteIdentifier($name);
+        $typeMap = [
+            TableSchemaInterface::TYPE_TINYINTEGER => ' TINYINT',
+            TableSchemaInterface::TYPE_SMALLINTEGER => ' SMALLINT',
+            TableSchemaInterface::TYPE_INTEGER => ' INTEGER',
+            TableSchemaInterface::TYPE_BIGINTEGER => ' BIGINT',
+            TableSchemaInterface::TYPE_BINARY_UUID => ' UNIQUEIDENTIFIER',
+            TableSchemaInterface::TYPE_BOOLEAN => ' BIT',
+            TableSchemaInterface::TYPE_CHAR => ' NCHAR',
+            TableSchemaInterface::TYPE_STRING => ' NVARCHAR',
+            TableSchemaInterface::TYPE_FLOAT => ' FLOAT',
+            TableSchemaInterface::TYPE_DECIMAL => ' DECIMAL',
+            TableSchemaInterface::TYPE_DATE => ' DATE',
+            TableSchemaInterface::TYPE_TIME => ' TIME',
+            TableSchemaInterface::TYPE_DATETIME => ' DATETIME2',
+            TableSchemaInterface::TYPE_DATETIME_FRACTIONAL => ' DATETIME2',
+            TableSchemaInterface::TYPE_TIMESTAMP => ' DATETIME2',
+            TableSchemaInterface::TYPE_TIMESTAMP_FRACTIONAL => ' DATETIME2',
+            TableSchemaInterface::TYPE_TIMESTAMP_TIMEZONE => ' DATETIME2',
+            TableSchemaInterface::TYPE_UUID => ' UNIQUEIDENTIFIER',
+            TableSchemaInterface::TYPE_NATIVE_UUID => ' UNIQUEIDENTIFIER',
+            TableSchemaInterface::TYPE_JSON => ' NVARCHAR(MAX)',
+            TableSchemaInterface::TYPE_GEOMETRY => ' GEOMETRY',
+            TableSchemaInterface::TYPE_POINT => ' GEOGRAPHY',
+            TableSchemaInterface::TYPE_LINESTRING => ' GEOGRAPHY',
+            TableSchemaInterface::TYPE_POLYGON => ' GEOGRAPHY',
+        ];
+
+        $foundType = false;
+        if (isset($typeMap[$column['type']])) {
+            $out .= $typeMap[$column['type']];
+            $foundType = true;
+        }
+
+        $hasLength = [
+            TableSchemaInterface::TYPE_CHAR,
+            TableSchemaInterface::TYPE_STRING,
+            TableSchemaInterface::TYPE_BINARY,
+        ];
+        $autoIncrementTypes = [
+            TableSchemaInterface::TYPE_TINYINTEGER,
+            TableSchemaInterface::TYPE_SMALLINTEGER,
+            TableSchemaInterface::TYPE_INTEGER,
+            TableSchemaInterface::TYPE_BIGINTEGER,
+        ];
+        $autoIncrement = (bool)($column['autoIncrement'] ?? false);
+        if (in_array($column['type'], $autoIncrementTypes, true) && $autoIncrement) {
+            $out .= ' IDENTITY(1, 1)';
+            $foundType = true;
+            unset($column['default']);
+        }
+
+        if ($column['type'] === TableSchemaInterface::TYPE_STRING && !isset($column['length'])) {
+            $column['length'] = TableSchema::LENGTH_TINY;
+        } elseif (
+            $column['type'] === TableSchemaInterface::TYPE_TEXT &&
+            $column['length'] !== TableSchema::LENGTH_TINY
+        ) {
+            $out .= ' NVARCHAR(MAX)';
+            $foundType = true;
+        }
+
+        if ($column['type'] === TableSchemaInterface::TYPE_BINARY) {
+            if (
+                !isset($column['length'])
+                || in_array($column['length'], [TableSchema::LENGTH_MEDIUM, TableSchema::LENGTH_LONG], true)
+            ) {
+                $column['length'] = 'MAX';
+            }
+
+            if ($column['length'] === 1) {
+                $out .= ' BINARY';
+            } else {
+                $out .= ' VARBINARY';
+            }
+            $foundType = true;
+        }
+
+        if ($column['type'] === TableSchemaInterface::TYPE_TEXT && $column['length'] === TableSchema::LENGTH_TINY) {
+            $out .= ' NVARCHAR';
+            $hasLength[] = $column['type'];
+            $foundType = true;
+        }
+        if (!$foundType) {
+            $out .= ' ' . strtoupper($column['type']);
+            $hasLength[] = $column['type'];
+        }
+        if (in_array($column['type'], $hasLength, true) && isset($column['length'])) {
+            $out .= '(' . $column['length'] . ')';
+        }
+
+        $hasCollate = [
+            TableSchemaInterface::TYPE_TEXT,
+            TableSchemaInterface::TYPE_STRING,
+            TableSchemaInterface::TYPE_CHAR,
+        ];
+        if (in_array($column['type'], $hasCollate, true) && isset($column['collate']) && $column['collate'] !== '') {
+            $out .= ' COLLATE ' . $column['collate'];
+        }
+
+        $precisionTypes = [
+            TableSchemaInterface::TYPE_FLOAT,
+            TableSchemaInterface::TYPE_DATETIME,
+            TableSchemaInterface::TYPE_DATETIME_FRACTIONAL,
+            TableSchemaInterface::TYPE_TIMESTAMP,
+            TableSchemaInterface::TYPE_TIMESTAMP_FRACTIONAL,
+        ];
+        if (in_array($column['type'], $precisionTypes, true) && isset($column['precision'])) {
+            $out .= '(' . (int)$column['precision'] . ')';
+        }
+
+        if (
+            $column['type'] === TableSchemaInterface::TYPE_DECIMAL &&
+            (isset($column['length']) || isset($column['precision']))
+        ) {
+            $out .= '(' . (int)$column['length'] . ',' . (int)$column['precision'] . ')';
+        }
+
+        if (isset($column['null']) && $column['null'] === false) {
+            $out .= ' NOT NULL';
+        }
+
+        $dateTimeTypes = [
+            TableSchemaInterface::TYPE_DATETIME,
+            TableSchemaInterface::TYPE_DATETIME_FRACTIONAL,
+            TableSchemaInterface::TYPE_TIMESTAMP,
+            TableSchemaInterface::TYPE_TIMESTAMP_FRACTIONAL,
+        ];
+        $dateTimeDefaults = [
+            'current_timestamp',
+            'getdate()',
+            'getutcdate()',
+            'sysdatetime()',
+            'sysutcdatetime()',
+            'sysdatetimeoffset()',
+        ];
+        if (
+            isset($column['default']) &&
+            in_array($column['type'], $dateTimeTypes, true) &&
+            is_string($column['default']) &&
+            in_array(strtolower($column['default']), $dateTimeDefaults, true)
+        ) {
+            $out .= ' DEFAULT ' . strtoupper($column['default']);
+        } elseif (isset($column['default'])) {
+            $default = is_bool($column['default'])
+                ? (int)$column['default']
+                : $this->_driver->schemaValue($column['default']);
+            $out .= ' DEFAULT ' . $default;
+        } elseif (isset($column['null']) && $column['null'] !== false) {
+            $out .= ' DEFAULT NULL';
+        }
+
+        return $out;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function addConstraintSql(TableSchema $schema): array
+    {
+        $sqlPattern = 'ALTER TABLE %s ADD %s;';
+        $sql = [];
+
+        foreach ($schema->constraints() as $name) {
+            $constraint = $schema->getConstraint($name);
+            assert($constraint !== null);
+            if ($constraint['type'] === TableSchema::CONSTRAINT_FOREIGN) {
+                $tableName = $this->_driver->quoteIdentifier($schema->name());
+                $sql[] = sprintf($sqlPattern, $tableName, $this->constraintSql($schema, $name));
+            }
+        }
+
+        return $sql;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function dropConstraintSql(TableSchema $schema): array
+    {
+        $sqlPattern = 'ALTER TABLE %s DROP CONSTRAINT %s;';
+        $sql = [];
+
+        foreach ($schema->constraints() as $name) {
+            $constraint = $schema->getConstraint($name);
+            assert($constraint !== null);
+            if ($constraint['type'] === TableSchema::CONSTRAINT_FOREIGN) {
+                $tableName = $this->_driver->quoteIdentifier($schema->name());
+                $constraintName = $this->_driver->quoteIdentifier($name);
+                $sql[] = sprintf($sqlPattern, $tableName, $constraintName);
+            }
+        }
+
+        return $sql;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function indexSql(TableSchema $schema, string $name): string
+    {
+        $index = $schema->index($name);
+        $columns = array_map(
+            $this->_driver->quoteIdentifier(...),
+            (array)$index->getColumns(),
+        );
+        $include = '';
+        $included = $index->getInclude();
+        if ($included !== null) {
+            $included = array_map(
+                $this->_driver->quoteIdentifier(...),
+                $included,
+            );
+            $include = sprintf(' INCLUDE (%s)', implode(', ', $included));
+        }
+
+        return sprintf(
+            'CREATE INDEX %s ON %s (%s)%s',
+            $this->_driver->quoteIdentifier($name),
+            $this->_driver->quoteIdentifier($schema->name()),
+            implode(', ', $columns),
+            $include,
+        );
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function constraintSql(TableSchema $schema, string $name): string
+    {
+        $data = $schema->getConstraint($name);
+        assert($data !== null);
+        $out = 'CONSTRAINT ' . $this->_driver->quoteIdentifier($name);
+        if ($data['type'] === TableSchema::CONSTRAINT_PRIMARY) {
+            $out = 'PRIMARY KEY';
+        }
+        if ($data['type'] === TableSchema::CONSTRAINT_UNIQUE) {
+            $out .= ' UNIQUE';
+        }
+
+        return $this->_keySql($out, $data);
+    }
+
+    /**
+     * Helper method for generating key SQL snippets.
+     *
+     * @param string $prefix The key prefix
+     * @param array $data Key data.
+     * @return string
+     */
+    protected function _keySql(string $prefix, array $data): string
+    {
+        $columns = array_map(
+            $this->_driver->quoteIdentifier(...),
+            $data['columns'],
+        );
+        if ($data['type'] === TableSchema::CONSTRAINT_FOREIGN) {
+            return $prefix . sprintf(
+                ' FOREIGN KEY (%s) REFERENCES %s (%s) ON UPDATE %s ON DELETE %s',
+                implode(', ', $columns),
+                $this->_driver->quoteIdentifier($data['references'][0]),
+                $this->_convertConstraintColumns($data['references'][1]),
+                $this->_foreignOnClause($data['update']),
+                $this->_foreignOnClause($data['delete']),
+            );
+        }
+
+        return $prefix . ' (' . implode(', ', $columns) . ')';
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function createTableSql(TableSchema $schema, array $columns, array $constraints, array $indexes): array
+    {
+        $content = array_merge($columns, $constraints);
+        $content = implode(",\n", array_filter($content));
+        $tableName = $this->_driver->quoteIdentifier($schema->name());
+        $out = [];
+        $out[] = sprintf("CREATE TABLE %s (\n%s\n)", $tableName, $content);
+        foreach ($indexes as $index) {
+            $out[] = $index;
+        }
+        foreach ($schema->columns() as $name) {
+            $column = $schema->getColumn($name);
+            $comment = $column['comment'] ?? null;
+            if ($comment !== null) {
+                $out[] = $this->columnCommentSql($schema, $name, $comment);
+            }
+        }
+
+        return $out;
+    }
+
+    /**
+     * Generate the SQL to create a column comment.
+     *
+     * @param \Cake\Database\Schema\TableSchema $schema The table schema.
+     * @param string $name The column name.
+     * @param string $comment The column comment.
+     * @return string
+     */
+    protected function columnCommentSql(TableSchema $schema, string $name, string $comment): string
+    {
+        $tableName = $this->_driver->quoteIdentifier($schema->name());
+        $columnName = $this->_driver->quoteIdentifier($name);
+        $comment = $this->_driver->schemaValue($comment);
+
+        return sprintf(
+            "EXEC sp_addextendedproperty N'MS_Description', %s, N'SCHEMA', N'dbo', N'TABLE', %s, N'COLUMN', %s;",
+            $comment,
+            $tableName,
+            $columnName,
+        );
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function truncateTableSql(TableSchema $schema): array
+    {
+        $name = $this->_driver->quoteIdentifier($schema->name());
+        $queries = [
+            sprintf('DELETE FROM %s', $name),
+        ];
+
+        // Restart identity sequences
+        $pk = $schema->getPrimaryKey();
+        if (count($pk) === 1) {
+            $column = $schema->getColumn($pk[0]);
+            assert($column !== null);
+            if (in_array($column['type'], ['integer', 'biginteger'])) {
+                $queries[] = sprintf(
+                    "IF EXISTS (SELECT * FROM sys.identity_columns WHERE OBJECT_NAME(OBJECT_ID) = '%s' AND " .
+                    "last_value IS NOT NULL) DBCC CHECKIDENT('%s', RESEED, 0)",
+                    $schema->name(),
+                    $schema->name(),
+                );
+            }
+        }
+
+        return $queries;
+    }
+}
diff --git a/src/Database/Schema/Table.php b/src/Database/Schema/Table.php
deleted file mode 100644
index d7dccd59393..00000000000
--- a/src/Database/Schema/Table.php
+++ /dev/null
@@ -1,3 +0,0 @@
-
      */
-    protected $_columns = [];
+    protected array $_columns = [];
 
     /**
      * A map with columns to types
      *
-     * @var array
+     * @var array
      */
-    protected $_typeMap = [];
+    protected array $_typeMap = [];
 
     /**
      * Indexes in the table.
      *
-     * @var array
+     * @var array
      */
-    protected $_indexes = [];
+    protected array $_indexes = [];
 
     /**
      * Constraints in the table.
      *
-     * @var array
+     * @var array
      */
-    protected $_constraints = [];
+    protected array $_constraints = [];
 
     /**
      * Options for the table.
      *
-     * @var array
+     * @var array
      */
-    protected $_options = [];
+    protected array $_options = [];
 
     /**
-     * Whether or not the table is temporary
+     * Whether the table is temporary
      *
      * @var bool
      */
-    protected $_temporary = false;
+    protected bool $_temporary = false;
 
     /**
      * Column length when using a `tiny` column type
      *
      * @var int
      */
-    const LENGTH_TINY = 255;
+    public const LENGTH_TINY = 255;
 
     /**
      * Column length when using a `medium` column type
      *
      * @var int
      */
-    const LENGTH_MEDIUM = 16777215;
+    public const LENGTH_MEDIUM = 16777215;
 
     /**
      * Column length when using a `long` column type
      *
      * @var int
      */
-    const LENGTH_LONG = 4294967295;
+    public const LENGTH_LONG = 4294967295;
 
     /**
      * Valid column length that can be used with text type columns
      *
-     * @var array
+     * @var array
      */
-    public static $columnLengths = [
+    public static array $columnLengths = [
         'tiny' => self::LENGTH_TINY,
         'medium' => self::LENGTH_MEDIUM,
-        'long' => self::LENGTH_LONG
+        'long' => self::LENGTH_LONG,
     ];
 
     /**
      * The valid keys that can be used in a column
      * definition.
      *
-     * @var array
+     * @var array
      */
-    protected static $_columnKeys = [
+    protected static array $_columnKeys = [
         'type' => null,
         'baseType' => null,
         'length' => null,
@@ -132,29 +132,38 @@ class TableSchema implements TableSchemaInterface, SqlGeneratorInterface
     /**
      * Additional type specific properties.
      *
-     * @var array
+     * @var array>
      */
-    protected static $_columnExtras = [
+    protected static array $_columnExtras = [
         'string' => [
-            'fixed' => null,
+            'collate' => null,
+        ],
+        'char' => [
             'collate' => null,
         ],
         'text' => [
             'collate' => null,
         ],
+        'uuid' => [
+            'collate' => null,
+        ],
         'tinyinteger' => [
             'unsigned' => null,
+            'autoIncrement' => null,
         ],
         'smallinteger' => [
             'unsigned' => null,
+            'autoIncrement' => null,
         ],
         'integer' => [
             'unsigned' => null,
             'autoIncrement' => null,
+            'generated' => null,
         ],
         'biginteger' => [
             'unsigned' => null,
             'autoIncrement' => null,
+            'generated' => null,
         ],
         'decimal' => [
             'unsigned' => null,
@@ -162,29 +171,72 @@ class TableSchema implements TableSchemaInterface, SqlGeneratorInterface
         'float' => [
             'unsigned' => null,
         ],
+        'geometry' => [
+            'geometryType' => null,
+            'srid' => null,
+        ],
+        'geography' => [
+            'geometryType' => null,
+            'srid' => null,
+        ],
+        'point' => [
+            'geometryType' => null,
+            'srid' => null,
+        ],
+        'linestring' => [
+            'geometryType' => null,
+            'srid' => null,
+        ],
+        'polygon' => [
+            'geometryType' => null,
+            'srid' => null,
+        ],
+        'datetime' => [
+            'onUpdate' => null,
+        ],
+        'datetimefractional' => [
+            'onUpdate' => null,
+        ],
+        'timestamp' => [
+            'onUpdate' => null,
+        ],
+        'timestampfractional' => [
+            'onUpdate' => null,
+        ],
+        'timestamptimezone' => [
+            'onUpdate' => null,
+        ],
+        'binary' => [
+            'fixed' => null,
+        ],
     ];
 
     /**
      * The valid keys that can be used in an index
      * definition.
      *
-     * @var array
+     * @var array
      */
-    protected static $_indexKeys = [
+    protected static array $_indexKeys = [
         'type' => null,
         'columns' => [],
         'length' => [],
         'references' => [],
+        'include' => null,
         'update' => 'restrict',
         'delete' => 'restrict',
+        'constraint' => null,
+        'deferrable' => null,
+        'expression' => null,
+        'accessMethod' => null,
     ];
 
     /**
      * Names of the valid index types.
      *
-     * @var array
+     * @var array
      */
-    protected static $_validIndexTypes = [
+    protected static array $_validIndexTypes = [
         self::INDEX_INDEX,
         self::INDEX_FULLTEXT,
     ];
@@ -192,20 +244,21 @@ class TableSchema implements TableSchemaInterface, SqlGeneratorInterface
     /**
      * Names of the valid constraint types.
      *
-     * @var array
+     * @var array
      */
-    protected static $_validConstraintTypes = [
+    protected static array $_validConstraintTypes = [
         self::CONSTRAINT_PRIMARY,
         self::CONSTRAINT_UNIQUE,
         self::CONSTRAINT_FOREIGN,
+        self::CONSTRAINT_CHECK,
     ];
 
     /**
      * Names of the valid foreign key actions.
      *
-     * @var array
+     * @var array
      */
-    protected static $_validForeignKeyActions = [
+    protected static array $_validForeignKeyActions = [
         self::ACTION_CASCADE,
         self::ACTION_SET_NULL,
         self::ACTION_SET_DEFAULT,
@@ -218,78 +271,85 @@ class TableSchema implements TableSchemaInterface, SqlGeneratorInterface
      *
      * @var string
      */
-    const CONSTRAINT_PRIMARY = 'primary';
+    public const CONSTRAINT_PRIMARY = 'primary';
 
     /**
      * Unique constraint type
      *
      * @var string
      */
-    const CONSTRAINT_UNIQUE = 'unique';
+    public const CONSTRAINT_UNIQUE = 'unique';
 
     /**
      * Foreign constraint type
      *
      * @var string
      */
-    const CONSTRAINT_FOREIGN = 'foreign';
+    public const CONSTRAINT_FOREIGN = 'foreign';
+
+    /**
+     * check constraint type
+     *
+     * @var string
+     */
+    public const CONSTRAINT_CHECK = 'check';
 
     /**
      * Index - index type
      *
      * @var string
      */
-    const INDEX_INDEX = 'index';
+    public const INDEX_INDEX = Index ::INDEX;
 
     /**
      * Fulltext index type
      *
      * @var string
      */
-    const INDEX_FULLTEXT = 'fulltext';
+    public const INDEX_FULLTEXT = Index::FULLTEXT;
 
     /**
      * Foreign key cascade action
      *
      * @var string
      */
-    const ACTION_CASCADE = 'cascade';
+    public const ACTION_CASCADE = ForeignKey::CASCADE;
 
     /**
      * Foreign key set null action
      *
      * @var string
      */
-    const ACTION_SET_NULL = 'setNull';
+    public const ACTION_SET_NULL = ForeignKey::SET_NULL;
 
     /**
      * Foreign key no action
      *
      * @var string
      */
-    const ACTION_NO_ACTION = 'noAction';
+    public const ACTION_NO_ACTION = ForeignKey::NO_ACTION;
 
     /**
      * Foreign key restrict action
      *
      * @var string
      */
-    const ACTION_RESTRICT = 'restrict';
+    public const ACTION_RESTRICT = ForeignKey::RESTRICT;
 
     /**
      * Foreign key restrict default
      *
      * @var string
      */
-    const ACTION_SET_DEFAULT = 'setDefault';
+    public const ACTION_SET_DEFAULT = ForeignKey::SET_DEFAULT;
 
     /**
      * Constructor.
      *
      * @param string $table The table name.
-     * @param array $columns The list of columns for the schema.
+     * @param array $columns The list of columns for the schema.
      */
-    public function __construct($table, array $columns = [])
+    public function __construct(string $table, array $columns = [])
     {
         $this->_table = $table;
         foreach ($columns as $field => $definition) {
@@ -298,17 +358,17 @@ public function __construct($table, array $columns = [])
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function name()
+    public function name(): string
     {
         return $this->_table;
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function addColumn($name, $attrs)
+    public function addColumn(string $name, array|string $attrs)
     {
         if (is_string($attrs)) {
             $attrs = ['type' => $attrs];
@@ -317,17 +377,44 @@ public function addColumn($name, $attrs)
         if (isset(static::$_columnExtras[$attrs['type']])) {
             $valid += static::$_columnExtras[$attrs['type']];
         }
+
         $attrs = array_intersect_key($attrs, $valid);
-        $this->_columns[$name] = $attrs + $valid;
-        $this->_typeMap[$name] = $this->_columns[$name]['type'];
+        $attrs['name'] = $name;
+        foreach (array_keys($attrs) as $key) {
+            $value = $attrs[$key];
+            if ($value === null) {
+                unset($attrs[$key]);
+                continue;
+            }
+            if ($key === 'autoIncrement') {
+                $attrs['identity'] = $value;
+                unset($attrs[$key]);
+                continue;
+            }
+            $attrs[$key] = $value;
+        }
+
+        // Cast numeric values that may come as floats from database drivers.
+        // PHP 8.4 is stricter about implicit float-to-int conversions.
+        // Known to affect SQLite on Windows x86.
+        foreach (['length', 'precision', 'srid'] as $key) {
+            if (isset($attrs[$key])) {
+                $attrs[$key] = (int)$attrs[$key];
+            }
+        }
+
+        $column = new Column(...$attrs);
+
+        $this->_columns[$name] = $column;
+        $this->_typeMap[$name] = $column->getType();
 
         return $this;
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function removeColumn($name)
+    public function removeColumn(string $name)
     {
         unset($this->_columns[$name], $this->_typeMap[$name]);
 
@@ -335,230 +422,263 @@ public function removeColumn($name)
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function columns()
+    public function columns(): array
     {
         return array_keys($this->_columns);
     }
 
     /**
-     * Get column data in the table.
-     *
-     * @param string $name The column name.
-     * @return array|null Column data or null.
-     * @deprecated 3.5.0 Use getColumn() instead.
+     * @inheritDoc
      */
-    public function column($name)
-    {
-        return $this->getColumn($name);
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function getColumn($name)
+    public function getColumn(string $name): ?array
     {
         if (!isset($this->_columns[$name])) {
             return null;
         }
         $column = $this->_columns[$name];
-        unset($column['baseType']);
+        $attrs = $column->toArray();
 
-        return $column;
+        $expected = static::$_columnKeys;
+        if (isset(static::$_columnExtras[$attrs['type']])) {
+            $expected += static::$_columnExtras[$attrs['type']];
+        }
+
+        if (isset($attrs['baseType']) && $attrs['baseType'] === $attrs['type']) {
+            unset($attrs['baseType']);
+        }
+
+        // Remove any attributes that weren't in the allow list.
+        // This is to provide backwards compatible keys
+        return array_intersect_key($attrs, $expected);
     }
 
     /**
-     * Sets the type of a column, or returns its current type
-     * if none is passed.
+     * Get a column object for a given column name.
+     *
+     * Will raise an exception if the column does not exist.
      *
-     * @param string $name The column to get the type of.
-     * @param string|null $type The type to set the column to.
-     * @return string|null Either the column type or null.
-     * @deprecated 3.5.0 Use setColumnType()/getColumnType() instead.
+     * @param string $name The name of the column to get.
+     * @return \Cake\Database\Schema\Column
      */
-    public function columnType($name, $type = null)
+    public function column(string $name): Column
     {
-        if ($type !== null) {
-            $this->setColumnType($name, $type);
+        $column = $this->_columns[$name] ?? null;
+        if ($column === null) {
+            $message = sprintf(
+                'Table `%s` does not contain a column named `%s`.',
+                $this->_table,
+                $name,
+            );
+            throw new DatabaseException($message);
         }
 
-        return $this->getColumnType($name);
+        return $column;
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function getColumnType($name)
+    public function getColumnType(string $name): ?string
     {
         if (!isset($this->_columns[$name])) {
             return null;
         }
 
-        return $this->_columns[$name]['type'];
+        return $this->_columns[$name]->getType();
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function setColumnType($name, $type)
+    public function setColumnType(string $name, string $type)
     {
         if (!isset($this->_columns[$name])) {
-            return $this;
+            $message = sprintf(
+                'Column `%s` of table `%s`: The column type `%s` can only be set if the column already exists;',
+                $name,
+                $this->_table,
+                $type,
+            );
+            $message .= ' can be checked using `hasColumn()`.';
+
+            throw new DatabaseException($message);
         }
 
-        $this->_columns[$name]['type'] = $type;
+        $this->_columns[$name]
+            ->setType($type)
+            ->setBaseType(null);
         $this->_typeMap[$name] = $type;
 
         return $this;
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function hasColumn($name)
+    public function hasColumn(string $name): bool
     {
         return isset($this->_columns[$name]);
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function baseColumnType($column)
+    public function baseColumnType(string $column): ?string
     {
-        if (isset($this->_columns[$column]['baseType'])) {
-            return $this->_columns[$column]['baseType'];
-        }
-
-        $type = $this->getColumnType($column);
-
-        if ($type === null) {
+        if (!isset($this->_columns[$column])) {
             return null;
         }
 
-        if (Type::map($type)) {
-            $type = Type::build($type)->getBaseType();
-        }
-
-        return $this->_columns[$column]['baseType'] = $type;
+        return $this->_columns[$column]->getBaseType();
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function typeMap()
+    public function typeMap(): array
     {
         return $this->_typeMap;
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function isNullable($name)
+    public function isNullable(string $name): bool
     {
         if (!isset($this->_columns[$name])) {
             return true;
         }
 
-        return ($this->_columns[$name]['null'] === true);
+        return $this->_columns[$name]->getNull() === true;
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function defaultValues()
+    public function defaultValues(): array
     {
         $defaults = [];
-        foreach ($this->_columns as $name => $data) {
-            if (!array_key_exists('default', $data)) {
-                continue;
-            }
-            if ($data['default'] === null && $data['null'] !== true) {
+        foreach ($this->_columns as $column) {
+            $default = $column->getDefault();
+            if ($default === null && $column->getNull() !== true && $column->getName()) {
                 continue;
             }
-            $defaults[$name] = $data['default'];
+            $defaults[$column->getName()] = $default;
         }
 
         return $defaults;
     }
 
     /**
-     * {@inheritDoc}
-     * @throws \Cake\Database\Exception
+     * @inheritDoc
      */
-    public function addIndex($name, $attrs)
+    public function addIndex(string $name, array|string $attrs)
     {
         if (is_string($attrs)) {
             $attrs = ['type' => $attrs];
         }
         $attrs = array_intersect_key($attrs, static::$_indexKeys);
         $attrs += static::$_indexKeys;
-        unset($attrs['references'], $attrs['update'], $attrs['delete']);
+        unset(
+            $attrs['references'],
+            $attrs['update'],
+            $attrs['delete'],
+            $attrs['constraint'],
+            $attrs['deferrable'],
+            $attrs['expression'],
+        );
 
         if (!in_array($attrs['type'], static::$_validIndexTypes, true)) {
-            throw new Exception(sprintf('Invalid index type "%s" in index "%s" in table "%s".', $attrs['type'], $name, $this->_table));
-        }
-        if (empty($attrs['columns'])) {
-            throw new Exception(sprintf('Index "%s" in table "%s" must have at least one column.', $name, $this->_table));
+            throw new DatabaseException(sprintf(
+                'Invalid index type `%s` in index `%s` in table `%s`.',
+                $attrs['type'],
+                $name,
+                $this->_table,
+            ));
         }
         $attrs['columns'] = (array)$attrs['columns'];
         foreach ($attrs['columns'] as $field) {
             if (empty($this->_columns[$field])) {
                 $msg = sprintf(
-                    'Columns used in index "%s" in table "%s" must be added to the Table schema first. ' .
-                    'The column "%s" was not found.',
+                    'Columns used in index `%s` in table `%s` must be added to the Table schema first. ' .
+                    'The column `%s` was not found.',
                     $name,
                     $this->_table,
-                    $field
+                    $field,
                 );
-                throw new Exception($msg);
+                throw new DatabaseException($msg);
             }
         }
-        $this->_indexes[$name] = $attrs;
+        $attrs['name'] = $name;
+
+        $this->_indexes[$name] = new Index(...$attrs);
 
         return $this;
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function indexes()
+    public function indexes(): array
     {
         return array_keys($this->_indexes);
     }
 
     /**
-     * Read information about an index based on name.
-     *
-     * @param string $name The name of the index.
-     * @return array|null Array of index data, or null
-     * @deprecated 3.5.0 Use getIndex() instead.
+     * @inheritDoc
      */
-    public function index($name)
+    public function getIndex(string $name): ?array
     {
-        return $this->getIndex($name);
+        if (!isset($this->_indexes[$name])) {
+            return null;
+        }
+        $index = $this->_indexes[$name];
+        $attrs = $index->toArray();
+
+        $optional = ['order', 'include', 'where'];
+        foreach ($optional as $key) {
+            if ($attrs[$key] === null) {
+                unset($attrs[$key]);
+            }
+        }
+        unset($attrs['name']);
+
+        return $attrs;
     }
 
     /**
-     * {@inheritDoc}
+     * Get a index object for a given index name.
+     *
+     * Will raise an exception if no index can be found.
+     *
+     * @param string $name The name of the index to get.
+     * @return \Cake\Database\Schema\Index
      */
-    public function getIndex($name)
+    public function index(string $name): Index
     {
-        if (!isset($this->_indexes[$name])) {
-            return null;
+        $index = $this->_indexes[$name] ?? null;
+        if ($index === null) {
+            $message = sprintf(
+                'Table `%s` does not contain a index named `%s`.',
+                $this->_table,
+                $name,
+            );
+            throw new DatabaseException($message);
         }
 
-        return $this->_indexes[$name];
+        return $index;
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function primaryKey()
+    public function getPrimaryKey(): array
     {
-        foreach ($this->_constraints as $name => $data) {
-            if ($data['type'] === static::CONSTRAINT_PRIMARY) {
-                return $data['columns'];
+        foreach ($this->_constraints as $data) {
+            if ($data->getType() === static::CONSTRAINT_PRIMARY) {
+                return (array)$data->getColumns();
             }
         }
 
@@ -566,66 +686,108 @@ public function primaryKey()
     }
 
     /**
-     * {@inheritDoc}
-     * @throws \Cake\Database\Exception
+     * @inheritDoc
      */
-    public function addConstraint($name, $attrs)
+    public function addConstraint(string $name, array|string $attrs)
     {
         if (is_string($attrs)) {
             $attrs = ['type' => $attrs];
         }
         $attrs = array_intersect_key($attrs, static::$_indexKeys);
         $attrs += static::$_indexKeys;
-        if (!in_array($attrs['type'], static::$_validConstraintTypes, true)) {
-            throw new Exception(sprintf('Invalid constraint type "%s" in table "%s".', $attrs['type'], $this->_table));
+        if ($attrs['constraint'] === null) {
+            unset($attrs['constraint']);
         }
-        if (empty($attrs['columns'])) {
-            throw new Exception(sprintf('Constraints in table "%s" must have at least one column.', $this->_table));
+
+        if (!in_array($attrs['type'], static::$_validConstraintTypes, true)) {
+            throw new DatabaseException(sprintf(
+                'Invalid constraint type `%s` in table `%s`.',
+                $attrs['type'],
+                $this->_table,
+            ));
         }
-        $attrs['columns'] = (array)$attrs['columns'];
-        foreach ($attrs['columns'] as $field) {
-            if (empty($this->_columns[$field])) {
-                $msg = sprintf(
-                    'Columns used in constraints must be added to the Table schema first. ' .
-                    'The column "%s" was not found in table "%s".',
-                    $field,
-                    $this->_table
-                );
-                throw new Exception($msg);
+        if ($attrs['type'] !== TableSchema::CONSTRAINT_CHECK) {
+            if (empty($attrs['columns'])) {
+                throw new DatabaseException(sprintf(
+                    'Constraints in table `%s` must have at least one column.',
+                    $this->_table,
+                ));
+            }
+            $attrs['columns'] = (array)$attrs['columns'];
+            foreach ($attrs['columns'] as $field) {
+                if (empty($this->_columns[$field])) {
+                    $msg = sprintf(
+                        'Columns used in constraints must be added to the Table schema first. ' .
+                        'The column `%s` was not found in table `%s`.',
+                        $field,
+                        $this->_table,
+                    );
+                    throw new DatabaseException($msg);
+                }
             }
         }
 
-        if ($attrs['type'] === static::CONSTRAINT_FOREIGN) {
-            $attrs = $this->_checkForeignKey($attrs);
-
-            if (isset($this->_constraints[$name])) {
-                $this->_constraints[$name]['columns'] = array_unique(array_merge(
-                    $this->_constraints[$name]['columns'],
-                    $attrs['columns']
-                ));
+        $attrs['name'] = $attrs['constraint'] ?? $name;
+        unset($attrs['constraint'], $attrs['include']);
 
-                if (isset($this->_constraints[$name]['references'])) {
-                    $this->_constraints[$name]['references'][1] = array_unique(array_merge(
-                        (array)$this->_constraints[$name]['references'][1],
-                        [$attrs['references'][1]]
-                    ));
+        $type = $attrs['type'] ?? null;
+        if ($type === static::CONSTRAINT_FOREIGN) {
+            $attrs = $this->_checkForeignKey($attrs);
+        } elseif ($type === static::CONSTRAINT_PRIMARY) {
+            $attrs = [
+                'type' => $type,
+                'name' => $attrs['name'],
+                'columns' => $attrs['columns'],
+            ];
+        } elseif ($type === static::CONSTRAINT_CHECK) {
+            $attrs = [
+                'name' => $attrs['name'],
+                'expression' => $attrs['expression'],
+            ];
+        } elseif ($type === static::CONSTRAINT_UNIQUE) {
+            $attrs = [
+                'name' => $attrs['name'],
+                'columns' => $attrs['columns'],
+                'length' => $attrs['length'],
+            ];
+        }
+        if ($type === static::CONSTRAINT_FOREIGN) {
+            $constraint = $this->_constraints[$name] ?? null;
+            if ($constraint instanceof ForeignKey) {
+                // Update an existing foreign key constraint.
+                // This is backwards compatible with the incremental
+                // build API that I would like to deprecate.
+                $constraint->setColumns(array_unique(array_merge(
+                    (array)$constraint->getColumns(),
+                    $attrs['columns'],
+                )));
+
+                if ($constraint->getReferencedTable()) {
+                    $constraint->setColumns(array_unique(array_merge(
+                        (array)$constraint->getReferencedColumns(),
+                        [$attrs['references'][1]],
+                    )));
                 }
 
                 return $this;
             }
-        } else {
-            unset($attrs['references'], $attrs['update'], $attrs['delete']);
         }
 
-        $this->_constraints[$name] = $attrs;
+        $this->_constraints[$name] = match ($type) {
+            static::CONSTRAINT_UNIQUE => new UniqueKey(...$attrs),
+            static::CONSTRAINT_FOREIGN => new ForeignKey(...$attrs),
+            static::CONSTRAINT_PRIMARY => new Constraint(...$attrs),
+            static::CONSTRAINT_CHECK => new CheckConstraint(...$attrs),
+            default => new Constraint(...$attrs),
+        };
 
         return $this;
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function dropConstraint($name)
+    public function dropConstraint(string $name)
     {
         if (isset($this->_constraints[$name])) {
             unset($this->_constraints[$name]);
@@ -635,14 +797,14 @@ public function dropConstraint($name)
     }
 
     /**
-     * Check whether or not a table has an autoIncrement column defined.
+     * Check whether a table has an autoIncrement column defined.
      *
      * @return bool
      */
-    public function hasAutoincrement()
+    public function hasAutoincrement(): bool
     {
         foreach ($this->_columns as $column) {
-            if (isset($column['autoIncrement']) && $column['autoIncrement']) {
+            if ($column->getIdentity()) {
                 return true;
             }
         }
@@ -653,135 +815,146 @@ public function hasAutoincrement()
     /**
      * Helper method to check/validate foreign keys.
      *
-     * @param array $attrs Attributes to set.
-     * @return array
-     * @throws \Cake\Database\Exception When foreign key definition is not valid.
+     * @param array $attrs Attributes to set.
+     * @return array
+     * @throws \Cake\Database\Exception\DatabaseException When foreign key definition is not valid.
      */
-    protected function _checkForeignKey($attrs)
+    protected function _checkForeignKey(array $attrs): array
     {
         if (count($attrs['references']) < 2) {
-            throw new Exception('References must contain a table and column.');
+            throw new DatabaseException('References must contain a table and column.');
         }
         if (!in_array($attrs['update'], static::$_validForeignKeyActions)) {
-            throw new Exception(sprintf('Update action is invalid. Must be one of %s', implode(',', static::$_validForeignKeyActions)));
+            throw new DatabaseException(sprintf(
+                'Update action is invalid. Must be one of %s',
+                implode(',', static::$_validForeignKeyActions),
+            ));
         }
         if (!in_array($attrs['delete'], static::$_validForeignKeyActions)) {
-            throw new Exception(sprintf('Delete action is invalid. Must be one of %s', implode(',', static::$_validForeignKeyActions)));
+            throw new DatabaseException(sprintf(
+                'Delete action is invalid. Must be one of %s',
+                implode(',', static::$_validForeignKeyActions),
+            ));
         }
 
+        // Map the backwards compatible attributes in. Need to check for existing instance.
+        $attrs['referencedTable'] = $attrs['references'][0];
+        $attrs['referencedColumns'] = (array)$attrs['references'][1];
+        unset($attrs['type'], $attrs['references'], $attrs['length'], $attrs['expression'], $attrs['accessMethod']);
+
         return $attrs;
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function constraints()
+    public function constraints(): array
     {
         return array_keys($this->_constraints);
     }
 
     /**
-     * Read information about a constraint based on name.
-     *
-     * @param string $name The name of the constraint.
-     * @return array|null Array of constraint data, or null
-     * @deprecated 3.5.0 Use getConstraint() instead.
+     * @inheritDoc
      */
-    public function constraint($name)
+    public function getConstraint(string $name): ?array
     {
-        return $this->getConstraint($name);
+        $constraint = $this->_constraints[$name] ?? null;
+        if ($constraint === null) {
+            return null;
+        }
+
+        $data = $constraint->toArray();
+        if ($constraint instanceof ForeignKey) {
+            $data['references'] = [
+                $constraint->getReferencedTable(),
+                $constraint->getReferencedColumns(),
+            ];
+            // If there is only one referenced column, we return it as a string.
+            // TODO this should be deprecated, but I don't know how to warn about it.
+            if (count($data['references'][1]) === 1) {
+                $data['references'][1] = $data['references'][1][0];
+            }
+            unset($data['referencedTable'], $data['referencedColumns']);
+        }
+        if ($constraint->getType() === static::CONSTRAINT_PRIMARY && $name === 'primary') {
+            $alias = $constraint->getName();
+            if ($alias !== 'primary') {
+                $data['constraint'] = $alias;
+            }
+        }
+        unset($data['name']);
+
+        return $data;
     }
 
     /**
-     * {@inheritDoc}
+     * Get a constraint object for a given constraint name.
+     *
+     * Constraints have a few subtypes such as foreign keys and primary keys.
+     * You can either use `instanceof` or getType() to check for subclass types.
+     *
+     * @param string $name The name of the constraint to get.
+     * @return \Cake\Database\Schema\Constraint A constraint object.
      */
-    public function getConstraint($name)
+    public function constraint(string $name): Constraint
     {
         if (!isset($this->_constraints[$name])) {
-            return null;
+            $message = sprintf(
+                'Table `%s` does not contain a constraint named `%s`.',
+                $this->_table,
+                $name,
+            );
+            throw new DatabaseException($message);
         }
 
         return $this->_constraints[$name];
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function setOptions($options)
+    public function setOptions(array $options)
     {
-        $this->_options = array_merge($this->_options, $options);
+        $this->_options = $options + $this->_options;
 
         return $this;
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function getOptions()
+    public function getOptions(): array
     {
         return $this->_options;
     }
 
     /**
-     * Get/set the options for a table.
-     *
-     * Table options allow you to set platform specific table level options.
-     * For example the engine type in MySQL.
-     *
-     * @deprecated 3.4.0 Use setOptions()/getOptions() instead.
-     * @param array|null $options The options to set, or null to read options.
-     * @return $this|array Either the TableSchema instance, or an array of options when reading.
+     * @inheritDoc
      */
-    public function options($options = null)
+    public function setTemporary(bool $temporary)
     {
-        if ($options !== null) {
-            return $this->setOptions($options);
-        }
-
-        return $this->getOptions();
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function setTemporary($temporary)
-    {
-        $this->_temporary = (bool)$temporary;
+        $this->_temporary = $temporary;
 
         return $this;
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function isTemporary()
+    public function isTemporary(): bool
     {
         return $this->_temporary;
     }
 
     /**
-     * Get/Set whether the table is temporary in the database
-     *
-     * @deprecated 3.4.0 Use setTemporary()/isTemporary() instead.
-     * @param bool|null $temporary whether or not the table is to be temporary
-     * @return $this|bool Either the TableSchema instance, the current temporary setting
-     */
-    public function temporary($temporary = null)
-    {
-        if ($temporary !== null) {
-            return $this->setTemporary($temporary);
-        }
-
-        return $this->isTemporary();
-    }
-
-    /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function createSql(Connection $connection)
+    public function createSql(Connection $connection): array
     {
-        $dialect = $connection->getDriver()->schemaDialect();
-        $columns = $constraints = $indexes = [];
+        $dialect = $connection->getWriteDriver()->schemaDialect();
+        $columns = [];
+        $constraints = [];
+        $indexes = [];
         foreach (array_keys($this->_columns) as $name) {
             $columns[] = $dialect->columnSql($this, $name);
         }
@@ -796,51 +969,98 @@ public function createSql(Connection $connection)
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function dropSql(Connection $connection)
+    public function dropSql(Connection $connection): array
     {
-        $dialect = $connection->getDriver()->schemaDialect();
+        $dialect = $connection->getWriteDriver()->schemaDialect();
 
         return $dialect->dropTableSql($this);
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function truncateSql(Connection $connection)
+    public function truncateSql(Connection $connection): array
     {
-        $dialect = $connection->getDriver()->schemaDialect();
+        $dialect = $connection->getWriteDriver()->schemaDialect();
 
         return $dialect->truncateTableSql($this);
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function addConstraintSql(Connection $connection)
+    public function addConstraintSql(Connection $connection): array
     {
-        $dialect = $connection->getDriver()->schemaDialect();
+        $dialect = $connection->getWriteDriver()->schemaDialect();
 
         return $dialect->addConstraintSql($this);
     }
 
     /**
-     * {@inheritDoc}
+     * @inheritDoc
      */
-    public function dropConstraintSql(Connection $connection)
+    public function dropConstraintSql(Connection $connection): array
     {
-        $dialect = $connection->getDriver()->schemaDialect();
+        $dialect = $connection->getWriteDriver()->schemaDialect();
 
         return $dialect->dropConstraintSql($this);
     }
 
+    /**
+     * Custom unserialization that handles compatibility
+     * with older CakePHP versions.
+     *
+     * Previously the `_columns`, `_indexes`, and `_constraints`
+     * attributes contained array data. As of 5.3, those attributes
+     * contain arrays of objects.
+     *
+     * @param array $data The serialized data.
+     * @return void
+     */
+    public function __unserialize(array $data): void
+    {
+        $this->_table = $data["\0*\0_table"] ?? '';
+
+        $columns = $data["\0*\0_columns"] ?? [];
+        foreach ($columns as $name => $column) {
+            $name = (string)$name;
+            if (is_array($column)) {
+                $this->addColumn($name, $column);
+            } else {
+                $this->_columns[$name] = $column;
+            }
+        }
+        $indexes = $data["\0*\0_indexes"] ?? [];
+        foreach ($indexes as $name => $index) {
+            $name = (string)$name;
+            if (is_array($index)) {
+                $this->addIndex($name, $index);
+            } else {
+                $this->_indexes[$name] = $index;
+            }
+        }
+        $constraints = $data["\0*\0_constraints"] ?? [];
+        foreach ($constraints as $name => $constraint) {
+            $name = (string)$name;
+            if (is_array($constraint)) {
+                $this->addConstraint($name, $constraint);
+            } else {
+                $this->_constraints[$name] = $constraint;
+            }
+        }
+        $this->_options = $data["\0*\0_options"] ?? [];
+        $this->_typeMap = $data["\0*\0_typeMap"] ?? [];
+        $this->_temporary = $data["\0*\0_temporary"] ?? false;
+    }
+
     /**
      * Returns an array of the table schema.
      *
-     * @return array
+     * @return array
      */
-    public function __debugInfo()
+    public function __debugInfo(): array
     {
         return [
             'table' => $this->_table,
@@ -853,6 +1073,3 @@ public function __debugInfo()
         ];
     }
 }
-
-// @deprecated Add backwards compat alias.
-class_alias('Cake\Database\Schema\TableSchema', 'Cake\Database\Schema\Table');
diff --git a/src/Database/Schema/TableSchemaAwareInterface.php b/src/Database/Schema/TableSchemaAwareInterface.php
deleted file mode 100644
index 7732b269bcf..00000000000
--- a/src/Database/Schema/TableSchemaAwareInterface.php
+++ /dev/null
@@ -1,37 +0,0 @@
- Column name(s) for the primary key. An
      *   empty list will be returned when the table has no primary key.
      */
-    public function primaryKey();
+    public function getPrimaryKey(): array;
 
     /**
      * Add an index.
@@ -176,31 +318,33 @@ public function primaryKey();
      * - `columns` The columns in the index.
      *
      * @param string $name The name of the index.
-     * @param array $attrs The attributes for the index.
+     * @param array|string $attrs The attributes for the index.
+     *   If string it will be used as `type`.
      * @return $this
+     * @throws \Cake\Database\Exception\DatabaseException
      */
-    public function addIndex($name, $attrs);
+    public function addIndex(string $name, array|string $attrs);
 
     /**
      * Read information about an index based on name.
      *
      * @param string $name The name of the index.
-     * @return array|null Array of index data, or null
+     * @return array|null Array of index data, or null
      */
-    public function getIndex($name);
+    public function getIndex(string $name): ?array;
 
     /**
      * Get the names of all the indexes in the table.
      *
-     * @return array
+     * @return array
      */
-    public function indexes();
+    public function indexes(): array;
 
     /**
      * Add a constraint.
      *
      * Used to add constraints to a table. For example primary keys, unique
-     * keys and foreign keys.
+     * keys, check constraints and foreign keys.
      *
      * ### Attributes
      *
@@ -209,22 +353,25 @@ public function indexes();
      * - `references` The table, column a foreign key references.
      * - `update` The behavior on update. Options are 'restrict', 'setNull', 'cascade', 'noAction'.
      * - `delete` The behavior on delete. Options are 'restrict', 'setNull', 'cascade', 'noAction'.
+     * - `expression` The SQL expression for check constraints.
      *
      * The default for 'update' & 'delete' is 'cascade'.
      *
      * @param string $name The name of the constraint.
-     * @param array $attrs The attributes for the constraint.
+     * @param array|string $attrs The attributes for the constraint.
+     *   If string it will be used as `type`.
      * @return $this
+     * @throws \Cake\Database\Exception\DatabaseException
      */
-    public function addConstraint($name, $attrs);
+    public function addConstraint(string $name, array|string $attrs);
 
     /**
      * Read information about a constraint based on name.
      *
      * @param string $name The name of the constraint.
-     * @return array|null Array of constraint data, or null
+     * @return array|null Array of constraint data, or null
      */
-    public function getConstraint($name);
+    public function getConstraint(string $name): ?array;
 
     /**
      * Remove a constraint.
@@ -232,12 +379,12 @@ public function getConstraint($name);
      * @param string $name Name of the constraint to remove
      * @return $this
      */
-    public function dropConstraint($name);
+    public function dropConstraint(string $name);
 
     /**
      * Get the names of all the constraints in the table.
      *
-     * @return array
+     * @return array
      */
-    public function constraints();
+    public function constraints(): array;
 }
diff --git a/src/Database/Schema/UniqueKey.php b/src/Database/Schema/UniqueKey.php
new file mode 100644
index 00000000000..070773e2376
--- /dev/null
+++ b/src/Database/Schema/UniqueKey.php
@@ -0,0 +1,155 @@
+ $columns The columns to constraint.
+     * @param array|null $length The length of the columns, if applicable.
+     */
+    public function __construct(
+        protected string $name,
+        protected array $columns,
+        protected ?array $length = null,
+    ) {
+        $this->type = self::UNIQUE;
+    }
+
+    /**
+     * Sets the constraint columns.
+     *
+     * @param array|string $columns Columns
+     * @return $this
+     */
+    public function setColumns(string|array $columns)
+    {
+        $this->columns = (array)$columns;
+
+        return $this;
+    }
+
+    /**
+     * Gets the constraint columns.
+     *
+     * @return ?array
+     */
+    public function getColumns(): ?array
+    {
+        return $this->columns;
+    }
+
+    /**
+     * Sets the constraint type.
+     *
+     * @param string $type Type
+     * @return $this
+     */
+    public function setType(string $type)
+    {
+        $this->type = $type;
+
+        return $this;
+    }
+
+    /**
+     * Gets the constraint type.
+     *
+     * @return string
+     */
+    public function getType(): string
+    {
+        return $this->type;
+    }
+
+    /**
+     * Sets the constraint name.
+     *
+     * @param string $name Name
+     * @return $this
+     */
+    public function setName(string $name)
+    {
+        $this->name = $name;
+
+        return $this;
+    }
+
+    /**
+     * Gets the constraint name.
+     *
+     * @return ?string
+     */
+    public function getName(): ?string
+    {
+        return $this->name;
+    }
+
+    /**
+     * Sets the constraint length.
+     *
+     * In MySQL unique constraints can have limit clauses to control the number of
+     * characters indexed in text and char columns.
+     *
+     * @param array $length array of length values
+     * @return $this
+     */
+    public function setLength(array $length)
+    {
+        $this->length = $length;
+
+        return $this;
+    }
+
+    /**
+     * Gets the constraint length.
+     *
+     * Can be an array of column names and lengths under MySQL.
+     *
+     * @return array|null
+     */
+    public function getLength(): ?array
+    {
+        return $this->length;
+    }
+
+    /**
+     * Converts a constraint to an array that is compatible
+     * with the constructor.
+     *
+     * @return array
+     */
+    public function toArray(): array
+    {
+        return [
+            'name' => $this->name,
+            'type' => $this->type,
+            'columns' => $this->columns,
+            'length' => $this->length,
+        ];
+    }
+}
diff --git a/src/Database/SchemaCache.php b/src/Database/SchemaCache.php
new file mode 100644
index 00000000000..2ea7e3fa573
--- /dev/null
+++ b/src/Database/SchemaCache.php
@@ -0,0 +1,112 @@
+_schema = $this->getSchema($connection);
+    }
+
+    /**
+     * Build metadata.
+     *
+     * @param string|null $name The name of the table to build cache data for.
+     * @return array Returns a list build table caches
+     */
+    public function build(?string $name = null): array
+    {
+        if ($name) {
+            $tables = [$name];
+        } else {
+            $tables = $this->_schema->listTables();
+        }
+
+        foreach ($tables as $table) {
+            $this->_schema->describe($table, ['forceRefresh' => true]);
+        }
+
+        return $tables;
+    }
+
+    /**
+     * Clear metadata.
+     *
+     * @param string|null $name The name of the table to clear cache data for.
+     * @return array Returns a list of cleared table caches
+     */
+    public function clear(?string $name = null): array
+    {
+        if ($name) {
+            $tables = [$name];
+        } else {
+            $tables = $this->_schema->listTables();
+        }
+
+        $cacher = $this->_schema->getCacher();
+
+        foreach ($tables as $table) {
+            $key = $this->_schema->cacheKey($table);
+            $cacher->delete($key);
+        }
+
+        return $tables;
+    }
+
+    /**
+     * Helper method to get the schema collection.
+     *
+     * @param \Cake\Database\Connection $connection Connection object
+     * @return \Cake\Database\Schema\CachedCollection
+     * @throws \RuntimeException If given connection object is not compatible with schema caching
+     */
+    public function getSchema(Connection $connection): CachedCollection
+    {
+        $config = $connection->config();
+        if (empty($config['cacheMetadata'])) {
+            $connection->cacheMetadata(true);
+        }
+
+        /** @var \Cake\Database\Schema\CachedCollection */
+        return $connection->getSchemaCollection();
+    }
+}
diff --git a/src/Database/SqlDialectTrait.php b/src/Database/SqlDialectTrait.php
deleted file mode 100644
index 6c2d2e0846e..00000000000
--- a/src/Database/SqlDialectTrait.php
+++ /dev/null
@@ -1,283 +0,0 @@
-_startQuote . $identifier . $this->_endQuote;
-        }
-
-        if (preg_match('/^[\w-]+\.[^ \*]*$/', $identifier)) {
-// string.string
-            $items = explode('.', $identifier);
-
-            return $this->_startQuote . implode($this->_endQuote . '.' . $this->_startQuote, $items) . $this->_endQuote;
-        }
-
-        if (preg_match('/^[\w-]+\.\*$/', $identifier)) {
-// string.*
-            return $this->_startQuote . str_replace('.*', $this->_endQuote . '.*', $identifier);
-        }
-
-        if (preg_match('/^([\w-]+)\((.*)\)$/', $identifier, $matches)) {
-// Functions
-            return $matches[1] . '(' . $this->quoteIdentifier($matches[2]) . ')';
-        }
-
-        // Alias.field AS thing
-        if (preg_match('/^([\w-]+(\.[\w-]+|\(.*\))*)\s+AS\s*([\w-]+)$/i', $identifier, $matches)) {
-            return $this->quoteIdentifier($matches[1]) . ' AS ' . $this->quoteIdentifier($matches[3]);
-        }
-
-        if (preg_match('/^[\w-_\s]*[\w-_]+/', $identifier)) {
-            return $this->_startQuote . $identifier . $this->_endQuote;
-        }
-
-        return $identifier;
-    }
-
-    /**
-     * Returns a callable function that will be used to transform a passed Query object.
-     * This function, in turn, will return an instance of a Query object that has been
-     * transformed to accommodate any specificities of the SQL dialect in use.
-     *
-     * @param string $type the type of query to be transformed
-     * (select, insert, update, delete)
-     * @return callable
-     */
-    public function queryTranslator($type)
-    {
-        return function ($query) use ($type) {
-            if ($this->isAutoQuotingEnabled()) {
-                $query = (new IdentifierQuoter($this))->quote($query);
-            }
-
-            $query = $this->{'_' . $type . 'QueryTranslator'}($query);
-            $translators = $this->_expressionTranslators();
-            if (!$translators) {
-                return $query;
-            }
-
-            $query->traverseExpressions(function ($expression) use ($translators, $query) {
-                foreach ($translators as $class => $method) {
-                    if ($expression instanceof $class) {
-                        $this->{$method}($expression, $query);
-                    }
-                }
-            });
-
-            return $query;
-        };
-    }
-
-    /**
-     * Returns an associative array of methods that will transform Expression
-     * objects to conform with the specific SQL dialect. Keys are class names
-     * and values a method in this class.
-     *
-     * @return array
-     */
-    protected function _expressionTranslators()
-    {
-        return [];
-    }
-
-    /**
-     * Apply translation steps to select queries.
-     *
-     * @param \Cake\Database\Query $query The query to translate
-     * @return \Cake\Database\Query The modified query
-     */
-    protected function _selectQueryTranslator($query)
-    {
-        return $this->_transformDistinct($query);
-    }
-
-    /**
-     * Returns the passed query after rewriting the DISTINCT clause, so that drivers
-     * that do not support the "ON" part can provide the actual way it should be done
-     *
-     * @param \Cake\Database\Query $query The query to be transformed
-     * @return \Cake\Database\Query
-     */
-    protected function _transformDistinct($query)
-    {
-        if (is_array($query->clause('distinct'))) {
-            $query->group($query->clause('distinct'), true);
-            $query->distinct(false);
-        }
-
-        return $query;
-    }
-
-    /**
-     * Apply translation steps to delete queries.
-     *
-     * Chops out aliases on delete query conditions as most database dialects do not
-     * support aliases in delete queries. This also removes aliases
-     * in table names as they frequently don't work either.
-     *
-     * We are intentionally not supporting deletes with joins as they have even poorer support.
-     *
-     * @param \Cake\Database\Query $query The query to translate
-     * @return \Cake\Database\Query The modified query
-     */
-    protected function _deleteQueryTranslator($query)
-    {
-        $hadAlias = false;
-        $tables = [];
-        foreach ($query->clause('from') as $alias => $table) {
-            if (is_string($alias)) {
-                $hadAlias = true;
-            }
-            $tables[] = $table;
-        }
-        if ($hadAlias) {
-            $query->from($tables, true);
-        }
-
-        if (!$hadAlias) {
-            return $query;
-        }
-
-        return $this->_removeAliasesFromConditions($query);
-    }
-
-    /**
-     * Apply translation steps to update queries.
-     *
-     * Chops out aliases on update query conditions as not all database dialects do support
-     * aliases in update queries.
-     *
-     * Just like for delete queries, joins are currently not supported for update queries.
-     *
-     * @param \Cake\Database\Query $query The query to translate
-     * @return \Cake\Database\Query The modified query
-     */
-    protected function _updateQueryTranslator($query)
-    {
-        return $this->_removeAliasesFromConditions($query);
-    }
-
-    /**
-     * Removes aliases from the `WHERE` clause of a query.
-     *
-     * @param \Cake\Database\Query $query The query to process.
-     * @return \Cake\Database\Query The modified query.
-     * @throws \RuntimeException In case the processed query contains any joins, as removing
-     *  aliases from the conditions can break references to the joined tables.
-     */
-    protected function _removeAliasesFromConditions($query)
-    {
-        if ($query->clause('join')) {
-            throw new \RuntimeException(
-                'Aliases are being removed from conditions for UPDATE/DELETE queries, ' .
-                'this can break references to joined tables.'
-            );
-        }
-
-        $conditions = $query->clause('where');
-        if ($conditions) {
-            $conditions->traverse(function ($condition) {
-                if (!($condition instanceof Comparison)) {
-                    return $condition;
-                }
-
-                $field = $condition->getField();
-                if ($field instanceof ExpressionInterface || strpos($field, '.') === false) {
-                    return $condition;
-                }
-
-                list(, $field) = explode('.', $field);
-                $condition->setField($field);
-
-                return $condition;
-            });
-        }
-
-        return $query;
-    }
-
-    /**
-     * Apply translation steps to insert queries.
-     *
-     * @param \Cake\Database\Query $query The query to translate
-     * @return \Cake\Database\Query The modified query
-     */
-    protected function _insertQueryTranslator($query)
-    {
-        return $query;
-    }
-
-    /**
-     * Returns a SQL snippet for creating a new transaction savepoint
-     *
-     * @param string $name save point name
-     * @return string
-     */
-    public function savePointSQL($name)
-    {
-        return 'SAVEPOINT LEVEL' . $name;
-    }
-
-    /**
-     * Returns a SQL snippet for releasing a previously created save point
-     *
-     * @param string $name save point name
-     * @return string
-     */
-    public function releaseSavePointSQL($name)
-    {
-        return 'RELEASE SAVEPOINT LEVEL' . $name;
-    }
-
-    /**
-     * Returns a SQL snippet for rollbacking a previously created save point
-     *
-     * @param string $name save point name
-     * @return string
-     */
-    public function rollbackSavePointSQL($name)
-    {
-        return 'ROLLBACK TO SAVEPOINT LEVEL' . $name;
-    }
-}
diff --git a/src/Database/SqliteCompiler.php b/src/Database/SqliteCompiler.php
deleted file mode 100644
index 5a9e5476795..00000000000
--- a/src/Database/SqliteCompiler.php
+++ /dev/null
@@ -1,32 +0,0 @@
-
      */
-    protected $_templates = [
+    protected array $_templates = [
         'delete' => 'DELETE',
         'where' => ' WHERE %s',
-        'group' => ' GROUP BY %s ',
-        'having' => ' HAVING %s ',
+        'group' => ' GROUP BY %s',
         'order' => ' %s',
         'offset' => ' OFFSET %s ROWS',
-        'epilog' => ' %s'
+        'epilog' => ' %s',
+        'comment' => '/* %s */ ',
     ];
 
     /**
      * {@inheritDoc}
+     *
+     * @var array
      */
-    protected $_selectParts = [
-        'select', 'from', 'join', 'where', 'group', 'having', 'order', 'offset',
-        'limit', 'union', 'epilog'
+    protected array $_selectParts = [
+        'comment', 'with', 'select', 'from', 'join', 'where', 'group', 'having', 'window', 'order',
+        'offset', 'limit', 'union', 'except', 'epilog', 'intersect',
     ];
 
+    /**
+     * Helper function used to build the string representation of a `WITH` clause,
+     * it constructs the CTE definitions list without generating the `RECURSIVE`
+     * keyword that is neither required nor valid.
+     *
+     * @param array<\Cake\Database\Expression\CommonTableExpression> $parts List of CTEs to be transformed to string
+     * @param \Cake\Database\Query $query The query that is being compiled
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
+     * @return string
+     */
+    protected function _buildWithPart(array $parts, Query $query, ValueBinder $binder): string
+    {
+        $expressions = [];
+        foreach ($parts as $cte) {
+            $expressions[] = $cte->sql($binder);
+        }
+
+        return sprintf('WITH %s ', implode(', ', $expressions));
+    }
+
     /**
      * Generates the INSERT part of a SQL query
      *
@@ -60,20 +81,26 @@ class SqlserverCompiler extends QueryCompiler
      *
      * @param array $parts The parts to build
      * @param \Cake\Database\Query $query The query that is being compiled
-     * @param \Cake\Database\ValueBinder $generator the placeholder generator to be used in expressions
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
      * @return string
      */
-    protected function _buildInsertPart($parts, $query, $generator)
+    protected function _buildInsertPart(array $parts, Query $query, ValueBinder $binder): string
     {
+        if (!isset($parts[0])) {
+            throw new DatabaseException(
+                'Could not compile insert query. No table was specified. ' .
+                'Use `into()` to define a table.',
+            );
+        }
         $table = $parts[0];
-        $columns = $this->_stringifyExpressions($parts[1], $generator);
-        $modifiers = $this->_buildModifierPart($query->clause('modifier'), $query, $generator);
+        $columns = $this->_stringifyExpressions($parts[1], $binder);
+        $modifiers = $this->_buildModifierPart($query->clause('modifier'), $query, $binder);
 
         return sprintf(
             'INSERT%s INTO %s (%s) OUTPUT INSERTED.*',
             $modifiers,
             $table,
-            implode(', ', $columns)
+            implode(', ', $columns),
         );
     }
 
@@ -84,12 +111,55 @@ protected function _buildInsertPart($parts, $query, $generator)
      * @param \Cake\Database\Query $query The query that is being compiled
      * @return string
      */
-    protected function _buildLimitPart($limit, $query)
+    protected function _buildLimitPart(int $limit, Query $query): string
     {
-        if ($limit === null || $query->clause('offset') === null) {
+        if ($query->clause('offset') === null) {
             return '';
         }
 
         return sprintf(' FETCH FIRST %d ROWS ONLY', $limit);
     }
+
+    /**
+     * Helper function used to build the string representation of a HAVING clause,
+     * it constructs the field list taking care of aliasing and
+     * converting expression objects to string.
+     *
+     * @param array $parts list of fields to be transformed to string
+     * @param \Cake\Database\Query $query The query that is being compiled
+     * @param \Cake\Database\ValueBinder $binder Value binder used to generate parameter placeholder
+     * @return string
+     */
+    protected function _buildHavingPart(array $parts, Query $query, ValueBinder $binder): string
+    {
+        $selectParts = $query->clause('select');
+
+        foreach ($selectParts as $selectKey => $selectPart) {
+            if (!$selectPart instanceof FunctionExpression) {
+                continue;
+            }
+            foreach ($parts as $k => $p) {
+                if (!is_string($p)) {
+                    continue;
+                }
+                preg_match_all(
+                    '/\b' . trim($selectKey, '[]') . '\b/i',
+                    $p,
+                    $matches,
+                );
+
+                if (empty($matches[0])) {
+                    continue;
+                }
+
+                $parts[$k] = preg_replace(
+                    ['/\[|\]/', '/\b' . trim($selectKey, '[]') . '\b/i'],
+                    ['', $selectPart->sql($binder)],
+                    $p,
+                );
+            }
+        }
+
+        return sprintf(' HAVING %s', implode(', ', $parts));
+    }
 }
diff --git a/src/Database/Statement/BufferResultsTrait.php b/src/Database/Statement/BufferResultsTrait.php
deleted file mode 100644
index 0ad4c795f5d..00000000000
--- a/src/Database/Statement/BufferResultsTrait.php
+++ /dev/null
@@ -1,44 +0,0 @@
-_bufferResults = (bool)$buffer;
-
-        return $this;
-    }
-}
diff --git a/src/Database/Statement/BufferedStatement.php b/src/Database/Statement/BufferedStatement.php
deleted file mode 100644
index 9d8452d3bb5..00000000000
--- a/src/Database/Statement/BufferedStatement.php
+++ /dev/null
@@ -1,165 +0,0 @@
-_reset();
-    }
-
-    /**
-     * Execute the statement and return the results.
-     *
-     * @param array|null $params list of values to be bound to query
-     * @return bool true on success, false otherwise
-     */
-    public function execute($params = null)
-    {
-        $this->_reset();
-
-        return parent::execute($params);
-    }
-
-    /**
-     * {@inheritDoc}
-     *
-     * @param string $type The type to fetch.
-     * @return array|false
-     */
-    public function fetch($type = 'num')
-    {
-        if ($this->_allFetched) {
-            $row = ($this->_counter < $this->_count) ? $this->_records[$this->_counter++] : false;
-            $row = ($row && $type === 'num') ? array_values($row) : $row;
-
-            return $row;
-        }
-
-        $record = parent::fetch($type);
-
-        if ($record === false) {
-            $this->_allFetched = true;
-            $this->_counter = $this->_count + 1;
-            $this->_statement->closeCursor();
-
-            return false;
-        }
-
-        $this->_count++;
-
-        return $this->_records[] = $record;
-    }
-
-    /**
-     * {@inheritDoc}
-     *
-     * @param string $type The type to fetch.
-     * @return array
-     */
-    public function fetchAll($type = 'num')
-    {
-        if ($this->_allFetched) {
-            return $this->_records;
-        }
-
-        $this->_records = parent::fetchAll($type);
-        $this->_count = count($this->_records);
-        $this->_allFetched = true;
-        $this->_statement->closeCursor();
-
-        return $this->_records;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function rowCount()
-    {
-        if (!$this->_allFetched) {
-            $counter = $this->_counter;
-            while ($this->fetch('assoc')) {
-            }
-            $this->_counter = $counter;
-        }
-
-        return $this->_count;
-    }
-
-    /**
-     * Rewind the _counter property
-     *
-     * @return void
-     */
-    public function rewind()
-    {
-        $this->_counter = 0;
-    }
-
-    /**
-     * Reset all properties
-     *
-     * @return void
-     */
-    protected function _reset()
-    {
-        $this->_count = $this->_counter = 0;
-        $this->_records = [];
-        $this->_allFetched = false;
-    }
-}
diff --git a/src/Database/Statement/CallbackStatement.php b/src/Database/Statement/CallbackStatement.php
deleted file mode 100644
index 6b9f697b558..00000000000
--- a/src/Database/Statement/CallbackStatement.php
+++ /dev/null
@@ -1,74 +0,0 @@
-_callback = $callback;
-    }
-
-    /**
-     * Fetch a row from the statement.
-     *
-     * The result will be processed by the callback when it is not `false`.
-     *
-     * @param string $type Either 'num' or 'assoc' to indicate the result format you would like.
-     * @return array|false
-     */
-    public function fetch($type = 'num')
-    {
-        $callback = $this->_callback;
-        $row = $this->_statement->fetch($type);
-
-        return $row === false ? $row : $callback($row);
-    }
-
-    /**
-     * Fetch all rows from the statement.
-     *
-     * Each row in the result will be processed by the callback when it is not `false.
-     *
-     * @param string $type Either 'num' or 'assoc' to indicate the result format you would like.
-     * @return array
-     */
-    public function fetchAll($type = 'num')
-    {
-        return array_map($this->_callback, $this->_statement->fetchAll($type));
-    }
-}
diff --git a/src/Database/Statement/MysqlStatement.php b/src/Database/Statement/MysqlStatement.php
deleted file mode 100644
index b2454de4542..00000000000
--- a/src/Database/Statement/MysqlStatement.php
+++ /dev/null
@@ -1,46 +0,0 @@
-_driver->connection();
-
-        try {
-            $connection->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, $this->_bufferResults);
-            $result = $this->_statement->execute($params);
-        } finally {
-            $connection->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, true);
-        }
-
-        return $result;
-    }
-}
diff --git a/src/Database/Statement/PDOStatement.php b/src/Database/Statement/PDOStatement.php
deleted file mode 100644
index 5a379b2f4b6..00000000000
--- a/src/Database/Statement/PDOStatement.php
+++ /dev/null
@@ -1,134 +0,0 @@
-bindValue(1, 'a title');
-     * $statement->bindValue(2, 5, PDO::INT);
-     * $statement->bindValue('active', true, 'boolean');
-     * $statement->bindValue(5, new \DateTime(), 'date');
-     * ```
-     *
-     * @param string|int $column name or param position to be bound
-     * @param mixed $value The value to bind to variable in query
-     * @param string|int $type PDO type or name of configured Type class
-     * @return void
-     */
-    public function bindValue($column, $value, $type = 'string')
-    {
-        if ($type === null) {
-            $type = 'string';
-        }
-        if (!ctype_digit($type)) {
-            list($value, $type) = $this->cast($value, $type);
-        }
-        $this->_statement->bindValue($column, $value, $type);
-    }
-
-    /**
-     * Returns the next row for the result set after executing this statement.
-     * Rows can be fetched to contain columns as names or positions. If no
-     * rows are left in result set, this method will return false
-     *
-     * ### Example:
-     *
-     * ```
-     *  $statement = $connection->prepare('SELECT id, title from articles');
-     *  $statement->execute();
-     *  print_r($statement->fetch('assoc')); // will show ['id' => 1, 'title' => 'a title']
-     * ```
-     *
-     * @param string $type 'num' for positional columns, assoc for named columns
-     * @return array|false Result array containing columns and values or false if no results
-     * are left
-     */
-    public function fetch($type = 'num')
-    {
-        if ($type === 'num') {
-            return $this->_statement->fetch(PDO::FETCH_NUM);
-        }
-        if ($type === 'assoc') {
-            return $this->_statement->fetch(PDO::FETCH_ASSOC);
-        }
-        if ($type === 'obj') {
-            return $this->_statement->fetch(PDO::FETCH_OBJ);
-        }
-
-        return $this->_statement->fetch($type);
-    }
-
-    /**
-     * Returns an array with all rows resulting from executing this statement
-     *
-     * ### Example:
-     *
-     * ```
-     *  $statement = $connection->prepare('SELECT id, title from articles');
-     *  $statement->execute();
-     *  print_r($statement->fetchAll('assoc')); // will show [0 => ['id' => 1, 'title' => 'a title']]
-     * ```
-     *
-     * @param string $type num for fetching columns as positional keys or assoc for column names as keys
-     * @return array list of all results from database for this statement
-     */
-    public function fetchAll($type = 'num')
-    {
-        if ($type === 'num') {
-            return $this->_statement->fetchAll(PDO::FETCH_NUM);
-        }
-        if ($type === 'assoc') {
-            return $this->_statement->fetchAll(PDO::FETCH_ASSOC);
-        }
-        if ($type === 'obj') {
-            return $this->_statement->fetchAll(PDO::FETCH_OBJ);
-        }
-
-        return $this->_statement->fetchAll($type);
-    }
-}
diff --git a/src/Database/Statement/SqliteStatement.php b/src/Database/Statement/SqliteStatement.php
index 08e0b558bff..7c2425c41b4 100644
--- a/src/Database/Statement/SqliteStatement.php
+++ b/src/Database/Statement/SqliteStatement.php
@@ -1,4 +1,6 @@
 _statement instanceof BufferedStatement) {
-            $this->_statement = $this->_statement->getInnerStatement();
-        }
+        $this->affectedRows = null;
 
-        if ($this->_bufferResults) {
-            $this->_statement = new BufferedStatement($this->_statement, $this->_driver);
-        }
-
-        return $this->_statement->execute($params);
+        return parent::execute($params);
     }
 
     /**
-     * Returns the number of rows returned of affected by last execution
-     *
-     * @return int
+     * @inheritDoc
      */
-    public function rowCount()
+    public function rowCount(): int
     {
-        if (preg_match('/^(?:DELETE|UPDATE|INSERT)/i', $this->_statement->queryString)) {
+        if ($this->affectedRows !== null) {
+            return $this->affectedRows;
+        }
+
+        if (
+            $this->statement->queryString &&
+            preg_match('/^(?:DELETE|UPDATE|INSERT)/i', $this->statement->queryString)
+        ) {
             $changes = $this->_driver->prepare('SELECT CHANGES()');
             $changes->execute();
-            $count = $changes->fetch()[0];
-            $changes->closeCursor();
+            $row = $changes->fetch();
 
-            return (int)$count;
+            $this->affectedRows = $row ? (int)$row[0] : 0;
+        } else {
+            $this->affectedRows = parent::rowCount();
         }
 
-        return parent::rowCount();
+        return $this->affectedRows;
     }
 }
diff --git a/src/Database/Statement/SqlserverStatement.php b/src/Database/Statement/SqlserverStatement.php
index 5b1eb539daa..49ee07a9fc1 100644
--- a/src/Database/Statement/SqlserverStatement.php
+++ b/src/Database/Statement/SqlserverStatement.php
@@ -1,4 +1,6 @@
 cast($value, $type);
-        }
-        if ($type == PDO::PARAM_LOB) {
-            $this->_statement->bindParam($column, $value, $type, 0, PDO::SQLSRV_ENCODING_BINARY);
+        if ($type === PDO::PARAM_LOB) {
+            $this->statement->bindParam($column, $value, $type, 0, PDO::SQLSRV_ENCODING_BINARY);
         } else {
-            $this->_statement->bindValue($column, $value, $type);
+            parent::performBind($column, $value, $type);
         }
     }
 }
diff --git a/src/Database/Statement/Statement.php b/src/Database/Statement/Statement.php
new file mode 100644
index 00000000000..73024e98464
--- /dev/null
+++ b/src/Database/Statement/Statement.php
@@ -0,0 +1,304 @@
+
+     */
+    protected const MODE_NAME_MAP = [
+        self::FETCH_TYPE_ASSOC => PDO::FETCH_ASSOC,
+        self::FETCH_TYPE_NUM => PDO::FETCH_NUM,
+        self::FETCH_TYPE_OBJ => PDO::FETCH_OBJ,
+    ];
+
+    /**
+     * @var \Cake\Database\Driver
+     */
+    protected Driver $_driver;
+
+    /**
+     * Cached bound parameters used for logging
+     *
+     * @var array
+     */
+    protected array $params = [];
+
+    /**
+     * @param \PDOStatement $statement PDO statement
+     * @param \Cake\Database\Driver $driver Database driver
+     * @param array<\Closure> $resultDecorators Results decorators
+     */
+    public function __construct(
+        protected PDOStatement $statement,
+        Driver $driver,
+        protected array $resultDecorators = [],
+    ) {
+        $this->_driver = $driver;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function bind(array $params, array $types): void
+    {
+        if (!$params) {
+            return;
+        }
+
+        $anonymousParams = is_int(key($params));
+        $offset = 1;
+        foreach ($params as $index => $value) {
+            $type = $types[$index] ?? null;
+            if ($anonymousParams) {
+                $index += $offset;
+            }
+            $this->bindValue($index, $value, $type);
+        }
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function bindValue(string|int $column, mixed $value, string|int|null $type = 'string'): void
+    {
+        $type ??= 'string';
+        if (!is_int($type)) {
+            [$value, $type] = $this->cast($value, $type);
+        }
+
+        $this->params[$column] = $value;
+        $this->performBind($column, $value, $type);
+    }
+
+    /**
+     * Converts a given value to a suitable database value based on type and
+     * return relevant internal statement type.
+     *
+     * @param mixed $value The value to cast.
+     * @param \Cake\Database\TypeInterface|string|int $type The type name or type instance to use.
+     * @return array{0:mixed, 1:int} List containing converted value and internal type.
+     */
+    protected function cast(mixed $value, TypeInterface|string|int $type = 'string'): array
+    {
+        if (is_string($type)) {
+            $type = TypeFactory::build($type);
+        }
+        if ($type instanceof TypeInterface) {
+            $value = $type->toDatabase($value, $this->_driver);
+            $type = $type->toStatement($value, $this->_driver);
+        }
+
+        return [$value, $type];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function getBoundParams(): array
+    {
+        return $this->params;
+    }
+
+    /**
+     * @param string|int $column
+     * @param mixed $value
+     * @param int $type
+     * @return void
+     */
+    protected function performBind(string|int $column, mixed $value, int $type): void
+    {
+        $this->statement->bindValue($column, $value, $type);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function execute(?array $params = null): bool
+    {
+        return $this->statement->execute($params);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function fetch(string|int $mode = PDO::FETCH_NUM): mixed
+    {
+        $mode = $this->convertMode($mode);
+        $row = $this->statement->fetch($mode);
+        if ($row === false) {
+            return false;
+        }
+
+        foreach ($this->resultDecorators as $decorator) {
+            $row = $decorator($row);
+        }
+
+        return $row;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function fetchAssoc(): array
+    {
+        return $this->fetch(PDO::FETCH_ASSOC) ?: [];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function fetchColumn(int $position): mixed
+    {
+        $row = $this->fetch(PDO::FETCH_NUM);
+        if ($row && isset($row[$position])) {
+            return $row[$position];
+        }
+
+        return false;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function fetchAll(string|int $mode = PDO::FETCH_NUM): array
+    {
+        $mode = $this->convertMode($mode);
+        $rows = $this->statement->fetchAll($mode);
+
+        foreach ($this->resultDecorators as $decorator) {
+            $rows = array_map($decorator, $rows);
+        }
+
+        return $rows;
+    }
+
+    /**
+     * Converts mode name to PDO constant.
+     *
+     * @param string|int $mode Mode name or PDO constant
+     * @return int
+     * @throws \InvalidArgumentException
+     */
+    protected function convertMode(string|int $mode): int
+    {
+        if (is_int($mode)) {
+            // We don't try to validate the PDO constants
+            return $mode;
+        }
+
+        return static::MODE_NAME_MAP[$mode]
+            ??
+            throw new InvalidArgumentException("Invalid fetch mode requested. Expected 'assoc', 'num' or 'obj'.");
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function closeCursor(): void
+    {
+        $this->statement->closeCursor();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function rowCount(): int
+    {
+        return $this->statement->rowCount();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function columnCount(): int
+    {
+        return $this->statement->columnCount();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function errorCode(): string
+    {
+        return $this->statement->errorCode() ?: '';
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function errorInfo(): array
+    {
+        return $this->statement->errorInfo();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function lastInsertId(?string $table = null, ?string $column = null): string|int
+    {
+        if ($column && $this->columnCount()) {
+            $row = $this->fetch(static::FETCH_TYPE_ASSOC);
+
+            if ($row && isset($row[$column])) {
+                return $row[$column];
+            }
+        }
+
+        return $this->_driver->lastInsertId($table);
+    }
+
+    /**
+     * Returns prepared query string stored in PDOStatement.
+     *
+     * @return string
+     */
+    public function queryString(): string
+    {
+        return $this->statement->queryString;
+    }
+
+    /**
+     * Get the inner iterator
+     *
+     * @return \Generator
+     */
+    public function getIterator(): Generator
+    {
+        $this->statement->setFetchMode(PDO::FETCH_ASSOC);
+
+        foreach ($this->statement as $row) {
+            foreach ($this->resultDecorators as $decorator) {
+                $row = $decorator($row);
+            }
+
+            yield $row;
+        }
+
+        $this->closeCursor();
+    }
+}
diff --git a/src/Database/Statement/StatementDecorator.php b/src/Database/Statement/StatementDecorator.php
deleted file mode 100644
index 2d9d6508ce9..00000000000
--- a/src/Database/Statement/StatementDecorator.php
+++ /dev/null
@@ -1,327 +0,0 @@
-_statement = $statement;
-        $this->_driver = $driver;
-    }
-
-    /**
-     * Magic getter to return $queryString as read-only.
-     *
-     * @param string $property internal property to get
-     * @return mixed
-     */
-    public function __get($property)
-    {
-        if ($property === 'queryString') {
-            return $this->_statement->queryString;
-        }
-    }
-
-    /**
-     * Assign a value to a positional or named variable in prepared query. If using
-     * positional variables you need to start with index one, if using named params then
-     * just use the name in any order.
-     *
-     * It is not allowed to combine positional and named variables in the same statement.
-     *
-     * ### Examples:
-     *
-     * ```
-     * $statement->bindValue(1, 'a title');
-     * $statement->bindValue('active', true, 'boolean');
-     * $statement->bindValue(5, new \DateTime(), 'date');
-     * ```
-     *
-     * @param string|int $column name or param position to be bound
-     * @param mixed $value The value to bind to variable in query
-     * @param string $type name of configured Type class
-     * @return void
-     */
-    public function bindValue($column, $value, $type = 'string')
-    {
-        $this->_statement->bindValue($column, $value, $type);
-    }
-
-    /**
-     * Closes a cursor in the database, freeing up any resources and memory
-     * allocated to it. In most cases you don't need to call this method, as it is
-     * automatically called after fetching all results from the result set.
-     *
-     * @return void
-     */
-    public function closeCursor()
-    {
-        $this->_statement->closeCursor();
-    }
-
-    /**
-     * Returns the number of columns this statement's results will contain.
-     *
-     * ### Example:
-     *
-     * ```
-     * $statement = $connection->prepare('SELECT id, title from articles');
-     * $statement->execute();
-     * echo $statement->columnCount(); // outputs 2
-     * ```
-     *
-     * @return int
-     */
-    public function columnCount()
-    {
-        return $this->_statement->columnCount();
-    }
-
-    /**
-     * Returns the error code for the last error that occurred when executing this statement.
-     *
-     * @return int|string
-     */
-    public function errorCode()
-    {
-        return $this->_statement->errorCode();
-    }
-
-    /**
-     * Returns the error information for the last error that occurred when executing
-     * this statement.
-     *
-     * @return array
-     */
-    public function errorInfo()
-    {
-        return $this->_statement->errorInfo();
-    }
-
-    /**
-     * Executes the statement by sending the SQL query to the database. It can optionally
-     * take an array or arguments to be bound to the query variables. Please note
-     * that binding parameters from this method will not perform any custom type conversion
-     * as it would normally happen when calling `bindValue`.
-     *
-     * @param array|null $params list of values to be bound to query
-     * @return bool true on success, false otherwise
-     */
-    public function execute($params = null)
-    {
-        $this->_hasExecuted = true;
-
-        return $this->_statement->execute($params);
-    }
-
-    /**
-     * Returns the next row for the result set after executing this statement.
-     * Rows can be fetched to contain columns as names or positions. If no
-     * rows are left in result set, this method will return false.
-     *
-     * ### Example:
-     *
-     * ```
-     * $statement = $connection->prepare('SELECT id, title from articles');
-     * $statement->execute();
-     * print_r($statement->fetch('assoc')); // will show ['id' => 1, 'title' => 'a title']
-     * ```
-     *
-     * @param string $type 'num' for positional columns, assoc for named columns
-     * @return array|false Result array containing columns and values or false if no results
-     * are left
-     */
-    public function fetch($type = 'num')
-    {
-        return $this->_statement->fetch($type);
-    }
-
-    /**
-     * Returns an array with all rows resulting from executing this statement.
-     *
-     * ### Example:
-     *
-     * ```
-     * $statement = $connection->prepare('SELECT id, title from articles');
-     * $statement->execute();
-     * print_r($statement->fetchAll('assoc')); // will show [0 => ['id' => 1, 'title' => 'a title']]
-     * ```
-     *
-     * @param string $type num for fetching columns as positional keys or assoc for column names as keys
-     * @return array List of all results from database for this statement
-     */
-    public function fetchAll($type = 'num')
-    {
-        return $this->_statement->fetchAll($type);
-    }
-
-    /**
-     * Returns the number of rows affected by this SQL statement.
-     *
-     * ### Example:
-     *
-     * ```
-     * $statement = $connection->prepare('SELECT id, title from articles');
-     * $statement->execute();
-     * print_r($statement->rowCount()); // will show 1
-     * ```
-     *
-     * @return int
-     */
-    public function rowCount()
-    {
-        return $this->_statement->rowCount();
-    }
-
-    /**
-     * Statements are iterable as arrays, this method will return
-     * the iterator object for traversing all items in the result.
-     *
-     * ### Example:
-     *
-     * ```
-     * $statement = $connection->prepare('SELECT id, title from articles');
-     * foreach ($statement as $row) {
-     *   //do stuff
-     * }
-     * ```
-     *
-     * @return \Cake\Database\StatementInterface|\PDOStatement
-     */
-    public function getIterator()
-    {
-        if (!$this->_hasExecuted) {
-            $this->execute();
-        }
-
-        return $this->_statement;
-    }
-
-    /**
-     * Statements can be passed as argument for count() to return the number
-     * for affected rows from last execution.
-     *
-     * @return int
-     */
-    public function count()
-    {
-        return $this->rowCount();
-    }
-
-    /**
-     * Binds a set of values to statement object with corresponding type.
-     *
-     * @param array $params list of values to be bound
-     * @param array $types list of types to be used, keys should match those in $params
-     * @return void
-     */
-    public function bind($params, $types)
-    {
-        if (empty($params)) {
-            return;
-        }
-
-        $anonymousParams = is_int(key($params)) ? true : false;
-        $offset = 1;
-        foreach ($params as $index => $value) {
-            $type = null;
-            if (isset($types[$index])) {
-                $type = $types[$index];
-            }
-            if ($anonymousParams) {
-                $index += $offset;
-            }
-            $this->bindValue($index, $value, $type);
-        }
-    }
-
-    /**
-     * Returns the latest primary inserted using this statement.
-     *
-     * @param string|null $table table name or sequence to get last insert value from
-     * @param string|null $column the name of the column representing the primary key
-     * @return string
-     */
-    public function lastInsertId($table = null, $column = null)
-    {
-        $row = null;
-        if ($column && $this->columnCount()) {
-            $row = $this->fetch('assoc');
-        }
-        if (isset($row[$column])) {
-            return $row[$column];
-        }
-
-        return $this->_driver->lastInsertId($table, $column);
-    }
-
-    /**
-     * Returns the statement object that was decorated by this class.
-     *
-     * @return \Cake\Database\StatementInterface|\PDOStatement
-     */
-    public function getInnerStatement()
-    {
-        return $this->_statement;
-    }
-}
diff --git a/src/Database/StatementInterface.php b/src/Database/StatementInterface.php
index f88ef22de50..27d849c156e 100644
--- a/src/Database/StatementInterface.php
+++ b/src/Database/StatementInterface.php
@@ -1,4 +1,6 @@
 
  */
-interface StatementInterface
+interface StatementInterface extends IteratorAggregate
 {
+    /**
+     * Maps to PDO::FETCH_NUM.
+     *
+     * @var string
+     * @link https://www.php.net/manual/en/pdo.constants.php
+     */
+    public const FETCH_TYPE_NUM = 'num';
+
+    /**
+     * Maps to PDO::FETCH_ASSOC.
+     *
+     * @var string
+     * @link https://www.php.net/manual/en/pdo.constants.php
+     */
+    public const FETCH_TYPE_ASSOC = 'assoc';
+
+    /**
+     * Maps to PDO::FETCH_OBJ.
+     *
+     * @var string
+     * @link https://www.php.net/manual/en/pdo.constants.php
+     */
+    public const FETCH_TYPE_OBJ = 'obj';
 
     /**
      * Assign a value to a positional or named variable in prepared query. If using
      * positional variables you need to start with index one, if using named params then
      * just use the name in any order.
      *
-     * It is not allowed to combine positional and named variables in the same statement
+     * It is not allowed to combine positional and named variables in the same statement.
      *
      * ### Examples:
      *
@@ -38,134 +65,151 @@ interface StatementInterface
      *
      * @param string|int $column name or param position to be bound
      * @param mixed $value The value to bind to variable in query
-     * @param string $type name of configured Type class
+     * @param string|int|null $type name of configured Type class
      * @return void
      */
-    public function bindValue($column, $value, $type = 'string');
+    public function bindValue(string|int $column, mixed $value, string|int|null $type = 'string'): void;
 
     /**
-     * Closes a cursor in the database, freeing up any resources and memory
-     * allocated to it. In most cases you don't need to call this method, as it is
-     * automatically called after fetching all results from the result set.
+     * Closes the cursor, enabling the statement to be executed again.
+     *
+     * This behaves the same as `PDOStatement::closeCursor()`.
      *
      * @return void
      */
-    public function closeCursor();
+    public function closeCursor(): void;
 
     /**
-     * Returns the number of columns this statement's results will contain
-     *
-     * ### Example:
+     * Returns the number of columns in the result set.
      *
-     * ```
-     *  $statement = $connection->prepare('SELECT id, title from articles');
-     *  $statement->execute();
-     *  echo $statement->columnCount(); // outputs 2
-     * ```
+     * This behaves the same as `PDOStatement::columnCount()`.
      *
      * @return int
+     * @link https://php.net/manual/en/pdostatement.columncount.php
      */
-    public function columnCount();
+    public function columnCount(): int;
 
     /**
-     * Returns the error code for the last error that occurred when executing this statement
+     * Fetch the SQLSTATE associated with the last operation on the statement handle.
+     *
+     * This behaves the same as `PDOStatement::errorCode()`.
      *
-     * @return int|string
+     * @return string
+     * @link https://www.php.net/manual/en/pdostatement.errorcode.php
      */
-    public function errorCode();
+    public function errorCode(): string;
 
     /**
-     * Returns the error information for the last error that occurred when executing
-     * this statement
+     * Fetch extended error information associated with the last operation on the statement handle.
+     *
+     * This behaves the same as `PDOStatement::errorInfo()`.
      *
      * @return array
+     * @link https://www.php.net/manual/en/pdostatement.errorinfo.php
      */
-    public function errorInfo();
+    public function errorInfo(): array;
 
     /**
      * Executes the statement by sending the SQL query to the database. It can optionally
      * take an array or arguments to be bound to the query variables. Please note
      * that binding parameters from this method will not perform any custom type conversion
-     * as it would normally happen when calling `bindValue`
+     * as it would normally happen when calling `bindValue`.
      *
      * @param array|null $params list of values to be bound to query
      * @return bool true on success, false otherwise
      */
-    public function execute($params = null);
+    public function execute(?array $params = null): bool;
 
     /**
-     * Returns the next row for the result set after executing this statement.
-     * Rows can be fetched to contain columns as names or positions. If no
-     * rows are left in result set, this method will return false
-     *
-     * ### Example:
+     * Fetches the next row from a result set
+     * and converts fields to types based on TypeMap.
      *
-     * ```
-     *  $statement = $connection->prepare('SELECT id, title from articles');
-     *  $statement->execute();
-     *  print_r($statement->fetch('assoc')); // will show ['id' => 1, 'title' => 'a title']
-     * ```
+     * This behaves the same as `PDOStatement::fetch()`.
      *
-     * @param string $type 'num' for positional columns, assoc for named columns
-     * @return array|false Result array containing columns and values or false if no results
-     * are left
+     * @param string|int $mode PDO::FETCH_* constant or fetch mode name.
+     *   Valid names are 'assoc', 'num' or 'obj'.
+     * @return mixed
+     * @throws \InvalidArgumentException
+     * @link https://www.php.net/manual/en/pdo.constants.php
      */
-    public function fetch($type = 'num');
+    public function fetch(string|int $mode = PDO::FETCH_NUM): mixed;
 
     /**
-     * Returns an array with all rows resulting from executing this statement
+     * Fetches the remaining rows from a result set
+     * and converts fields to types based on TypeMap.
      *
-     * ### Example:
+     * This behaves the same as `PDOStatement::fetchAll()`.
      *
-     * ```
-     *  $statement = $connection->prepare('SELECT id, title from articles');
-     *  $statement->execute();
-     *  print_r($statement->fetchAll('assoc')); // will show [0 => ['id' => 1, 'title' => 'a title']]
-     * ```
-     *
-     * @param string $type num for fetching columns as positional keys or assoc for column names as keys
-     * @return array list of all results from database for this statement
+     * @param string|int $mode PDO::FETCH_* constant or fetch mode name.
+     *   Valid names are 'assoc', 'num' or 'obj'.
+     * @return array
+     * @throws \InvalidArgumentException
+     * @link https://www.php.net/manual/en/pdo.constants.php
      */
-    public function fetchAll($type = 'num');
+    public function fetchAll(string|int $mode = PDO::FETCH_NUM): array;
 
     /**
-     * Returns the number of rows affected by this SQL statement
+     * Fetches the next row from a result set using PDO::FETCH_NUM
+     * and converts fields to types based on TypeMap.
      *
-     * ### Example:
+     * This behaves the same as `PDOStatement::fetch()` except only
+     * a specific column from the row is returned.
      *
-     * ```
-     *  $statement = $connection->prepare('SELECT id, title from articles');
-     *  $statement->execute();
-     *  print_r($statement->rowCount()); // will show 1
-     * ```
+     * @param int $position Column index in result row.
+     * @return mixed
+     */
+    public function fetchColumn(int $position): mixed;
+
+    /**
+     * Fetches the next row from a result set using PDO::FETCH_ASSOC
+     * and converts fields to types based on TypeMap.
      *
-     * @return int
+     * This behaves the same as `PDOStatement::fetch()` except an
+     * empty array is returned instead of false.
+     *
+     * @return array
      */
-    public function rowCount();
+    public function fetchAssoc(): array;
 
     /**
-     * Statements can be passed as argument for count()
-     * to return the number for affected rows from last execution
+     * Returns the number of rows affected by the last SQL statement.
+     *
+     * This behaves the same as `PDOStatement::rowCount()`.
      *
      * @return int
+     * @link https://www.php.net/manual/en/pdostatement.rowcount.php
      */
-    public function count();
+    public function rowCount(): int;
 
     /**
-     * Binds a set of values to statement object with corresponding type
+     * Binds a set of values to statement object with corresponding type.
      *
      * @param array $params list of values to be bound
      * @param array $types list of types to be used, keys should match those in $params
      * @return void
      */
-    public function bind($params, $types);
+    public function bind(array $params, array $types): void;
 
     /**
-     * Returns the latest primary inserted using this statement
+     * Returns the latest primary inserted using this statement.
      *
      * @param string|null $table table name or sequence to get last insert value from
      * @param string|null $column the name of the column representing the primary key
+     * @return string|int
+     */
+    public function lastInsertId(?string $table = null, ?string $column = null): string|int;
+
+    /**
+     * Returns prepared query string.
+     *
      * @return string
      */
-    public function lastInsertId($table = null, $column = null);
+    public function queryString(): string;
+
+    /**
+     * Get the bound params.
+     *
+     * @return array
+     */
+    public function getBoundParams(): array;
 }
diff --git a/src/Database/Type.php b/src/Database/Type.php
deleted file mode 100644
index 45ea5454ac8..00000000000
--- a/src/Database/Type.php
+++ /dev/null
@@ -1,319 +0,0 @@
- 'Cake\Database\Type\IntegerType',
-        'smallinteger' => 'Cake\Database\Type\IntegerType',
-        'integer' => 'Cake\Database\Type\IntegerType',
-        'biginteger' => 'Cake\Database\Type\IntegerType',
-        'binary' => 'Cake\Database\Type\BinaryType',
-        'boolean' => 'Cake\Database\Type\BoolType',
-        'date' => 'Cake\Database\Type\DateType',
-        'datetime' => 'Cake\Database\Type\DateTimeType',
-        'decimal' => 'Cake\Database\Type\DecimalType',
-        'float' => 'Cake\Database\Type\FloatType',
-        'json' => 'Cake\Database\Type\JsonType',
-        'string' => 'Cake\Database\Type\StringType',
-        'text' => 'Cake\Database\Type\StringType',
-        'time' => 'Cake\Database\Type\TimeType',
-        'timestamp' => 'Cake\Database\Type\DateTimeType',
-        'uuid' => 'Cake\Database\Type\UuidType',
-    ];
-
-    /**
-     * List of basic type mappings, used to avoid having to instantiate a class
-     * for doing conversion on these.
-     *
-     * @var array
-     * @deprecated 3.1 All types will now use a specific class
-     */
-    protected static $_basicTypes = [
-        'string' => ['callback' => ['\Cake\Database\Type', 'strval']],
-        'text' => ['callback' => ['\Cake\Database\Type', 'strval']],
-        'boolean' => [
-            'callback' => ['\Cake\Database\Type', 'boolval'],
-            'pdo' => PDO::PARAM_BOOL
-        ],
-    ];
-
-    /**
-     * Contains a map of type object instances to be reused if needed.
-     *
-     * @var \Cake\Database\Type[]
-     */
-    protected static $_builtTypes = [];
-
-    /**
-     * Identifier name for this type
-     *
-     * @var string|null
-     */
-    protected $_name;
-
-    /**
-     * Constructor
-     *
-     * @param string|null $name The name identifying this type
-     */
-    public function __construct($name = null)
-    {
-        $this->_name = $name;
-    }
-
-    /**
-     * Returns a Type object capable of converting a type identified by name.
-     *
-     * @param string $name type identifier
-     * @throws \InvalidArgumentException If type identifier is unknown
-     * @return \Cake\Database\Type
-     */
-    public static function build($name)
-    {
-        if (isset(static::$_builtTypes[$name])) {
-            return static::$_builtTypes[$name];
-        }
-        if (!isset(static::$_types[$name])) {
-            throw new InvalidArgumentException(sprintf('Unknown type "%s"', $name));
-        }
-        if (is_string(static::$_types[$name])) {
-            return static::$_builtTypes[$name] = new static::$_types[$name]($name);
-        }
-
-        return static::$_builtTypes[$name] = static::$_types[$name];
-    }
-
-    /**
-     * Returns an arrays with all the mapped type objects, indexed by name.
-     *
-     * @return array
-     */
-    public static function buildAll()
-    {
-        $result = [];
-        foreach (static::$_types as $name => $type) {
-            $result[$name] = isset(static::$_builtTypes[$name]) ? static::$_builtTypes[$name] : static::build($name);
-        }
-
-        return $result;
-    }
-
-    /**
-     * Returns a Type object capable of converting a type identified by $name
-     *
-     * @param string $name The type identifier you want to set.
-     * @param \Cake\Database\Type $instance The type instance you want to set.
-     * @return void
-     */
-    public static function set($name, Type $instance)
-    {
-        static::$_builtTypes[$name] = $instance;
-    }
-
-    /**
-     * Registers a new type identifier and maps it to a fully namespaced classname,
-     * If called with no arguments it will return current types map array
-     * If $className is omitted it will return mapped class for $type
-     *
-     * Deprecated: The usage of $type as \Cake\Database\Type[] is deprecated. Please always use string[] if you pass an array
-     * as first argument.
-     *
-     * @param string|string[]|\Cake\Database\Type[]|null $type If string name of type to map, if array list of arrays to be mapped
-     * @param string|\Cake\Database\Type|null $className The classname or object instance of it to register.
-     * @return array|string|null If $type is null then array with current map, if $className is null string
-     * configured class name for give $type, null otherwise
-     */
-    public static function map($type = null, $className = null)
-    {
-        if ($type === null) {
-            return static::$_types;
-        }
-        if (is_array($type)) {
-            static::$_types = $type;
-
-            return null;
-        }
-        if ($className === null) {
-            return isset(static::$_types[$type]) ? static::$_types[$type] : null;
-        }
-
-        static::$_types[$type] = $className;
-        unset(static::$_builtTypes[$type]);
-    }
-
-    /**
-     * Clears out all created instances and mapped types classes, useful for testing
-     *
-     * @return void
-     */
-    public static function clear()
-    {
-        static::$_types = [];
-        static::$_builtTypes = [];
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function getName()
-    {
-        return $this->_name;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function getBaseType()
-    {
-        return $this->_name;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function toDatabase($value, Driver $driver)
-    {
-        return $this->_basicTypeCast($value);
-    }
-
-    /**
-     * Casts given value from a database type to PHP equivalent
-     *
-     * @param mixed $value Value to be converted to PHP equivalent
-     * @param \Cake\Database\Driver $driver Object from which database preferences and configuration will be extracted
-     * @return mixed
-     */
-    public function toPHP($value, Driver $driver)
-    {
-        return $this->_basicTypeCast($value);
-    }
-
-    /**
-     * Checks whether this type is a basic one and can be converted using a callback
-     * If it is, returns converted value
-     *
-     * @param mixed $value Value to be converted to PHP equivalent
-     * @return mixed
-     * @deprecated 3.1 All types should now be a specific class
-     */
-    protected function _basicTypeCast($value)
-    {
-        if ($value === null) {
-            return null;
-        }
-        if (!empty(static::$_basicTypes[$this->_name])) {
-            $typeInfo = static::$_basicTypes[$this->_name];
-            if (isset($typeInfo['callback'])) {
-                return $typeInfo['callback']($value);
-            }
-        }
-
-        return $value;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function toStatement($value, Driver $driver)
-    {
-        if ($value === null) {
-            return PDO::PARAM_NULL;
-        }
-
-        return PDO::PARAM_STR;
-    }
-
-    /**
-     * Type converter for boolean values.
-     *
-     * Will convert string true/false into booleans.
-     *
-     * @param mixed $value The value to convert to a boolean.
-     * @return bool
-     * @deprecated 3.1.8 This method is now unused.
-     */
-    public static function boolval($value)
-    {
-        if (is_string($value) && !is_numeric($value)) {
-            return strtolower($value) === 'true';
-        }
-
-        return !empty($value);
-    }
-
-    /**
-     * Type converter for string values.
-     *
-     * Will convert values into strings
-     *
-     * @param mixed $value The value to convert to a string.
-     * @return string
-     * @deprecated 3.1.8 This method is now unused.
-     */
-    public static function strval($value)
-    {
-        if (is_array($value)) {
-            $value = '';
-        }
-
-        return (string)$value;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function newId()
-    {
-        return null;
-    }
-
-    /**
-     * {@inheritDoc}
-     */
-    public function marshal($value)
-    {
-        return $this->_basicTypeCast($value);
-    }
-
-    /**
-     * Returns an array that can be used to describe the internal state of this
-     * object.
-     *
-     * @return array
-     */
-    public function __debugInfo()
-    {
-        return [
-            'name' => $this->_name,
-        ];
-    }
-}
diff --git a/src/Database/Type/Attribute/Label.php b/src/Database/Type/Attribute/Label.php
new file mode 100644
index 00000000000..a7caddf5b25
--- /dev/null
+++ b/src/Database/Type/Attribute/Label.php
@@ -0,0 +1,44 @@
+_name = $name;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function getName(): ?string
+    {
+        return $this->_name;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function getBaseType(): ?string
+    {
+        return $this->_name;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function toStatement(mixed $value, Driver $driver): int
+    {
+        if ($value === null) {
+            return PDO::PARAM_NULL;
+        }
+
+        return PDO::PARAM_STR;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function newId(): mixed
+    {
+        return null;
+    }
+}
diff --git a/src/Database/Type/BatchCastingInterface.php b/src/Database/Type/BatchCastingInterface.php
new file mode 100644
index 00000000000..bf0cf024220
--- /dev/null
+++ b/src/Database/Type/BatchCastingInterface.php
@@ -0,0 +1,37 @@
+ $fields The field keys to cast
+     * @param \Cake\Database\Driver $driver Object from which database preferences and configuration will be extracted.
+     * @return array
+     */
+    public function manyToPHP(array $values, array $fields, Driver $driver): array;
+}
diff --git a/src/Database/Type/BinaryType.php b/src/Database/Type/BinaryType.php
index dc75d306376..a2da78b5093 100644
--- a/src/Database/Type/BinaryType.php
+++ b/src/Database/Type/BinaryType.php
@@ -1,4 +1,6 @@
 _name = $name;
-    }
-
     /**
      * Convert binary data into the database format.
      *
      * Binary data is not altered before being inserted into the database.
      * As PDO will handle reading file handles.
      *
-     * @param string|resource $value The value to convert.
+     * @param mixed $value The value to convert.
      * @param \Cake\Database\Driver $driver The driver instance to convert with.
-     * @return string|resource
+     * @return resource|string
      */
-    public function toDatabase($value, Driver $driver)
+    public function toDatabase(mixed $value, Driver $driver): mixed
     {
         return $value;
     }
@@ -69,51 +45,43 @@ public function toDatabase($value, Driver $driver)
     /**
      * Convert binary into resource handles
      *
-     * @param null|string|resource $value The value to convert.
+     * @param mixed $value The value to convert.
      * @param \Cake\Database\Driver $driver The driver instance to convert with.
      * @return resource|null
-     * @throws \Cake\Core\Exception\Exception
+     * @throws \Cake\Core\Exception\CakeException
      */
-    public function toPHP($value, Driver $driver)
+    public function toPHP(mixed $value, Driver $driver): mixed
     {
         if ($value === null) {
             return null;
         }
-        if (is_string($value) && $driver instanceof Sqlserver) {
-            $value = pack('H*', $value);
-        }
         if (is_string($value)) {
-            return fopen('data:text/plain;base64,' . base64_encode($value), 'rb');
+            return fopen('data:text/plain;base64,' . base64_encode($value), 'rb') ?: null;
         }
         if (is_resource($value)) {
             return $value;
         }
-        throw new Exception(sprintf('Unable to convert %s into binary.', gettype($value)));
+        throw new CakeException(sprintf('Unable to convert `%s` into binary.', gettype($value)));
     }
 
     /**
-     * Get the correct PDO binding type for Binary data.
-     *
-     * @param mixed $value The value being bound.
-     * @param \Cake\Database\Driver $driver The driver.
-     * @return int
+     * @inheritDoc
      */
-    public function toStatement($value, Driver $driver)
+    public function toStatement(mixed $value, Driver $driver): int
     {
         return PDO::PARAM_LOB;
     }
 
     /**
-     * Marshalls flat data into PHP objects.
+     * Marshals flat data into PHP objects.
      *
      * Most useful for converting request data into PHP objects
      * that make sense for the rest of the ORM/Database layers.
      *
      * @param mixed $value The value to convert.
-     *
      * @return mixed Converted value.
      */
-    public function marshal($value)
+    public function marshal(mixed $value): mixed
     {
         return $value;
     }
diff --git a/src/Database/Type/BinaryUuidType.php b/src/Database/Type/BinaryUuidType.php
new file mode 100644
index 00000000000..b6e942f6188
--- /dev/null
+++ b/src/Database/Type/BinaryUuidType.php
@@ -0,0 +1,143 @@
+convertStringToBinaryUuid($value);
+    }
+
+    /**
+     * Generate a new binary UUID
+     *
+     * @return string A new primary key value.
+     */
+    public function newId(): string
+    {
+        return Text::uuid();
+    }
+
+    /**
+     * Convert binary uuid into resource handles
+     *
+     * @param mixed $value The value to convert.
+     * @param \Cake\Database\Driver $driver The driver instance to convert with.
+     * @return resource|string|null
+     * @throws \Cake\Core\Exception\CakeException
+     */
+    public function toPHP(mixed $value, Driver $driver): mixed
+    {
+        if ($value === null) {
+            return null;
+        }
+        if (is_string($value)) {
+            return $this->convertBinaryUuidToString($value);
+        }
+        if (is_resource($value)) {
+            return $value;
+        }
+
+        throw new CakeException(sprintf('Unable to convert %s into binary uuid.', gettype($value)));
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function toStatement(mixed $value, Driver $driver): int
+    {
+        return PDO::PARAM_LOB;
+    }
+
+    /**
+     * Marshals flat data into PHP objects.
+     *
+     * Most useful for converting request data into PHP objects
+     * that make sense for the rest of the ORM/Database layers.
+     *
+     * @param mixed $value The value to convert.
+     * @return mixed Converted value.
+     */
+    public function marshal(mixed $value): mixed
+    {
+        return $value;
+    }
+
+    /**
+     * Converts a binary uuid to a string representation
+     *
+     * @param mixed $binary The value to convert.
+     * @return string Converted value.
+     */
+    protected function convertBinaryUuidToString(mixed $binary): string
+    {
+        $string = unpack('H*', $binary);
+        assert($string !== false, 'Could not unpack uuid');
+
+        /** @var array $string */
+        $string = preg_replace(
+            '/([0-9a-f]{8})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{12})/',
+            '$1-$2-$3-$4-$5',
+            $string,
+        );
+
+        return $string[1];
+    }
+
+    /**
+     * Converts a string UUID (36 or 32 char) to a binary representation.
+     *
+     * @param string $string The value to convert.
+     * @return string Converted value.
+     */
+    protected function convertStringToBinaryUuid(string $string): string
+    {
+        $string = str_replace('-', '', $string);
+
+        return pack('H*', $string);
+    }
+}
diff --git a/src/Database/Type/BoolType.php b/src/Database/Type/BoolType.php
index 7037d25966f..7287237f466 100644
--- a/src/Database/Type/BoolType.php
+++ b/src/Database/Type/BoolType.php
@@ -1,4 +1,6 @@
 _name = $name;
-    }
-
     /**
      * Convert bool data into the database format.
      *
@@ -57,9 +34,9 @@ public function __construct($name = null)
      * @param \Cake\Database\Driver $driver The driver instance to convert with.
      * @return bool|null
      */
-    public function toDatabase($value, Driver $driver)
+    public function toDatabase(mixed $value, Driver $driver): ?bool
     {
-        if ($value === true || $value === false || $value === null) {
+        if (in_array($value, [true, false, null], true)) {
             return $value;
         }
 
@@ -67,7 +44,11 @@ public function toDatabase($value, Driver $driver)
             return (bool)$value;
         }
 
-        throw new InvalidArgumentException('Cannot convert value to bool');
+        throw new InvalidArgumentException(sprintf(
+            'Cannot convert value `%s` of type `%s` to bool',
+            print_r($value, true),
+            get_debug_type($value),
+        ));
     }
 
     /**
@@ -77,12 +58,13 @@ public function toDatabase($value, Driver $driver)
      * @param \Cake\Database\Driver $driver The driver instance to convert with.
      * @return bool|null
      */
-    public function toPHP($value, Driver $driver)
+    public function toPHP(mixed $value, Driver $driver): ?bool
     {
-        if ($value === null) {
-            return null;
+        if ($value === null || is_bool($value)) {
+            return $value;
         }
-        if (is_string($value) && !is_numeric($value)) {
+
+        if (!is_numeric($value)) {
             return strtolower($value) === 'true';
         }
 
@@ -90,13 +72,31 @@ public function toPHP($value, Driver $driver)
     }
 
     /**
-     * Get the correct PDO binding type for bool data.
-     *
-     * @param mixed $value The value being bound.
-     * @param \Cake\Database\Driver $driver The driver.
-     * @return int
+     * @inheritDoc
      */
-    public function toStatement($value, Driver $driver)
+    public function manyToPHP(array $values, array $fields, Driver $driver): array
+    {
+        foreach ($fields as $field) {
+            $value = $values[$field] ?? null;
+            if ($value === null || is_bool($value)) {
+                continue;
+            }
+
+            if (!is_numeric($value)) {
+                $values[$field] = strtolower($value) === 'true';
+                continue;
+            }
+
+            $values[$field] = !empty($value);
+        }
+
+        return $values;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function toStatement(mixed $value, Driver $driver): int
     {
         if ($value === null) {
             return PDO::PARAM_NULL;
@@ -106,23 +106,17 @@ public function toStatement($value, Driver $driver)
     }
 
     /**
-     * Marshalls request data into PHP booleans.
+     * Marshals request data into PHP booleans.
      *
      * @param mixed $value The value to convert.
      * @return bool|null Converted value.
      */
-    public function marshal($value)
+    public function marshal(mixed $value): ?bool
     {
-        if ($value === null) {
+        if ($value === null || $value === '') {
             return null;
         }
-        if ($value === 'true') {
-            return true;
-        }
-        if ($value === 'false') {
-            return false;
-        }
 
-        return !empty($value);
+        return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
     }
 }
diff --git a/src/Database/Type/ColumnSchemaAwareInterface.php b/src/Database/Type/ColumnSchemaAwareInterface.php
new file mode 100644
index 00000000000..a8653d12a95
--- /dev/null
+++ b/src/Database/Type/ColumnSchemaAwareInterface.php
@@ -0,0 +1,29 @@
+|null Array of column information, or `null` in case the column isn't processed by this type.
+     */
+    public function convertColumnDefinition(array $definition, Driver $driver): ?array;
+}
diff --git a/src/Database/Type/DateTimeFractionalType.php b/src/Database/Type/DateTimeFractionalType.php
new file mode 100644
index 00000000000..cdfee8390cc
--- /dev/null
+++ b/src/Database/Type/DateTimeFractionalType.php
@@ -0,0 +1,28 @@
+
+     */
+
+    protected array $_marshalFormats = [
+        'Y-m-d H:i',
+        'Y-m-d H:i:s',
+        'Y-m-d H:i:sP',
+        'Y-m-d H:i:s.u',
+        'Y-m-d H:i:s.uP',
+        'Y-m-d\TH:i',
+        'Y-m-d\TH:i:s',
+        'Y-m-d\TH:i:sP',
+        'Y-m-d\TH:i:s.u',
+        'Y-m-d\TH:i:s.uP',
+        '!Y-m-d',
+    ];
+}
diff --git a/src/Database/Type/DateTimeType.php b/src/Database/Type/DateTimeType.php
index 6efb905f3fd..03009496f94 100644
--- a/src/Database/Type/DateTimeType.php
+++ b/src/Database/Type/DateTimeType.php
@@ -1,4 +1,6 @@
 
      */
-    protected $_name;
+    protected array $_marshalFormats = [
+        'Y-m-d H:i',
+        'Y-m-d H:i:s',
+        'Y-m-d H:i:s.u',
+        'Y-m-d\TH:i',
+        'Y-m-d\TH:i:s',
+        'Y-m-d\TH:i:sP',
+        'Y-m-d\TH:i:s.u',
+        'Y-m-d\TH:i:s.uP',
+        '!Y-m-d',
+    ];
 
     /**
-     * The class to use for representing date objects
+     * Whether `marshal()` should use locale-aware parser with `_localeMarshalFormat`.
      *
-     * This property can only be used before an instance of this type
-     * class is constructed. After that use `useMutable()` or `useImmutable()` instead.
+     * @var bool
+     */
+    protected bool $_useLocaleMarshal = false;
+
+    /**
+     * The locale-aware format `marshal()` uses when `_useLocaleParser` is true.
      *
-     * @var string
-     * @deprecated 3.2.0 Use DateTimeType::useMutable() or DateTimeType::useImmutable() instead.
+     * See `Cake\I18n\Time::parseDateTime()` for accepted formats.
+     *
+     * @var array|string|int|null
      */
-    public static $dateTimeClass = 'Cake\I18n\Time';
+    protected array|string|int|null $_localeMarshalFormat = null;
 
     /**
-     * String format to use for DateTime parsing
+     * The classname to use when creating objects.
      *
-     * @var string|array
+     * @var class-string<\Cake\I18n\DateTime>|class-string<\DateTimeImmutable>
      */
-    protected $_format = [
-        'Y-m-d H:i:s',
-        'Y-m-d\TH:i:sP',
-    ];
+    protected string $_className;
 
     /**
-     * Whether dates should be parsed using a locale aware parser
-     * when marshalling string inputs.
+     * Database time zone.
      *
-     * @var bool
+     * @var \DateTimeZone|null
      */
-    protected $_useLocaleParser = false;
+    protected ?DateTimeZone $dbTimezone = null;
 
     /**
-     * The date format to use for parsing incoming dates for marshalling.
+     * User time zone.
      *
-     * @var string|array|int
+     * @var \DateTimeZone|null
      */
-    protected $_localeFormat;
+    protected ?DateTimeZone $userTimezone = null;
 
     /**
-     * An instance of the configured dateTimeClass, used to quickly generate
-     * new instances without calling the constructor.
+     * Default time zone.
      *
-     * @var \DateTime
+     * @var \DateTimeZone
      */
-    protected $_datetimeInstance;
+    protected DateTimeZone $defaultTimezone;
 
     /**
-     * The classname to use when creating objects.
+     * Whether database time zone is kept when converting
      *
-     * @var string
+     * @var bool
      */
-    protected $_className;
+    protected bool $keepDatabaseTimezone = false;
 
     /**
      * {@inheritDoc}
+     *
+     * @param string|null $name The name identifying this type
      */
-    public function __construct($name = null)
+    public function __construct(?string $name = null)
     {
-        $this->_name = $name;
+        parent::__construct($name);
 
-        $this->_setClassName(static::$dateTimeClass, 'DateTime');
+        $this->defaultTimezone = new DateTimeZone(date_default_timezone_get());
+        $this->_className = class_exists(DateTime::class) ? DateTime::class : DateTimeImmutable::class;
     }
 
     /**
      * Convert DateTime instance into strings.
      *
-     * @param string|int|\DateTime $value The value to convert.
+     * @param mixed $value The value to convert.
      * @param \Cake\Database\Driver $driver The driver instance to convert with.
      * @return string|null
      */
-    public function toDatabase($value, Driver $driver)
+    public function toDatabase(mixed $value, Driver $driver): ?string
     {
         if ($value === null || is_string($value)) {
             return $value;
         }
-        if (is_int($value)) {
+        if (is_int($value) || is_float($value)) {
             $class = $this->_className;
             $value = new $class('@' . $value);
         }
 
-        $format = (array)$this->_format;
+        if ($value instanceof ChronosDate) {
+            return $value->format($this->_format);
+        }
+
+        if (!$value instanceof DateTimeInterface) {
+            return null;
+        }
+
+        if (
+            $this->dbTimezone !== null
+            && $this->dbTimezone->getName() !== $value->getTimezone()->getName()
+        ) {
+            if (!$value instanceof DateTimeImmutable) {
+                $value = clone $value;
+            }
+            $value = $value->setTimezone($this->dbTimezone);
+        }
 
-        return $value->format(array_shift($format));
+        return $value->format($this->_format);
     }
 
     /**
-     * Convert strings into DateTime instances.
+     * Set database timezone.
      *
-     * @param string $value The value to convert.
-     * @param \Cake\Database\Driver $driver The driver instance to convert with.
-     * @return \Cake\I18n\Time|\DateTime|null
+     * This is the time zone used when converting database strings to DateTime
+     * instances and converting DateTime instances to database strings.
+     *
+     * @see \Cake\Database\Type\DateTimeType::setKeepDatabaseTimezone()
+     * @param \DateTimeZone|string|null $timezone Database timezone.
+     * @return $this
      */
-    public function toPHP($value, Driver $driver)
+    public function setDatabaseTimezone(DateTimeZone|string|null $timezone)
     {
-        if ($value === null || strpos($value, '0000-00-00') === 0) {
+        if (is_string($timezone)) {
+            $timezone = new DateTimeZone($timezone);
+        }
+        $this->dbTimezone = $timezone;
+
+        return $this;
+    }
+
+    /**
+     * Set user timezone.
+     *
+     * This is the time zone used when marshaling strings to DateTime instances.
+     *
+     * @param \DateTimeZone|string|null $timezone User timezone.
+     * @return $this
+     */
+    public function setUserTimezone(DateTimeZone|string|null $timezone)
+    {
+        if (is_string($timezone)) {
+            $timezone = new DateTimeZone($timezone);
+        }
+        $this->userTimezone = $timezone;
+
+        return $this;
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * @param mixed $value Value to be converted to PHP equivalent
+     * @param \Cake\Database\Driver $driver Object from which database preferences and configuration will be extracted
+     * @return \Cake\I18n\DateTime|\DateTimeImmutable|null
+     */
+    public function toPHP(mixed $value, Driver $driver): DateTime|DateTimeImmutable|null
+    {
+        if ($value === null) {
+            return null;
+        }
+
+        $class = $this->_className;
+        if (is_numeric($value)) {
+            $instance = new $class('@' . $value);
+        } elseif (str_starts_with($value, '0000-00-00')) {
             return null;
+        } else {
+            $instance = new $class($value, $this->dbTimezone);
         }
 
-        if (strpos($value, '.') !== false) {
-            list($value) = explode('.', $value);
+        if (
+            !$this->keepDatabaseTimezone
+            && $instance->getTimezone()
+            && $instance->getTimezone()->getName() !== $this->defaultTimezone->getName()
+        ) {
+            return $instance->setTimezone($this->defaultTimezone);
         }
 
-        $instance = clone $this->_datetimeInstance;
+        return $instance;
+    }
+
+    /**
+     * Set whether DateTime object created from database string is converted
+     * to default time zone.
+     *
+     * If your database date times are in a specific time zone that you want
+     * to keep in the DateTime instance then set this to true.
+     *
+     * When false, datetime timezones are converted to default time zone.
+     * This is default behavior.
+     *
+     * @param bool $keep If true, database time zone is kept when converting
+     *      to DateTime instances.
+     * @return $this
+     */
+    public function setKeepDatabaseTimezone(bool $keep)
+    {
+        $this->keepDatabaseTimezone = $keep;
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function manyToPHP(array $values, array $fields, Driver $driver): array
+    {
+        foreach ($fields as $field) {
+            if (!isset($values[$field])) {
+                continue;
+            }
+
+            $value = $values[$field];
+
+            $class = $this->_className;
+            if (is_int($value)) {
+                $instance = new $class('@' . $value);
+            } elseif (str_starts_with($value, '0000-00-00')) {
+                $values[$field] = null;
+                continue;
+            } else {
+                $instance = new $class($value, $this->dbTimezone);
+            }
+
+            if (
+                !$this->keepDatabaseTimezone
+                && $instance->getTimezone()
+                && $instance->getTimezone()->getName() !== $this->defaultTimezone->getName()
+            ) {
+                $instance = $instance->setTimezone($this->defaultTimezone);
+            }
 
-        return $instance->modify($value);
+            $values[$field] = $instance;
+        }
+
+        return $values;
     }
 
     /**
      * Convert request data into a datetime object.
      *
      * @param mixed $value Request data
-     * @return \DateTimeInterface
+     * @return \DateTimeInterface|null
      */
-    public function marshal($value)
+    public function marshal(mixed $value): ?DateTimeInterface
     {
         if ($value instanceof DateTimeInterface) {
-            return $value;
+            if ($value instanceof NativeDateTime) {
+                $value = clone $value;
+            }
+
+            /** @var \Datetime|\DateTimeImmutable $value */
+            return $value->setTimezone($this->defaultTimezone);
+        }
+        if ($value instanceof ChronosDate) {
+            return $value->toNative();
         }
 
         $class = $this->_className;
         try {
-            $compare = $date = false;
-            if ($value === '' || $value === null || $value === false || $value === true) {
-                return null;
-            }
-            $isString = is_string($value);
-            if (ctype_digit($value)) {
-                $date = new $class('@' . $value);
-            } elseif ($isString && $this->_useLocaleParser) {
-                return $this->_parseValue($value);
-            } elseif ($isString) {
-                $date = new $class($value);
-                $compare = true;
-            }
-            if ($compare && $date && !$this->_compare($date, $value)) {
-                return $value;
+            if (is_int($value) || (is_string($value) && ctype_digit($value))) {
+                $dateTime = new $class('@' . $value);
+
+                return $dateTime->setTimezone($this->defaultTimezone);
             }
-            if ($date) {
-                return $date;
+
+            if (is_string($value)) {
+                if ($this->_useLocaleMarshal) {
+                    $dateTime = $this->_parseLocaleValue($value);
+                } else {
+                    $dateTime = $this->_parseValue($value);
+                }
+
+                if ($dateTime) {
+                    return $dateTime->setTimezone($this->defaultTimezone);
+                }
+
+                return $dateTime;
             }
-        } catch (Exception $e) {
-            return $value;
+        } catch (Exception) {
+            return null;
         }
 
-        if (is_array($value) && implode('', $value) === '') {
+        if (!is_array($value)) {
             return null;
         }
-        $value += ['hour' => 0, 'minute' => 0, 'second' => 0];
 
-        $format = '';
-        if (isset($value['year'], $value['month'], $value['day']) &&
-            (is_numeric($value['year']) && is_numeric($value['month']) && is_numeric($value['day']))
+        $value += [
+            'year' => null, 'month' => null, 'day' => null,
+            'hour' => 0, 'minute' => 0, 'second' => 0, 'microsecond' => 0,
+        ];
+        if (
+            !is_numeric($value['year']) || !is_numeric($value['month']) || !is_numeric($value['day']) ||
+            !is_numeric($value['hour']) || !is_numeric($value['minute']) || !is_numeric($value['second']) ||
+            !is_numeric($value['microsecond'])
         ) {
-            $format .= sprintf('%d-%02d-%02d', $value['year'], $value['month'], $value['day']);
+            return null;
         }
 
         if (isset($value['meridian']) && (int)$value['hour'] === 12) {
@@ -199,148 +356,119 @@ public function marshal($value)
         if (isset($value['meridian'])) {
             $value['hour'] = strtolower($value['meridian']) === 'am' ? $value['hour'] : $value['hour'] + 12;
         }
-        $format .= sprintf(
-            '%s%02d:%02d:%02d',
-            empty($format) ? '' : ' ',
+        $format = sprintf(
+            '%d-%02d-%02d %02d:%02d:%02d.%06d',
+            $value['year'],
+            $value['month'],
+            $value['day'],
             $value['hour'],
             $value['minute'],
-            $value['second']
+            $value['second'],
+            $value['microsecond'],
         );
-        $tz = isset($value['timezone']) ? $value['timezone'] : null;
-
-        return new $class($format, $tz);
-    }
 
-    /**
-     * @param \Cake\I18n\Time|\DateTime $date DateTime object
-     * @param mixed $value Request data
-     * @return bool
-     */
-    protected function _compare($date, $value)
-    {
-        foreach ((array)$this->_format as $format) {
-            if ($date->format($format) === $value) {
-                return true;
-            }
-        }
+        $dateTime = new $class($format, $value['timezone'] ?? $this->userTimezone);
 
-        return false;
+        return $dateTime->setTimezone($this->defaultTimezone);
     }
 
     /**
-     * Sets whether or not to parse dates passed to the marshal() function
-     * by using a locale aware parser.
+     * Sets whether to parse strings passed to `marshal()` using
+     * the locale-aware format set by `setLocaleFormat()`.
      *
-     * @param bool $enable Whether or not to enable
+     * @param bool $enable Whether to enable
      * @return $this
      */
-    public function useLocaleParser($enable = true)
+    public function useLocaleParser(bool $enable = true)
     {
         if ($enable === false) {
-            $this->_useLocaleParser = $enable;
+            $this->_useLocaleMarshal = $enable;
 
             return $this;
         }
-        if (method_exists($this->_className, 'parseDateTime')) {
-            $this->_useLocaleParser = $enable;
+        if (is_a($this->_className, DateTime::class, true)) {
+            $this->_useLocaleMarshal = $enable;
 
             return $this;
         }
-        throw new RuntimeException(
-            sprintf('Cannot use locale parsing with the %s class', $this->_className)
+        throw new DatabaseException(
+            sprintf('Cannot use locale parsing with the %s class', $this->_className),
         );
     }
 
     /**
-     * Sets the format string to use for parsing dates in this class. The formats
-     * that are accepted are documented in the `Cake\I18n\Time::parseDateTime()`
-     * function.
+     * Sets the locale-aware format used by `marshal()` when parsing strings.
      *
-     * @param string|array $format The format in which the string are passed.
-     * @see \Cake\I18n\Time::parseDateTime()
-     * @return $this
-     */
-    public function setLocaleFormat($format)
-    {
-        $this->_localeFormat = $format;
-
-        return $this;
-    }
-
-    /**
-     * Change the preferred class name to the FrozenTime implementation.
+     * See `Cake\I18n\Time::parseDateTime()` for accepted formats.
      *
+     * @param array|string $format The locale-aware format
+     * @see \Cake\I18n\DateTime::parseDateTime()
      * @return $this
      */
-    public function useImmutable()
+    public function setLocaleFormat(array|string $format)
     {
-        $this->_setClassName('Cake\I18n\FrozenTime', 'DateTimeImmutable');
+        $this->_localeMarshalFormat = $format;
 
         return $this;
     }
 
-    /**
-     * Set the classname to use when building objects.
-     *
-     * @param string $class The classname to use.
-     * @param string $fallback The classname to use when the preferred class does not exist.
-     * @return void
-     */
-    protected function _setClassName($class, $fallback)
-    {
-        if (!class_exists($class)) {
-            $class = $fallback;
-        }
-        $this->_className = $class;
-        $this->_datetimeInstance = new $this->_className;
-    }
-
     /**
      * Get the classname used for building objects.
      *
-     * @return string
+     * @return class-string<\Cake\I18n\DateTime>|class-string<\DateTimeImmutable>
      */
-    public function getDateTimeClassName()
+    public function getDateTimeClassName(): string
     {
         return $this->_className;
     }
 
     /**
-     * Change the preferred class name to the mutable Time implementation.
+     * Converts a string into a DateTime object after parsing it using the locale
+     * aware parser with the format set by `setLocaleFormat()`.
      *
-     * @return $this
+     * @param string $value The value to parse and convert to an object.
+     * @return \Cake\I18n\DateTime|null
      */
-    public function useMutable()
+    protected function _parseLocaleValue(string $value): ?DateTime
     {
-        $this->_setClassName('Cake\I18n\Time', 'DateTime');
+        /** @var class-string<\Cake\I18n\DateTime> $class */
+        $class = $this->_className;
 
-        return $this;
+        return $class::parseDateTime($value, $this->_localeMarshalFormat, $this->userTimezone);
     }
 
     /**
-     * Converts a string into a DateTime object after parsing it using the locale
-     * aware parser with the specified format.
+     * Converts a string into a DateTime object after parsing it using the
+     * formats in `_marshalFormats`.
      *
      * @param string $value The value to parse and convert to an object.
-     * @return \Cake\I18n\Time|null
+     * @return \Cake\I18n\DateTime|\DateTimeImmutable|null
      */
-    protected function _parseValue($value)
+    protected function _parseValue(string $value): DateTime|DateTimeImmutable|null
     {
-        /* @var \Cake\I18n\Time $class */
         $class = $this->_className;
 
-        return $class::parseDateTime($value, $this->_localeFormat);
+        foreach ($this->_marshalFormats as $format) {
+            try {
+                $dateTime = $class::createFromFormat($format, $value, $this->userTimezone);
+                // Check for false in case DateTimeImmutable is used
+                if ($dateTime !== false) {
+                    return $dateTime;
+                }
+            } catch (InvalidArgumentException) {
+                // Chronos wraps DateTimeImmutable::createFromFormat and throws
+                // exception if parse fails.
+                continue;
+            }
+        }
+
+        return null;
     }
 
     /**
-     * Casts given value to Statement equivalent
-     *
-     * @param mixed $value value to be converted to PDO statement
-     * @param \Cake\Database\Driver $driver object from which database preferences and configuration will be extracted
-     *
-     * @return mixed
+     * @inheritDoc
      */
-    public function toStatement($value, Driver $driver)
+    public function toStatement(mixed $value, Driver $driver): int
     {
         return PDO::PARAM_STR;
     }
diff --git a/src/Database/Type/DateType.php b/src/Database/Type/DateType.php
index e5b2b9ec81e..b2f64f182b9 100644
--- a/src/Database/Type/DateType.php
+++ b/src/Database/Type/DateType.php
@@ -1,4 +1,6 @@
 
+     */
+    protected array $_marshalFormats = [
+        'Y-m-d',
+    ];
+
+    /**
+     * Whether `marshal()` should use locale-aware parser with `_localeMarshalFormat`.
      *
-     * This property can only be used before an instance of this type
-     * class is constructed. After that use `useMutable()` or `useImmutable()` instead.
+     * @var bool
+     */
+    protected bool $_useLocaleMarshal = false;
+
+    /**
+     * The locale-aware format `marshal()` uses when `_useLocaleParser` is true.
      *
-     * @var string
-     * @deprecated 3.2.0 Use DateType::useMutable() or DateType::useImmutable() instead.
+     * See `Cake\I18n\Date::parseDate()` for accepted formats.
+     *
+     * @var string|int|null
      */
-    public static $dateTimeClass = 'Cake\I18n\Date';
+    protected string|int|null $_localeMarshalFormat = null;
 
     /**
-     * Date format for DateTime object
+     * The classname to use when creating objects.
      *
-     * @var string|array
+     * @var class-string<\Cake\Chronos\ChronosDate>
+     */
+    protected string $_className;
+
+    /**
+     * @inheritDoc
      */
-    protected $_format = 'Y-m-d';
+    public function __construct(?string $name = null)
+    {
+        parent::__construct($name);
+
+        $this->_className = class_exists(Date::class) ? Date::class : ChronosDate::class;
+    }
 
     /**
-     * Change the preferred class name to the FrozenDate implementation.
+     * Convert DateTime instance into strings.
      *
-     * @return $this
+     * @param mixed $value The value to convert.
+     * @param \Cake\Database\Driver $driver The driver instance to convert with.
+     * @return string|null
      */
-    public function useImmutable()
+    public function toDatabase(mixed $value, Driver $driver): ?string
     {
-        $this->_setClassName('Cake\I18n\FrozenDate', 'DateTimeImmutable');
+        if ($value === null || is_string($value)) {
+            return $value;
+        }
+        if (is_int($value)) {
+            $class = $this->_className;
+            $value = new $class('@' . $value);
+        }
 
-        return $this;
+        assert(is_object($value) && method_exists($value, 'format'));
+
+        return $value->format($this->_format);
     }
 
     /**
-     * Change the preferred class name to the mutable Date implementation.
+     * {@inheritDoc}
      *
-     * @return $this
+     * @param mixed $value Value to be converted to PHP equivalent
+     * @param \Cake\Database\Driver $driver Object from which database preferences and configuration will be extracted
+     * @return \Cake\Chronos\ChronosDate|null
      */
-    public function useMutable()
+    public function toPHP(mixed $value, Driver $driver): ?ChronosDate
     {
-        $this->_setClassName('Cake\I18n\Date', 'DateTime');
+        if ($value === null) {
+            return null;
+        }
 
-        return $this;
+        $class = $this->_className;
+        if (is_int($value)) {
+            $instance = new $class('@' . $value);
+        } elseif (str_starts_with($value, '0000-00-00')) {
+            return null;
+        } else {
+            $instance = new $class($value);
+        }
+
+        return $instance;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function manyToPHP(array $values, array $fields, Driver $driver): array
+    {
+        foreach ($fields as $field) {
+            if (!isset($values[$field])) {
+                continue;
+            }
+
+            $value = $values[$field];
+
+            $class = $this->_className;
+            if (is_int($value)) {
+                $instance = new $class('@' . $value);
+            } elseif (str_starts_with($value, '0000-00-00')) {
+                $values[$field] = null;
+                continue;
+            } else {
+                $instance = new $class($value);
+            }
+
+            $values[$field] = $instance;
+        }
+
+        return $values;
     }
 
     /**
      * Convert request data into a datetime object.
      *
      * @param mixed $value Request data
-     * @return \DateTimeInterface
+     * @return \Cake\Chronos\ChronosDate|null
      */
-    public function marshal($value)
+    public function marshal(mixed $value): ?ChronosDate
     {
-        $date = parent::marshal($value);
-        if ($date instanceof DateTime) {
-            $date->setTime(0, 0, 0);
+        if ($value instanceof $this->_className) {
+            return $value;
+        }
+
+        if ($value instanceof DateTimeInterface || $value instanceof ChronosDate) {
+            return new $this->_className($value->format($this->_format));
         }
 
-        return $date;
+        $class = $this->_className;
+        try {
+            if (is_int($value) || (is_string($value) && ctype_digit($value))) {
+                return new $class('@' . $value);
+            }
+
+            if (is_string($value)) {
+                if ($this->_useLocaleMarshal) {
+                    return $this->_parseLocaleValue($value);
+                }
+
+                return $this->_parseValue($value);
+            }
+        } catch (Exception) {
+            return null;
+        }
+
+        if (
+            !is_array($value) ||
+            !isset($value['year'], $value['month'], $value['day']) ||
+            !is_numeric($value['year']) || !is_numeric($value['month']) || !is_numeric($value['day'])
+        ) {
+            return null;
+        }
+
+        $format = sprintf('%d-%02d-%02d', $value['year'], $value['month'], $value['day']);
+
+        return new $class($format);
     }
 
     /**
-     * Convert strings into Date instances.
+     * Sets whether to parse strings passed to `marshal()` using
+     * the locale-aware format set by `setLocaleFormat()`.
      *
-     * @param string $value The value to convert.
-     * @param \Cake\Database\Driver $driver The driver instance to convert with.
-     * @return \Cake\I18n\Date|\DateTime
+     * @param bool $enable Whether to enable
+     * @return $this
      */
-    public function toPHP($value, Driver $driver)
+    public function useLocaleParser(bool $enable = true)
     {
-        $date = parent::toPHP($value, $driver);
-        if ($date instanceof DateTime) {
-            $date->setTime(0, 0, 0);
+        if ($enable === false) {
+            $this->_useLocaleMarshal = $enable;
+
+            return $this;
         }
+        if (is_a($this->_className, Date::class, true)) {
+            $this->_useLocaleMarshal = $enable;
 
-        return $date;
+            return $this;
+        }
+        throw new DatabaseException(
+            sprintf('Cannot use locale parsing with %s', $this->_className),
+        );
     }
 
     /**
-     * {@inheritDoc}
+     * Sets the locale-aware format used by `marshal()` when parsing strings.
+     *
+     * See `Cake\I18n\Date::parseDate()` for accepted formats.
+     *
+     * @param string|int $format The locale-aware format
+     * @see \Cake\I18n\Date::parseDate()
+     * @return $this
      */
-    protected function _parseValue($value)
+    public function setLocaleFormat(string|int $format)
+    {
+        $this->_localeMarshalFormat = $format;
+
+        return $this;
+    }
+
+    /**
+     * Get the classname used for building objects.
+     *
+     * @return class-string<\Cake\Chronos\ChronosDate>
+     */
+    public function getDateClassName(): string
+    {
+        return $this->_className;
+    }
+
+    /**
+     * @param string $value
+     * @return \Cake\I18n\Date|null
+     */
+    protected function _parseLocaleValue(string $value): ?Date
+    {
+        /** @var class-string<\Cake\I18n\Date> $class */
+        $class = $this->_className;
+
+        return $class::parseDate($value, $this->_localeMarshalFormat);
+    }
+
+    /**
+     * Converts a string into a DateTime object after parsing it using the
+     * formats in `_marshalFormats`.
+     *
+     * @param string $value The value to parse and convert to an object.
+     * @return \Cake\Chronos\ChronosDate|null
+     */
+    protected function _parseValue(string $value): ?ChronosDate
     {
-        /* @var \Cake\I18n\Time $class */
         $class = $this->_className;
+        foreach ($this->_marshalFormats as $format) {
+            try {
+                return $class::createFromFormat($format, $value);
+            } catch (InvalidArgumentException) {
+                continue;
+            }
+        }
 
-        return $class::parseDate($value, $this->_localeFormat);
+        return null;
     }
 }
diff --git a/src/Database/Type/DecimalType.php b/src/Database/Type/DecimalType.php
index a15e90a4cc8..81a958ebda4 100644
--- a/src/Database/Type/DecimalType.php
+++ b/src/Database/Type/DecimalType.php
@@ -1,4 +1,6 @@
 _name = $name;
-    }
-
     /**
      * The class to use for representing number objects
      *
-     * @var string
+     * @var class-string<\Cake\I18n\Number>|string
      */
-    public static $numberClass = 'Cake\I18n\Number';
+    public static string $numberClass = Number::class;
 
     /**
      * Whether numbers should be parsed using a locale aware parser
-     * when marshalling string inputs.
+     * when marshaling string inputs.
      *
      * @var bool
      */
-    protected $_useLocaleParser = false;
+    protected bool $_useLocaleParser = false;
 
     /**
-     * Convert integer data into the database format.
+     * Convert decimal strings into the database format.
      *
-     * @param string|int|float $value The value to convert.
+     * @param mixed $value The value to convert.
      * @param \Cake\Database\Driver $driver The driver instance to convert with.
-     * @return string|null
+     * @return string|float|int|null
      * @throws \InvalidArgumentException
      */
-    public function toDatabase($value, Driver $driver)
+    public function toDatabase(mixed $value, Driver $driver): string|float|int|null
     {
         if ($value === null || $value === '') {
             return null;
         }
-        if (!is_scalar($value)) {
-            throw new InvalidArgumentException('Cannot convert value to a decimal.');
-        }
-        if (is_string($value) && is_numeric($value)) {
+
+        if (is_numeric($value)) {
             return $value;
         }
 
-        return sprintf('%F', $value);
+        if ($value instanceof Stringable) {
+            $str = (string)$value;
+
+            if (is_numeric($str)) {
+                return $str;
+            }
+        }
+
+        throw new InvalidArgumentException(sprintf(
+            'Cannot convert value `%s` of type `%s` to a decimal',
+            print_r($value, true),
+            get_debug_type($value),
+        ));
     }
 
     /**
-     * Convert float values to PHP integers
+     * {@inheritDoc}
      *
-     * @param null|string|resource $value The value to convert.
+     * @param mixed $value The value to convert.
      * @param \Cake\Database\Driver $driver The driver instance to convert with.
-     * @return float|null
-     * @throws \Cake\Core\Exception\Exception
+     * @return string|null
      */
-    public function toPHP($value, Driver $driver)
+    public function toPHP(mixed $value, Driver $driver): ?string
     {
         if ($value === null) {
             return null;
         }
 
-        return (float)$value;
+        return (string)$value;
     }
 
     /**
-     * Get the correct PDO binding type for integer data.
-     *
-     * @param mixed $value The value being bound.
-     * @param \Cake\Database\Driver $driver The driver.
-     * @return int
+     * @inheritDoc
+     */
+    public function manyToPHP(array $values, array $fields, Driver $driver): array
+    {
+        foreach ($fields as $field) {
+            if (!isset($values[$field])) {
+                continue;
+            }
+
+            $values[$field] = (string)$values[$field];
+        }
+
+        return $values;
+    }
+
+    /**
+     * @inheritDoc
      */
-    public function toStatement($value, Driver $driver)
+    public function toStatement(mixed $value, Driver $driver): int
     {
         return PDO::PARAM_STR;
     }
 
     /**
-     * Marshalls request data into PHP floats.
+     * Marshalls request data into decimal strings.
      *
      * @param mixed $value The value to convert.
-     * @return mixed Converted value.
+     * @return string|null Converted value.
      */
-    public function marshal($value)
+    public function marshal(mixed $value): ?string
     {
         if ($value === null || $value === '') {
             return null;
@@ -133,53 +133,59 @@ public function marshal($value)
             return $this->_parseValue($value);
         }
         if (is_numeric($value)) {
-            return (float)$value;
+            return (string)$value;
         }
-        if (is_array($value)) {
-            return 1;
+        if (is_string($value) && preg_match('/^[0-9,. ]+$/', $value)) {
+            return $value;
         }
 
-        return $value;
+        return null;
     }
 
     /**
-     * Sets whether or not to parse numbers passed to the marshal() function
+     * Sets whether to parse numbers passed to the marshal() function
      * by using a locale aware parser.
      *
-     * @param bool $enable Whether or not to enable
+     * @param bool $enable Whether to enable
      * @return $this
+     * @throws \Cake\Database\Exception\DatabaseException
      */
-    public function useLocaleParser($enable = true)
+    public function useLocaleParser(bool $enable = true)
     {
         if ($enable === false) {
             $this->_useLocaleParser = $enable;
 
             return $this;
         }
-        if (static::$numberClass === 'Cake\I18n\Number' ||
-            is_subclass_of(static::$numberClass, 'Cake\I18n\Number')
+        if (
+            static::$numberClass === Number::class ||
+            is_subclass_of(static::$numberClass, Number::class)
         ) {
             $this->_useLocaleParser = $enable;
 
             return $this;
         }
-        throw new RuntimeException(
-            sprintf('Cannot use locale parsing with the %s class', static::$numberClass)
+        throw new DatabaseException(
+            sprintf('Cannot use locale parsing with the %s class', static::$numberClass),
         );
     }
 
     /**
-     * Converts a string into a float point after parsing it using the locale
-     * aware parser.
+     * Converts localized string into a decimal string after parsing it using
+     * the locale aware parser.
      *
      * @param string $value The value to parse and convert to an float.
-     * @return float
+     * @return string|null
      */
-    protected function _parseValue($value)
+    protected function _parseValue(string $value): ?string
     {
-        /* @var \Cake\I18n\Number $class */
         $class = static::$numberClass;
+        $result = $class::parseFloat($value);
+
+        if ($result === null) {
+            return null;
+        }
 
-        return $class::parseFloat($value);
+        return (string)$result;
     }
 }
diff --git a/src/Database/Type/EnumLabelInterface.php b/src/Database/Type/EnumLabelInterface.php
new file mode 100644
index 00000000000..ca5feb3f19b
--- /dev/null
+++ b/src/Database/Type/EnumLabelInterface.php
@@ -0,0 +1,31 @@
+ $labels */
+        static $labels = [];
+
+        if (isset($labels[$this->name])) {
+            return $this->translatedLabel($labels[$this->name]);
+        }
+
+        $reflection = new ReflectionClassConstant(static::class, $this->name);
+        $enumAttributes = $reflection->getAttributes(Label::class);
+
+        if ($enumAttributes === []) {
+            $labels[$this->name] = [
+                'label' => Inflector::humanize(Inflector::underscore($this->name)),
+                'context' => '',
+                'domain' => 'default',
+            ];
+        } else {
+            $instance = $enumAttributes[0]->newInstance();
+            $labels[$this->name] = [
+                'label' => $instance->label,
+                'context' => $instance->context,
+                'domain' => $instance->domain,
+            ];
+        }
+
+        return $this->translatedLabel($labels[$this->name]);
+    }
+
+    /**
+     * Returns the translated label for the enum case.
+     *
+     * @param array{label:string,context:string,domain:string} $label
+     */
+    private function translatedLabel(array $label): string
+    {
+        /** @var bool $i18n */
+        static $i18n;
+
+        $i18n ??= function_exists('\Cake\I18n\__dx');
+
+        if (!$i18n) {
+            return $label['label'];
+        }
+
+        $context = $label['context'] ?? '';
+        $domain = $label['domain'] ?? 'default';
+
+        return __dx($domain, $context, $label['label']);
+    }
+}
diff --git a/src/Database/Type/EnumType.php b/src/Database/Type/EnumType.php
new file mode 100644
index 00000000000..05b16036ee0
--- /dev/null
+++ b/src/Database/Type/EnumType.php
@@ -0,0 +1,227 @@
+
+     */
+    protected string $enumClassName;
+
+    /**
+     * @param string $name The name identifying this type
+     * @param class-string<\BackedEnum> $enumClassName The associated enum class name
+     */
+    public function __construct(
+        string $name,
+        string $enumClassName,
+    ) {
+        parent::__construct($name);
+        $this->enumClassName = $enumClassName;
+
+        try {
+            $reflectionEnum = new ReflectionEnum($enumClassName);
+        } catch (ReflectionException $e) {
+            throw new DatabaseException(sprintf(
+                'Unable to use `%s` for type `%s`. %s.',
+                $enumClassName,
+                $name,
+                $e->getMessage(),
+            ));
+        }
+
+        $namedType = $reflectionEnum->getBackingType();
+        if ($namedType === null) {
+            throw new DatabaseException(
+                sprintf('Unable to use enum `%s` for type `%s`, must be a backed enum.', $enumClassName, $name),
+            );
+        }
+
+        $this->backingType = (string)$namedType;
+    }
+
+    /**
+     * Convert enum instances into the database format.
+     *
+     * @param mixed $value The value to convert.
+     * @param \Cake\Database\Driver $driver The driver instance to convert with.
+     * @return string|int|null
+     * @throws \InvalidArgumentException When the given value is not a valid value for the associated enum
+     */
+    public function toDatabase(mixed $value, Driver $driver): string|int|null
+    {
+        if ($value === null) {
+            return null;
+        }
+
+        if ($value instanceof $this->enumClassName) {
+            return $value->value;
+        }
+
+        if ($this->backingType === 'int' && is_string($value)) {
+            $intVal = filter_var($value, FILTER_VALIDATE_INT);
+            if ($intVal !== false) {
+                $value = $intVal;
+            }
+        }
+
+        try {
+            return $this->enumClassName::from($value)->value;
+        } catch (ValueError | TypeError $exception) {
+            if ($exception instanceof TypeError) {
+                throw new InvalidArgumentException(sprintf(
+                    'Given value `%s` of type `%s` does not match associated `%s` backed enum in `%s`',
+                    print_r($value, true),
+                    get_debug_type($value),
+                    $this->backingType,
+                    $this->enumClassName,
+                ));
+            }
+
+            throw new InvalidArgumentException(sprintf(
+                '`%s` is not a valid value for `%s`',
+                $value,
+                $this->enumClassName,
+            ));
+        }
+    }
+
+    /**
+     * Transform DB value to backed enum instance
+     *
+     * @param mixed $value The value to convert.
+     * @param \Cake\Database\Driver $driver The driver instance to convert with.
+     * @return \BackedEnum|null
+     */
+    public function toPHP(mixed $value, Driver $driver): ?BackedEnum
+    {
+        if ($value === null) {
+            return null;
+        }
+
+        if ($this->backingType === 'int' && is_string($value)) {
+            $intVal = filter_var($value, FILTER_VALIDATE_INT);
+            if ($intVal !== false) {
+                $value = $intVal;
+            }
+        }
+
+        return $this->enumClassName::from($value);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function toStatement(mixed $value, Driver $driver): int
+    {
+        if ($this->backingType === 'int') {
+            return PDO::PARAM_INT;
+        }
+
+        return PDO::PARAM_STR;
+    }
+
+    /**
+     * Marshals request data
+     *
+     * @param mixed $value The value to convert.
+     * @return \BackedEnum|null Converted value.
+     */
+    public function marshal(mixed $value): ?BackedEnum
+    {
+        if ($value === null) {
+            return null;
+        }
+
+        if ($value instanceof $this->enumClassName) {
+            return $value;
+        }
+
+        if ($this->backingType === 'int') {
+            if ($value === '') {
+                return null;
+            }
+
+            if (is_numeric($value)) {
+                $value = (int)$value;
+            }
+        }
+
+        try {
+            return $this->enumClassName::from($value);
+        } catch (ValueError | TypeError) {
+            return null;
+        }
+    }
+
+    /**
+     * Create an `EnumType` that is paired with the provided `$enumClassName`.
+     *
+     * ### Usage
+     *
+     * ```
+     * // In a table class
+     * $this->getSchema()->setColumnType('status', EnumType::from(StatusEnum::class));
+     * ```
+     *
+     * @param class-string<\BackedEnum> $enumClassName The enum class name
+     * @return string
+     */
+    public static function from(string $enumClassName): string
+    {
+        $typeName = 'enum-' . strtolower(Text::slug($enumClassName));
+        $instance = new EnumType($typeName, $enumClassName);
+        TypeFactory::set($typeName, $instance);
+
+        return $typeName;
+    }
+
+    /**
+     * @return class-string<\BackedEnum>
+     */
+    public function getEnumClassName(): string
+    {
+        return $this->enumClassName;
+    }
+}
diff --git a/src/Database/Type/ExpressionTypeCasterTrait.php b/src/Database/Type/ExpressionTypeCasterTrait.php
index 51c62475791..1de47b09fee 100644
--- a/src/Database/Type/ExpressionTypeCasterTrait.php
+++ b/src/Database/Type/ExpressionTypeCasterTrait.php
@@ -1,4 +1,6 @@
 toExpression(...), $value);
         }
 
         return $converter->toExpression($value);
@@ -63,12 +64,12 @@ protected function _castToExpression($value, $type)
      * @param array $types List of type names
      * @return array
      */
-    protected function _requiresToExpressionCasting($types)
+    protected function _requiresToExpressionCasting(array $types): array
     {
         $result = [];
         $types = array_filter($types);
         foreach ($types as $k => $type) {
-            $object = Type::build($type);
+            $object = TypeFactory::build($type);
             if ($object instanceof ExpressionTypeInterface) {
                 $result[$k] = $object;
             }
diff --git a/src/Database/Type/ExpressionTypeInterface.php b/src/Database/Type/ExpressionTypeInterface.php
index d2f931e7355..43a49017077 100644
--- a/src/Database/Type/ExpressionTypeInterface.php
+++ b/src/Database/Type/ExpressionTypeInterface.php
@@ -1,4 +1,6 @@
 _name = $name;
-    }
-
     /**
      * The class to use for representing number objects
      *
      * @var string
      */
-    public static $numberClass = 'Cake\I18n\Number';
+    public static string $numberClass = Number::class;
 
     /**
      * Whether numbers should be parsed using a locale aware parser
-     * when marshalling string inputs.
+     * when marshaling string inputs.
      *
      * @var bool
      */
-    protected $_useLocaleParser = false;
+    protected bool $_useLocaleParser = false;
 
     /**
      * Convert integer data into the database format.
      *
-     * @param string|resource $value The value to convert.
+     * @param mixed $value The value to convert.
      * @param \Cake\Database\Driver $driver The driver instance to convert with.
      * @return float|null
      */
-    public function toDatabase($value, Driver $driver)
+    public function toDatabase(mixed $value, Driver $driver): ?float
     {
         if ($value === null || $value === '') {
             return null;
@@ -82,84 +60,93 @@ public function toDatabase($value, Driver $driver)
     }
 
     /**
-     * Convert float values to PHP integers
+     * {@inheritDoc}
      *
-     * @param null|string|resource $value The value to convert.
+     * @param mixed $value The value to convert.
      * @param \Cake\Database\Driver $driver The driver instance to convert with.
      * @return float|null
-     * @throws \Cake\Core\Exception\Exception
      */
-    public function toPHP($value, Driver $driver)
+    public function toPHP(mixed $value, Driver $driver): ?float
     {
         if ($value === null) {
             return null;
         }
-        if (is_array($value)) {
-            return 1.0;
-        }
 
         return (float)$value;
     }
 
     /**
-     * Get the correct PDO binding type for integer data.
-     *
-     * @param mixed $value The value being bound.
-     * @param \Cake\Database\Driver $driver The driver.
-     * @return int
+     * @inheritDoc
+     */
+    public function manyToPHP(array $values, array $fields, Driver $driver): array
+    {
+        foreach ($fields as $field) {
+            if (!isset($values[$field])) {
+                continue;
+            }
+
+            $values[$field] = (float)$values[$field];
+        }
+
+        return $values;
+    }
+
+    /**
+     * @inheritDoc
      */
-    public function toStatement($value, Driver $driver)
+    public function toStatement(mixed $value, Driver $driver): int
     {
         return PDO::PARAM_STR;
     }
 
     /**
-     * Marshalls request data into PHP floats.
+     * Marshals request data into PHP floats.
      *
      * @param mixed $value The value to convert.
-     * @return float|null Converted value.
+     * @return string|float|null Converted value.
      */
-    public function marshal($value)
+    public function marshal(mixed $value): string|float|null
     {
         if ($value === null || $value === '') {
             return null;
         }
-        if (is_numeric($value)) {
-            return (float)$value;
-        }
         if (is_string($value) && $this->_useLocaleParser) {
             return $this->_parseValue($value);
         }
-        if (is_array($value)) {
-            return 1.0;
+        if (is_numeric($value)) {
+            return (float)$value;
+        }
+        if (is_string($value) && preg_match('/^[0-9,. ]+$/', $value)) {
+            return $value;
         }
 
-        return $value;
+        return null;
     }
 
     /**
-     * Sets whether or not to parse numbers passed to the marshal() function
+     * Sets whether to parse numbers passed to the marshal() function
      * by using a locale aware parser.
      *
-     * @param bool $enable Whether or not to enable
+     * @param bool $enable Whether to enable
      * @return $this
      */
-    public function useLocaleParser($enable = true)
+    public function useLocaleParser(bool $enable = true)
     {
         if ($enable === false) {
             $this->_useLocaleParser = $enable;
 
             return $this;
         }
-        if (static::$numberClass === 'Cake\I18n\Number' ||
-            is_subclass_of(static::$numberClass, 'Cake\I18n\Number')
+        if (
+            static::$numberClass === Number::class ||
+            is_subclass_of(static::$numberClass, Number::class)
         ) {
             $this->_useLocaleParser = $enable;
 
             return $this;
         }
-        throw new RuntimeException(
-            sprintf('Cannot use locale parsing with the %s class', static::$numberClass)
+        throw new DatabaseException(
+            sprintf('Cannot use locale parsing with the %s class', static::$numberClass),
         );
     }
 
@@ -168,9 +155,9 @@ public function useLocaleParser($enable = true)
      * aware parser.
      *
      * @param string $value The value to parse and convert to an float.
-     * @return float
+     * @return float|null
      */
-    protected function _parseValue($value)
+    protected function _parseValue(string $value): ?float
     {
         $class = static::$numberClass;
 
diff --git a/src/Database/Type/IntegerType.php b/src/Database/Type/IntegerType.php
index eba5ae26326..9198ed71520 100644
--- a/src/Database/Type/IntegerType.php
+++ b/src/Database/Type/IntegerType.php
@@ -1,4 +1,6 @@
 _name = $name;
+        if (!is_numeric($value) && !is_bool($value)) {
+            throw new InvalidArgumentException(sprintf(
+                'Cannot convert value `%s` of type `%s` to int',
+                print_r($value, true),
+                get_debug_type($value),
+            ));
+        }
     }
 
     /**
@@ -57,27 +52,25 @@ public function __construct($name = null)
      * @param \Cake\Database\Driver $driver The driver instance to convert with.
      * @return int|null
      */
-    public function toDatabase($value, Driver $driver)
+    public function toDatabase(mixed $value, Driver $driver): ?int
     {
         if ($value === null || $value === '') {
             return null;
         }
 
-        if (!is_scalar($value)) {
-            throw new InvalidArgumentException('Cannot convert value to integer');
-        }
+        $this->checkNumeric($value);
 
         return (int)$value;
     }
 
     /**
-     * Convert integer values to PHP integers
+     * {@inheritDoc}
      *
      * @param mixed $value The value to convert.
      * @param \Cake\Database\Driver $driver The driver instance to convert with.
      * @return int|null
      */
-    public function toPHP($value, Driver $driver)
+    public function toPHP(mixed $value, Driver $driver): ?int
     {
         if ($value === null) {
             return null;
@@ -87,35 +80,43 @@ public function toPHP($value, Driver $driver)
     }
 
     /**
-     * Get the correct PDO binding type for integer data.
-     *
-     * @param mixed $value The value being bound.
-     * @param \Cake\Database\Driver $driver The driver.
-     * @return int
+     * @inheritDoc
      */
-    public function toStatement($value, Driver $driver)
+    public function manyToPHP(array $values, array $fields, Driver $driver): array
+    {
+        foreach ($fields as $field) {
+            if (!isset($values[$field])) {
+                continue;
+            }
+
+            $this->checkNumeric($values[$field]);
+
+            $values[$field] = (int)$values[$field];
+        }
+
+        return $values;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function toStatement(mixed $value, Driver $driver): int
     {
         return PDO::PARAM_INT;
     }
 
     /**
-     * Marshalls request data into PHP floats.
+     * Marshals request data into PHP integers.
      *
      * @param mixed $value The value to convert.
      * @return int|null Converted value.
      */
-    public function marshal($value)
+    public function marshal(mixed $value): ?int
     {
-        if ($value === null || $value === '') {
+        if ($value === '' || !is_numeric($value)) {
             return null;
         }
-        if (is_numeric($value) || ctype_digit($value)) {
-            return (int)$value;
-        }
-        if (is_array($value)) {
-            return 1;
-        }
 
-        return null;
+        return (int)$value;
     }
 }
diff --git a/src/Database/Type/JsonType.php b/src/Database/Type/JsonType.php
index 1ca27068522..2c9b1fd0fd4 100644
--- a/src/Database/Type/JsonType.php
+++ b/src/Database/Type/JsonType.php
@@ -1,4 +1,6 @@
 _name = $name;
-    }
+    protected int $_decodingOptions = JSON_OBJECT_AS_ARRAY;
 
     /**
      * Convert a value data into a JSON string
@@ -56,48 +45,99 @@ public function __construct($name = null)
      * @param mixed $value The value to convert.
      * @param \Cake\Database\Driver $driver The driver instance to convert with.
      * @return string|null
+     * @throws \InvalidArgumentException
+     * @throws \JsonException
      */
-    public function toDatabase($value, Driver $driver)
+    public function toDatabase(mixed $value, Driver $driver): ?string
     {
         if (is_resource($value)) {
             throw new InvalidArgumentException('Cannot convert a resource value to JSON');
         }
 
-        return json_encode($value);
+        if ($value === null) {
+            return null;
+        }
+
+        return json_encode($value, JSON_THROW_ON_ERROR | $this->_encodingOptions);
     }
 
     /**
-     * Convert string values to PHP arrays.
+     * {@inheritDoc}
      *
      * @param mixed $value The value to convert.
      * @param \Cake\Database\Driver $driver The driver instance to convert with.
-     * @return string|null|array
+     * @return mixed
      */
-    public function toPHP($value, Driver $driver)
+    public function toPHP(mixed $value, Driver $driver): mixed
     {
-        return json_decode($value, true);
+        if (!is_string($value)) {
+            return null;
+        }
+
+        return json_decode($value, flags: $this->_decodingOptions);
     }
 
     /**
-     * Get the correct PDO binding type for string data.
-     *
-     * @param mixed $value The value being bound.
-     * @param \Cake\Database\Driver $driver The driver.
-     * @return int
+     * @inheritDoc
+     */
+    public function manyToPHP(array $values, array $fields, Driver $driver): array
+    {
+        foreach ($fields as $field) {
+            if (!isset($values[$field])) {
+                continue;
+            }
+
+            $values[$field] = json_decode($values[$field], flags: $this->_decodingOptions);
+        }
+
+        return $values;
+    }
+
+    /**
+     * @inheritDoc
      */
-    public function toStatement($value, Driver $driver)
+    public function toStatement(mixed $value, Driver $driver): int
     {
         return PDO::PARAM_STR;
     }
 
     /**
-     * Marshalls request data into a JSON compatible structure.
+     * Marshals request data into a JSON compatible structure.
      *
      * @param mixed $value The value to convert.
      * @return mixed Converted value.
      */
-    public function marshal($value)
+    public function marshal(mixed $value): mixed
     {
         return $value;
     }
+
+    /**
+     * Set json_encode options.
+     *
+     * @param int $options Encoding flags. Use JSON_* flags. Set `0` to reset.
+     * @return $this
+     * @see https://www.php.net/manual/en/function.json-encode.php
+     */
+    public function setEncodingOptions(int $options)
+    {
+        $this->_encodingOptions = $options;
+
+        return $this;
+    }
+
+    /**
+     * Set json_decode() options.
+     *
+     * By default, the value is `JSON_OBJECT_AS_ARRAY`.
+     *
+     * @param int $options Decoding flags. Use JSON_* flags. Set `0` to reset.
+     * @return $this
+     */
+    public function setDecodingOptions(int $options)
+    {
+        $this->_decodingOptions = $options;
+
+        return $this;
+    }
 }
diff --git a/src/Database/Type/OptionalConvertInterface.php b/src/Database/Type/OptionalConvertInterface.php
index 8d6c03a7840..512683bb677 100644
--- a/src/Database/Type/OptionalConvertInterface.php
+++ b/src/Database/Type/OptionalConvertInterface.php
@@ -1,4 +1,6 @@
 __toString();
+        if ($value instanceof Stringable) {
+            return (string)$value;
         }
 
         if (is_scalar($value)) {
             return (string)$value;
         }
 
-        throw new InvalidArgumentException('Cannot convert value to string');
+        throw new InvalidArgumentException(sprintf(
+            'Cannot convert value `%s` of type `%s` to string',
+            print_r($value, true),
+            get_debug_type($value),
+        ));
     }
 
     /**
@@ -59,7 +63,7 @@ public function toDatabase($value, Driver $driver)
      * @param \Cake\Database\Driver $driver The driver instance to convert with.
      * @return string|null
      */
-    public function toPHP($value, Driver $driver)
+    public function toPHP(mixed $value, Driver $driver): ?string
     {
         if ($value === null) {
             return null;
@@ -69,31 +73,24 @@ public function toPHP($value, Driver $driver)
     }
 
     /**
-     * Get the correct PDO binding type for string data.
-     *
-     * @param mixed $value The value being bound.
-     * @param \Cake\Database\Driver $driver The driver.
-     * @return int
+     * @inheritDoc
      */
-    public function toStatement($value, Driver $driver)
+    public function toStatement(mixed $value, Driver $driver): int
     {
         return PDO::PARAM_STR;
     }
 
     /**
-     * Marshalls request data into PHP strings.
+     * Marshals request data into PHP strings.
      *
      * @param mixed $value The value to convert.
      * @return string|null Converted value.
      */
-    public function marshal($value)
+    public function marshal(mixed $value): ?string
     {
-        if ($value === null) {
+        if ($value === null || is_array($value)) {
             return null;
         }
-        if (is_array($value)) {
-            return '';
-        }
 
         return (string)$value;
     }
@@ -101,9 +98,9 @@ public function marshal($value)
     /**
      * {@inheritDoc}
      *
-     * @return boolean False as database results are returned already as strings
+     * @return bool False as database results are returned already as strings
      */
-    public function requiresToPhpCast()
+    public function requiresToPhpCast(): bool
     {
         return false;
     }
diff --git a/src/Database/Type/TimeType.php b/src/Database/Type/TimeType.php
index 582a4e28709..41af787fc70 100644
--- a/src/Database/Type/TimeType.php
+++ b/src/Database/Type/TimeType.php
@@ -1,4 +1,6 @@
 
+     */
+    protected string $_className;
+
+    /**
+     * Constructor
+     *
+     * @param string|null $name The name identifying this type.
+     * @param class-string<\Cake\Chronos\ChronosTime>|null $className Class name for time representation.
+     */
+    public function __construct(?string $name = null, ?string $className = null)
+    {
+        parent::__construct($name);
+
+        if ($className === null) {
+            $className = class_exists(Time::class) ? Time::class : ChronosTime::class;
+        }
+
+        $this->_className = $className;
+    }
+
+    /**
+     * Convert request data into a datetime object.
+     *
+     * @param mixed $value Request data
+     * @return \Cake\Chronos\ChronosTime|null
+     */
+    public function marshal(mixed $value): ?ChronosTime
+    {
+        if ($value instanceof $this->_className) {
+            return $value;
+        }
+
+        if ($value instanceof DateTimeInterface || $value instanceof ChronosTime) {
+            return new $this->_className($value->format($this->_format));
+        }
+
+        if (is_string($value)) {
+            if ($this->_useLocaleMarshal) {
+                return $this->_parseLocalTimeValue($value);
+            }
+
+            return $this->_parseTimeValue($value);
+        }
+
+        if (!is_array($value)) {
+            return null;
+        }
+
+        $value += ['hour' => null, 'minute' => null, 'second' => 0, 'microsecond' => 0];
+        if (
+            !is_numeric($value['hour']) || !is_numeric($value['minute']) || !is_numeric($value['second']) ||
+            !is_numeric($value['microsecond'])
+        ) {
+            return null;
+        }
+
+        if (isset($value['meridian']) && (int)$value['hour'] === 12) {
+            $value['hour'] = 0;
+        }
+        if (isset($value['meridian'])) {
+            $value['hour'] = strtolower($value['meridian']) === 'am' ? $value['hour'] : $value['hour'] + 12;
+        }
+        $format = sprintf(
+            '%02d:%02d:%02d.%06d',
+            $value['hour'],
+            $value['minute'],
+            $value['second'],
+            $value['microsecond'],
+        );
+
+        return new $this->_className($format);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function manyToPHP(array $values, array $fields, Driver $driver): array
+    {
+        foreach ($fields as $field) {
+            if (!isset($values[$field])) {
+                continue;
+            }
+
+            $value = $values[$field];
+            $instance = new $this->_className($value);
+            $values[$field] = $instance;
+        }
+
+        return $values;
+    }
+
+    /**
+     * Convert time data into the database time format.
+     *
+     * @param mixed $value The value to convert.
+     * @param \Cake\Database\Driver $driver The driver instance to convert with.
+     * @return mixed
+     */
+    public function toDatabase(mixed $value, Driver $driver): mixed
+    {
+        if ($value === null || is_string($value)) {
+            return $value;
+        }
+
+        assert(method_exists($value, 'format'));
+
+        return $value->format($this->_format);
+    }
 
     /**
-     * Time format for DateTime object
+     * Convert time values to PHP time instances
      *
-     * @var string|array
+     * @param mixed $value The value to convert.
+     * @param \Cake\Database\Driver $driver The driver instance to convert with.
+     * @return \Cake\Chronos\ChronosTime|null
      */
-    protected $_format = 'H:i:s';
+    public function toPHP(mixed $value, Driver $driver): ?ChronosTime
+    {
+        if ($value === null) {
+            return null;
+        }
+
+        return new $this->_className($value);
+    }
 
     /**
-     * {@inheritDoc}
+     * Get the classname used for building objects.
+     *
+     * @return class-string<\Cake\Chronos\ChronosTime>
+     */
+    public function getTimeClassName(): string
+    {
+        return $this->_className;
+    }
+
+    /**
+     * Converts a string into a Time object
+     *
+     * @param string $value The value to parse and convert to an object.
+     * @return \Cake\Chronos\ChronosTime|null
+     */
+    protected function _parseTimeValue(string $value): ?ChronosTime
+    {
+        try {
+            return $this->_className::parse($value);
+        } catch (InvalidArgumentException) {
+            return null;
+        }
+    }
+
+    /**
+     * Converts a string into a Time object after parsing it using the locale
+     * aware parser with the format set by `setLocaleFormat()`.
+     *
+     * @param string $value The value to parse and convert to an object.
+     * @return \Cake\Chronos\ChronosTime|null
+     */
+    protected function _parseLocalTimeValue(string $value): ?ChronosTime
+    {
+        assert(is_a($this->_className, Time::class, true));
+
+        return $this->_className::parseTime($value, $this->_localeMarshalFormat);
+    }
+
+    /**
+     * Sets whether to parse strings passed to `marshal()` using
+     * the locale-aware format set by `setLocaleFormat()`.
+     *
+     * @param bool $enable Whether to enable
+     * @return $this
+     */
+    public function useLocaleParser(bool $enable = true)
+    {
+        if (
+            $enable &&
+            ($this->_className !== Time::class && !is_subclass_of($this->_className, Time::class))
+        ) {
+            throw new CakeException('You must install the `cakephp/i18n` package to use locale aware parsing.');
+        }
+
+        $this->_useLocaleMarshal = $enable;
+
+        return $this;
+    }
+
+    /**
+     * Sets the locale-aware format used by `marshal()` when parsing strings.
+     *
+     * See `Cake\I18n\Time::parseTime()` for accepted formats.
+     *
+     * @param string|int|null $format The locale-aware format
+     * @see \Cake\I18n\Time::parseTime()
+     * @return $this
      */
-    protected function _parseValue($value)
+    public function setLocaleFormat(string|int|null $format)
     {
-        /* @var \Cake\I18n\Time $class */
-        $class = $this->_className;
+        $this->_localeMarshalFormat = $format;
 
-        return $class::parseTime($value, $this->_localeFormat);
+        return $this;
     }
 }
diff --git a/src/Database/Type/UuidType.php b/src/Database/Type/UuidType.php
index ecfdb859364..2942794978b 100644
--- a/src/Database/Type/UuidType.php
+++ b/src/Database/Type/UuidType.php
@@ -1,4 +1,6 @@
 toDatabase($value, $this->_driver);
-            $type = $type->toStatement($value, $this->_driver);
-        }
-
-        return [$value, $type];
-    }
-
-    /**
-     * Matches columns to corresponding types
-     *
-     * Both $columns and $types should either be numeric based or string key based at
-     * the same time.
-     *
-     * @param array $columns list or associative array of columns and parameters to be bound with types
-     * @param array $types list or associative array of types
-     * @return array
-     */
-    public function matchTypes($columns, $types)
-    {
-        if (!is_int(key($types))) {
-            $positions = array_intersect_key(array_flip($columns), $types);
-            $types = array_intersect_key($types, $positions);
-            $types = array_combine($positions, $types);
-        }
-
-        return $types;
-    }
-}
diff --git a/src/Database/TypeFactory.php b/src/Database/TypeFactory.php
new file mode 100644
index 00000000000..6d1efb44499
--- /dev/null
+++ b/src/Database/TypeFactory.php
@@ -0,0 +1,185 @@
+>
+     */
+    protected static array $_types = [
+        'biginteger' => Type\IntegerType::class,
+        'binary' => Type\BinaryType::class,
+        'binaryuuid' => Type\BinaryUuidType::class,
+        'boolean' => Type\BoolType::class,
+        'char' => Type\StringType::class,
+        'cidr' => Type\StringType::class,
+        'citext' => Type\StringType::class,
+        'date' => Type\DateType::class,
+        'datetime' => Type\DateTimeType::class,
+        'datetimefractional' => Type\DateTimeFractionalType::class,
+        'decimal' => Type\DecimalType::class,
+        'float' => Type\FloatType::class,
+        'geometry' => Type\StringType::class,
+        'integer' => Type\IntegerType::class,
+        'inet' => Type\StringType::class,
+        'json' => Type\JsonType::class,
+        'linestring' => Type\StringType::class,
+        'macaddr' => Type\StringType::class,
+        'nativeuuid' => Type\UuidType::class,
+        'point' => Type\StringType::class,
+        'polygon' => Type\StringType::class,
+        'smallinteger' => Type\IntegerType::class,
+        'string' => Type\StringType::class,
+        'text' => Type\StringType::class,
+        'time' => Type\TimeType::class,
+        'timestamp' => Type\DateTimeType::class,
+        'timestampfractional' => Type\DateTimeFractionalType::class,
+        'timestamptimezone' => Type\DateTimeTimezoneType::class,
+        'tinyinteger' => Type\IntegerType::class,
+        'uuid' => Type\UuidType::class,
+        'year' => Type\IntegerType::class,
+    ];
+
+    /**
+     * Contains a map of type object instances to be reused if needed.
+     *
+     * @var array<\Cake\Database\TypeInterface>
+     */
+    protected static array $_builtTypes = [];
+
+    /**
+     * Returns a Type object capable of converting a type identified by name.
+     *
+     * @param string $name type identifier
+     * @return \Cake\Database\TypeInterface
+     */
+    public static function build(string $name): TypeInterface
+    {
+        if (isset(static::$_builtTypes[$name])) {
+            return static::$_builtTypes[$name];
+        }
+        if (!isset(static::$_types[$name])) {
+            return static::$_builtTypes[$name] = new static::$_types['string']($name);
+        }
+
+        return static::$_builtTypes[$name] = new static::$_types[$name]($name);
+    }
+
+    /**
+     * Returns an arrays with all the mapped type objects, indexed by name.
+     *
+     * @return array<\Cake\Database\TypeInterface>
+     */
+    public static function buildAll(): array
+    {
+        foreach (static::$_types as $name => $type) {
+            static::$_builtTypes[$name] ??= static::build($name);
+        }
+
+        return static::$_builtTypes;
+    }
+
+    /**
+     * Set TypeInterface instance capable of converting a type identified by $name
+     *
+     * @param string $name The type identifier you want to set.
+     * @param \Cake\Database\TypeInterface $instance The type instance you want to set.
+     * @return void
+     */
+    public static function set(string $name, TypeInterface $instance): void
+    {
+        static::$_builtTypes[$name] = $instance;
+    }
+
+    /**
+     * Registers a new type identifier and maps it to a fully namespaced classname.
+     *
+     * @param string $type Name of type to map.
+     * @param class-string<\Cake\Database\TypeInterface> $className The classname to register.
+     * @return void
+     */
+    public static function map(string $type, string $className): void
+    {
+        static::$_types[$type] = $className;
+        unset(static::$_builtTypes[$type]);
+    }
+
+    /**
+     * Set type to classname mapping.
+     *
+     * @param array> $map List of types to be mapped.
+     * @return void
+     */
+    public static function setMap(array $map): void
+    {
+        static::$_types = $map;
+        static::$_builtTypes = [];
+    }
+
+    /**
+     * Get the type mapping array.
+     *
+     * Deprecated 5.3.0: Argument $type has been deprecated.
+     * Use getMap() without arguments to get the full map, or getMapped($type) to get a specific type mapping.
+     *
+     * @param string|null $type Type name to get mapped class for or null to get map array.
+     * @return array>|string|null Configured class name for given $type or map array.
+     */
+    public static function getMap(?string $type = null): array|string|null
+    {
+        if ($type === null) {
+            return static::$_types;
+        }
+
+        trigger_error(
+            'Calling getMap() with a type argument is deprecated. Use getMapped() instead.',
+            E_USER_DEPRECATED,
+        );
+
+        return static::$_types[$type] ?? null;
+    }
+
+    /**
+     * Get mapped class name for a specific type.
+     *
+     * @param string $type Type name to get mapped class for.
+     * @return class-string<\Cake\Database\TypeInterface>|null Configured class name for given $type or null if not found.
+     */
+    public static function getMapped(string $type): ?string
+    {
+        return static::$_types[$type] ?? null;
+    }
+
+    /**
+     * Clears out all created instances and mapped types classes, useful for testing
+     *
+     * @return void
+     */
+    public static function clear(): void
+    {
+        static::$_types = [];
+        static::$_builtTypes = [];
+    }
+}
diff --git a/src/Database/TypeInterface.php b/src/Database/TypeInterface.php
index 82e17f47770..3789452707c 100644
--- a/src/Database/TypeInterface.php
+++ b/src/Database/TypeInterface.php
@@ -1,4 +1,6 @@
 
      */
-    protected $_defaults;
+    protected array $_defaults = [];
 
     /**
-     * Associative array with the fields and the related types that override defaults this query might contain
+     * Array with the fields and the related types that override defaults this query might contain
      *
      * Used to avoid repetition when calling multiple functions inside this class that
      * may require a custom type for a specific field.
      *
-     * @var array
+     * @var array
      */
-    protected $_types = [];
+    protected array $_types = [];
 
     /**
      * Creates an instance with the given defaults
      *
-     * @param array $defaults The defaults to use.
+     * @param array $defaults The defaults to use.
      */
     public function __construct(array $defaults = [])
     {
@@ -51,10 +52,13 @@ public function __construct(array $defaults = [])
     }
 
     /**
-     * Configures a map of default fields and their associated types to be
-     * used as the default list of types for every function in this class
-     * with a $types param. Useful to avoid repetition when calling the same
-     * functions using the same fields and types.
+     * Configures a map of fields and associated type.
+     *
+     * These values will be used as the default mapping of types for every function
+     * in this instance that supports a `$types` param.
+     *
+     * This method is useful when you want to avoid repeating type definitions
+     * as setting types overwrites the last set of types.
      *
      * ### Example
      *
@@ -62,9 +66,10 @@ public function __construct(array $defaults = [])
      * $query->setDefaults(['created' => 'datetime', 'is_visible' => 'boolean']);
      * ```
      *
-     * This method will replace all the existing type maps with the ones provided.
+     * This method will replace all the existing default mappings with the ones provided.
+     * To add into the mappings use `addDefaults()`.
      *
-     * @param array $defaults Associative array where keys are field names and values
+     * @param array $defaults Array where keys are field names / positions and values
      * are the correspondent type.
      * @return $this
      */
@@ -78,52 +83,22 @@ public function setDefaults(array $defaults)
     /**
      * Returns the currently configured types.
      *
-     * @return array
+     * @return array
      */
-    public function getDefaults()
+    public function getDefaults(): array
     {
         return $this->_defaults;
     }
 
-    /**
-     * Configures a map of default fields and their associated types to be
-     * used as the default list of types for every function in this class
-     * with a $types param. Useful to avoid repetition when calling the same
-     * functions using the same fields and types.
-     *
-     * If called with no arguments it will return the currently configured types.
-     *
-     * ### Example
-     *
-     * ```
-     * $query->defaults(['created' => 'datetime', 'is_visible' => 'boolean']);
-     * ```
-     *
-     * This method will replace all the existing type maps with the ones provided.
-     *
-     * @deprecated 3.4.0 Use setDefaults()/getDefaults() instead.
-     * @param array|null $defaults associative array where keys are field names and values
-     * are the correspondent type.
-     * @return $this|array
-     */
-    public function defaults(array $defaults = null)
-    {
-        if ($defaults !== null) {
-            return $this->setDefaults($defaults);
-        }
-
-        return $this->getDefaults();
-    }
-
     /**
      * Add additional default types into the type map.
      *
      * If a key already exists it will not be overwritten.
      *
-     * @param array $types The additional types to add.
+     * @param array $types The additional types to add.
      * @return void
      */
-    public function addDefaults(array $types)
+    public function addDefaults(array $types): void
     {
         $this->_defaults += $types;
     }
@@ -139,7 +114,7 @@ public function addDefaults(array $types)
      *
      * This method will replace all the existing type maps with the ones provided.
      *
-     * @param array $types Associative array where keys are field names and values
+     * @param array $types Array where keys are field names / positions and values
      * are the correspondent type.
      * @return $this
      */
@@ -153,66 +128,32 @@ public function setTypes(array $types)
     /**
      * Gets a map of fields and their associated types for single-use.
      *
-     * @return array
+     * @return array
      */
-    public function getTypes()
+    public function getTypes(): array
     {
         return $this->_types;
     }
 
-    /**
-     * Sets a map of fields and their associated types for single-use.
-     *
-     * If called with no arguments it will return the currently configured types.
-     *
-     * ### Example
-     *
-     * ```
-     * $query->types(['created' => 'time']);
-     * ```
-     *
-     * This method will replace all the existing type maps with the ones provided.
-     *
-     * @deprecated 3.4.0 Use setTypes()/getTypes() instead.
-     * @param array|null $types associative array where keys are field names and values
-     * are the correspondent type.
-     * @return $this|array
-     */
-    public function types(array $types = null)
-    {
-        if ($types !== null) {
-            return $this->setTypes($types);
-        }
-
-        return $this->getTypes();
-    }
-
     /**
      * Returns the type of the given column. If there is no single use type is configured,
      * the column type will be looked for inside the default mapping. If neither exist,
      * null will be returned.
      *
-     * @param string $column The type for a given column
-     * @return null|string
+     * @param string|int $column The type for a given column
+     * @return string|null
      */
-    public function type($column)
+    public function type(string|int $column): ?string
     {
-        if (isset($this->_types[$column])) {
-            return $this->_types[$column];
-        }
-        if (isset($this->_defaults[$column])) {
-            return $this->_defaults[$column];
-        }
-
-        return null;
+        return $this->_types[$column] ?? $this->_defaults[$column] ?? null;
     }
 
     /**
      * Returns an array of all types mapped types
      *
-     * @return array
+     * @return array
      */
-    public function toArray()
+    public function toArray(): array
     {
         return $this->_types + $this->_defaults;
     }
diff --git a/src/Database/TypeMapTrait.php b/src/Database/TypeMapTrait.php
index 990308197a4..ac0d8d941aa 100644
--- a/src/Database/TypeMapTrait.php
+++ b/src/Database/TypeMapTrait.php
@@ -1,4 +1,6 @@
  $typeMap Creates a TypeMap if array, otherwise sets the given TypeMap
      * @return $this
      */
-    public function setTypeMap($typeMap)
+    public function setTypeMap(TypeMap|array $typeMap)
     {
         $this->_typeMap = is_array($typeMap) ? new TypeMap($typeMap) : $typeMap;
 
@@ -43,37 +47,24 @@ public function setTypeMap($typeMap)
      *
      * @return \Cake\Database\TypeMap
      */
-    public function getTypeMap()
+    public function getTypeMap(): TypeMap
     {
-        if ($this->_typeMap === null) {
-            $this->_typeMap = new TypeMap();
-        }
-
-        return $this->_typeMap;
+        return $this->_typeMap ??= new TypeMap();
     }
 
     /**
-     * Creates a new TypeMap if $typeMap is an array, otherwise returns the existing type map
-     * or exchanges it for the given one.
+     * Overwrite the default type mappings for fields
+     * in the implementing object.
      *
-     * @deprecated 3.4.0 Use setTypeMap()/getTypeMap() instead.
-     * @param array|\Cake\Database\TypeMap|null $typeMap Creates a TypeMap if array, otherwise sets the given TypeMap
-     * @return $this|\Cake\Database\TypeMap
-     */
-    public function typeMap($typeMap = null)
-    {
-        if ($typeMap !== null) {
-            return $this->setTypeMap($typeMap);
-        }
-
-        return $this->getTypeMap();
-    }
-
-    /**
-     * Allows setting default types when chaining query.
+     * This method is useful if you need to set type mappings that are shared across
+     * multiple functions/expressions in a query.
+     *
+     * To add a default without overwriting existing ones
+     * use `getTypeMap()->addDefaults()`
      *
-     * @param array $types The array of types to set.
+     * @param array $types The array of types to set.
      * @return $this
+     * @see \Cake\Database\TypeMap::setDefaults()
      */
     public function setDefaultTypes(array $types)
     {
@@ -85,26 +76,10 @@ public function setDefaultTypes(array $types)
     /**
      * Gets default types of current type map.
      *
-     * @return array
+     * @return array
      */
-    public function getDefaultTypes()
+    public function getDefaultTypes(): array
     {
         return $this->getTypeMap()->getDefaults();
     }
-
-    /**
-     * Allows setting default types when chaining query
-     *
-     * @deprecated 3.4.0 Use setDefaultTypes()/getDefaultTypes() instead.
-     * @param array|null $types The array of types to set.
-     * @return $this|array
-     */
-    public function defaultTypes(array $types = null)
-    {
-        if ($types !== null) {
-            return $this->setDefaultTypes($types);
-        }
-
-        return $this->getDefaultTypes();
-    }
 }
diff --git a/src/Database/TypedResultInterface.php b/src/Database/TypedResultInterface.php
index b77d46385ab..434ff08ca3b 100644
--- a/src/Database/TypedResultInterface.php
+++ b/src/Database/TypedResultInterface.php
@@ -1,4 +1,6 @@
 _returnType;
     }
@@ -43,29 +44,10 @@ public function getReturnType()
      * @param string $type The name of the type that is to be returned
      * @return $this
      */
-    public function setReturnType($type)
+    public function setReturnType(string $type)
     {
         $this->_returnType = $type;
 
         return $this;
     }
-
-    /**
-     * Sets the type of the value this object will generate.
-     * If called without arguments, returns the current known type
-     *
-     * @deprecated 3.5.0 Use getReturnType()/setReturnType() instead.
-     * @param string|null $type The name of the type that is to be returned
-     * @return string|$this
-     */
-    public function returnType($type = null)
-    {
-        if ($type !== null) {
-            $this->_returnType = $type;
-
-            return $this;
-        }
-
-        return $this->_returnType;
-    }
 }
diff --git a/src/Database/ValueBinder.php b/src/Database/ValueBinder.php
index a13838d461c..4e152647a6e 100644
--- a/src/Database/ValueBinder.php
+++ b/src/Database/ValueBinder.php
@@ -1,4 +1,6 @@
 _bindings[$param] = compact('value', 'type') + [
-            'placeholder' => is_int($param) ? $param : substr($param, 1)
+            'placeholder' => is_int($param) ? $param : substr($param, 1),
         ];
     }
 
@@ -64,11 +65,11 @@ public function bind($param, $value, $type = 'string')
      * if it starts with a colon, then the same string is returned
      * @return string to be used as a placeholder in a query expression
      */
-    public function placeholder($token)
+    public function placeholder(string $token): string
     {
         $number = $this->_bindingsCount++;
-        if ($token[0] !== ':' && $token !== '?') {
-            $token = sprintf(':%s%s', $token, $number);
+        if (!str_starts_with($token, ':') && $token !== '?') {
+            return sprintf(':%s%s', $token, $number);
         }
 
         return $token;
@@ -78,11 +79,11 @@ public function placeholder($token)
      * Creates unique named placeholders for each of the passed values
      * and binds them with the specified type.
      *
-     * @param array|\Traversable $values The list of values to be bound
-     * @param string $type The type with which all values will be bound
+     * @param iterable $values The list of values to be bound
+     * @param string|int|null $type The type with which all values will be bound
      * @return array with the placeholders to insert in the query
      */
-    public function generateManyNamed($values, $type = 'string')
+    public function generateManyNamed(iterable $values, string|int|null $type = null): array
     {
         $placeholders = [];
         foreach ($values as $k => $value) {
@@ -104,7 +105,7 @@ public function generateManyNamed($values, $type = 'string')
      *
      * @return array
      */
-    public function bindings()
+    public function bindings(): array
     {
         return $this->_bindings;
     }
@@ -114,7 +115,7 @@ public function bindings()
      *
      * @return void
      */
-    public function reset()
+    public function reset(): void
     {
         $this->_bindings = [];
         $this->_bindingsCount = 0;
@@ -125,7 +126,7 @@ public function reset()
      *
      * @return void
      */
-    public function resetCount()
+    public function resetCount(): void
     {
         $this->_bindingsCount = 0;
     }
@@ -136,10 +137,10 @@ public function resetCount()
      * @param \Cake\Database\StatementInterface $statement The statement to add parameters to.
      * @return void
      */
-    public function attachTo($statement)
+    public function attachTo(StatementInterface $statement): void
     {
         $bindings = $this->bindings();
-        if (empty($bindings)) {
+        if (!$bindings) {
             return;
         }
 
@@ -147,4 +148,16 @@ public function attachTo($statement)
             $statement->bindValue($b['placeholder'], $b['value'], $b['type']);
         }
     }
+
+    /**
+     * Get verbose debugging data.
+     *
+     * @return array
+     */
+    public function __debugInfo(): array
+    {
+        return [
+            'bindings' => $this->bindings(),
+        ];
+    }
 }
diff --git a/src/Database/composer.json b/src/Database/composer.json
index 37bc777ea74..8554cf3a9bd 100644
--- a/src/Database/composer.json
+++ b/src/Database/composer.json
@@ -24,17 +24,33 @@
         "source": "https://github.com/cakephp/database"
     },
     "require": {
-        "php": ">=5.6.0",
-        "cakephp/cache": "^3.0.0",
-        "cakephp/core": "^3.0.0",
-        "cakephp/datasource": "^3.0.0"
+        "php": ">=8.2",
+        "cakephp/core": "^5.4.0",
+        "cakephp/chronos": "^3.3",
+        "cakephp/datasource": "^5.4.0",
+        "cakephp/event": "^5.4.0",
+        "psr/log": "^3.0"
     },
-    "suggest": {
-        "cakephp/log": "Require this if you want to use the built-in query logger"
+    "require-dev": {
+        "cakephp/i18n": "^5.4.0",
+        "cakephp/log": "^5.4.0",
+        "cakephp/utility": "^5.4.0"
     },
     "autoload": {
         "psr-4": {
             "Cake\\Database\\": "."
         }
+    },
+    "suggest": {
+        "cakephp/i18n": "If you are using locale-aware datetime formats.",
+        "cakephp/log": "If you want to use query logging without providing a logger yourself.",
+        "cakephp/utility": "If you want to use EnumLabelTrait."
+    },
+    "minimum-stability": "dev",
+    "prefer-stable": true,
+    "extra": {
+        "branch-alias": {
+            "dev-5.next": "5.5.x-dev"
+        }
     }
 }
diff --git a/src/Database/phpstan.neon.dist b/src/Database/phpstan.neon.dist
new file mode 100644
index 00000000000..994bfbd471e
--- /dev/null
+++ b/src/Database/phpstan.neon.dist
@@ -0,0 +1,22 @@
+parameters:
+	level: 8
+	treatPhpDocTypesAsCertain: false
+	bootstrapFiles:
+		- tests/phpstan-bootstrap.php
+	paths:
+		- ./
+	excludePaths:
+		- vendor/
+	ignoreErrors:
+		-
+			identifier: missingType.iterableValue
+		-
+			identifier: trait.unused
+
+		-	'#Unsafe usage of new static\(\).#'
+
+		-
+			message: '#^Dead catch \- InvalidArgumentException is never thrown in the try block\.$#'
+			identifier: catch.neverThrown
+			path: Type/DateTimeType.php
+			reportUnmatched: false
diff --git a/src/Database/tests/phpstan-bootstrap.php b/src/Database/tests/phpstan-bootstrap.php
new file mode 100644
index 00000000000..0e60e7fbe4e
--- /dev/null
+++ b/src/Database/tests/phpstan-bootstrap.php
@@ -0,0 +1,60 @@
+ 'App',
+    'encoding' => 'UTF-8',
+]);
+
+ini_set('intl.default_locale', 'en_US');
+ini_set('session.gc_divisor', '1');
+ini_set('assert.exception', '1');
diff --git a/src/Datasource/.gitattributes b/src/Datasource/.gitattributes
new file mode 100644
index 00000000000..0086560d10e
--- /dev/null
+++ b/src/Datasource/.gitattributes
@@ -0,0 +1,10 @@
+# Define the line ending behavior of the different file extensions
+# Set default behavior, in case users don't have core.autocrlf set.
+* text text=auto eol=lf
+
+.php diff=php
+
+# Remove files for archives generated using `git archive`
+.gitattributes export-ignore
+phpstan.neon.dist export-ignore
+tests/ export-ignore
diff --git a/src/Datasource/ConnectionInterface.php b/src/Datasource/ConnectionInterface.php
index 44db87bffe8..2c1fb6b24c0 100644
--- a/src/Datasource/ConnectionInterface.php
+++ b/src/Datasource/ConnectionInterface.php
@@ -1,4 +1,6 @@
 
      */
-    public function logger($instance = null);
+    public function config(): array;
 }
diff --git a/src/Datasource/ConnectionManager.php b/src/Datasource/ConnectionManager.php
index b9beb80f366..114fbb89f1e 100644
--- a/src/Datasource/ConnectionManager.php
+++ b/src/Datasource/ConnectionManager.php
@@ -1,4 +1,6 @@
 
      */
-    protected static $_aliasMap = [];
+    protected static array $_aliasMap = [];
 
     /**
      * An array mapping url schemes to fully qualified driver class names
      *
-     * @return array
+     * @var array
      */
-    protected static $_dsnClassMap = [
-        'mysql' => 'Cake\Database\Driver\Mysql',
-        'postgres' => 'Cake\Database\Driver\Postgres',
-        'sqlite' => 'Cake\Database\Driver\Sqlite',
-        'sqlserver' => 'Cake\Database\Driver\Sqlserver',
+    protected static array $_dsnClassMap = [
+        'mysql' => Mysql::class,
+        'postgres' => Postgres::class,
+        'sqlite' => Sqlite::class,
+        'sqlserver' => Sqlserver::class,
     ];
 
     /**
@@ -58,20 +65,20 @@ class ConnectionManager
      *
      * @var \Cake\Datasource\ConnectionRegistry
      */
-    protected static $_registry;
+    protected static ConnectionRegistry $_registry;
 
     /**
      * Configure a new connection object.
      *
      * The connection will not be constructed until it is first used.
      *
-     * @param string|array $key The name of the connection config, or an array of multiple configs.
-     * @param array|null $config An array of name => config data for adapter.
+     * @param array|string $key The name of the connection config, or an array of multiple configs.
+     * @param \Cake\Datasource\ConnectionInterface|\Closure|array|null $config An array of name => config data for adapter.
      * @return void
-     * @throws \Cake\Core\Exception\Exception When trying to modify an existing config.
-     * @see \Cake\Core\StaticConfigTrait::config()
+     * @throws \Cake\Core\Exception\CakeException When trying to modify an existing config.
+     * @see \Cake\Core\StaticConfigTrait::setConfig()
      */
-    public static function setConfig($key, $config = null)
+    public static function setConfig(array|string $key, ConnectionInterface|Closure|array|null $config = null): void
     {
         if (is_array($config)) {
             $config['name'] = $key;
@@ -102,20 +109,20 @@ public static function setConfig($key, $config = null)
      *
      * Note that query-string arguments are also parsed and set as values in the returned configuration.
      *
-     * @param string|null $config The DSN string to convert to a configuration array
-     * @return array The configuration array to be stored after parsing the DSN
+     * @param string $dsn The DSN string to convert to a configuration array
+     * @return array The configuration array to be stored after parsing the DSN
      */
-    public static function parseDsn($config = null)
+    public static function parseDsn(string $dsn): array
     {
-        $config = static::_parseDsn($config);
+        $config = static::_parseDsn($dsn);
 
-        if (isset($config['path']) && empty($config['database'])) {
+        if (isset($config['path']) && empty($config['database']) && is_string($config['path'])) {
             $config['database'] = substr($config['path'], 1);
         }
 
         if (empty($config['driver'])) {
-            $config['driver'] = $config['className'];
-            $config['className'] = 'Cake\Database\Connection';
+            $config['driver'] = $config['className'] ?? null;
+            $config['className'] = Connection::class;
         }
 
         unset($config['path']);
@@ -143,20 +150,13 @@ public static function parseDsn($config = null)
      * ConnectionManager::alias('test_things', 'things');
      * ```
      *
-     * @param string $alias The alias to add. Fetching $source will return $alias when loaded with get.
-     * @param string $source The connection to add an alias to.
+     * @param string $source The existing connection to alias.
+     * @param string $alias The alias name that resolves to `$source`.
      * @return void
-     * @throws \Cake\Datasource\Exception\MissingDatasourceConfigException When aliasing a
-     * connection that does not exist.
      */
-    public static function alias($alias, $source)
+    public static function alias(string $source, string $alias): void
     {
-        if (empty(static::$_config[$source]) && empty(static::$_config[$alias])) {
-            throw new MissingDatasourceConfigException(
-                sprintf('Cannot create alias of "%s" as it does not exist.', $alias)
-            );
-        }
-        static::$_aliasMap[$source] = $alias;
+        static::$_aliasMap[$alias] = $source;
     }
 
     /**
@@ -165,12 +165,22 @@ public static function alias($alias, $source)
      * Removes an alias from ConnectionManager. Fetching the aliased
      * connection may fail if there is no other connection with that name.
      *
-     * @param string $name The connection name to remove aliases for.
+     * @param string $alias The connection alias to drop
      * @return void
      */
-    public static function dropAlias($name)
+    public static function dropAlias(string $alias): void
+    {
+        unset(static::$_aliasMap[$alias]);
+    }
+
+    /**
+     * Returns the current connection aliases and what they alias.
+     *
+     * @return array
+     */
+    public static function aliases(): array
     {
-        unset(static::$_aliasMap[$name]);
+        return static::$_aliasMap;
     }
 
     /**
@@ -182,26 +192,22 @@ public static function dropAlias($name)
      * as second parameter.
      *
      * @param string $name The connection name.
-     * @param bool $useAliases Set to false to not use aliased connections.
-     * @return \Cake\Datasource\ConnectionInterface A connection object.
+     * @param bool $useAliases Whether connection aliases are used
+     * @return \Cake\Datasource\ConnectionInterface
      * @throws \Cake\Datasource\Exception\MissingDatasourceConfigException When config
      * data is missing.
      */
-    public static function get($name, $useAliases = true)
+    public static function get(string $name, bool $useAliases = true): ConnectionInterface
     {
         if ($useAliases && isset(static::$_aliasMap[$name])) {
             $name = static::$_aliasMap[$name];
         }
-        if (empty(static::$_config[$name])) {
+
+        if (!isset(static::$_config[$name])) {
             throw new MissingDatasourceConfigException(['name' => $name]);
         }
-        if (empty(static::$_registry)) {
-            static::$_registry = new ConnectionRegistry();
-        }
-        if (isset(static::$_registry->{$name})) {
-            return static::$_registry->{$name};
-        }
+        static::$_registry ??= new ConnectionRegistry();
 
-        return static::$_registry->load($name, static::$_config[$name]);
+        return static::$_registry->{$name} ?? static::$_registry->load($name, static::$_config[$name]);
     }
 }
diff --git a/src/Datasource/ConnectionRegistry.php b/src/Datasource/ConnectionRegistry.php
index ea9d434ddd7..b5455f3f547 100644
--- a/src/Datasource/ConnectionRegistry.php
+++ b/src/Datasource/ConnectionRegistry.php
@@ -1,4 +1,6 @@
 
  */
 class ConnectionRegistry extends ObjectRegistry
 {
-
     /**
      * Resolve a datasource classname.
      *
      * Part of the template method for Cake\Core\ObjectRegistry::load()
      *
      * @param string $class Partial classname to resolve.
-     * @return string|false Either the correct classname or false.
+     * @return class-string<\Cake\Datasource\ConnectionInterface>|null Either the correct class name or null.
      */
-    protected function _resolveClassName($class)
+    protected function _resolveClassName(string $class): ?string
     {
-        if (is_object($class)) {
-            return $class;
-        }
-
+        /** @var class-string<\Cake\Datasource\ConnectionInterface>|null */
         return App::className($class, 'Datasource');
     }
 
@@ -49,11 +49,11 @@ protected function _resolveClassName($class)
      * Part of the template method for Cake\Core\ObjectRegistry::load()
      *
      * @param string $class The classname that is missing.
-     * @param string $plugin The plugin the datasource is missing in.
+     * @param string|null $plugin The plugin the datasource is missing in.
      * @return void
      * @throws \Cake\Datasource\Exception\MissingDatasourceException
      */
-    protected function _throwMissingClassError($class, $plugin)
+    protected function _throwMissingClassError(string $class, ?string $plugin): void
     {
         throw new MissingDatasourceException([
             'class' => $class,
@@ -66,37 +66,39 @@ protected function _throwMissingClassError($class, $plugin)
      *
      * Part of the template method for Cake\Core\ObjectRegistry::load()
      *
-     * If a callable is passed as first argument, The returned value of this
-     * function will be the result of the callable.
+     * If a closure is passed as first argument, The returned value of this
+     * function will be the result from calling the closure.
      *
-     * @param string|object|callable $class The classname or object to make.
+     * @param \Cake\Datasource\ConnectionInterface|\Closure|class-string<\Cake\Datasource\ConnectionInterface> $class The classname or object to make.
      * @param string $alias The alias of the object.
-     * @param array $settings An array of settings to use for the datasource.
-     * @return object A connection with the correct settings.
+     * @param array $config An array of settings to use for the datasource.
+     * @return \Cake\Datasource\ConnectionInterface A connection with the correct settings.
      */
-    protected function _create($class, $alias, $settings)
+    protected function _create(object|string $class, string $alias, array $config): ConnectionInterface
     {
-        if (is_callable($class)) {
-            return $class($alias);
-        }
+        if (is_string($class)) {
+            unset($config['className']);
 
-        if (is_object($class)) {
-            return $class;
+            return new $class($config);
         }
 
-        unset($settings['className']);
+        if ($class instanceof Closure) {
+            return $class($alias);
+        }
 
-        return new $class($settings);
+        return $class;
     }
 
     /**
      * Remove a single adapter from the registry.
      *
      * @param string $name The adapter name.
-     * @return void
+     * @return $this
      */
-    public function unload($name)
+    public function unload(string $name)
     {
         unset($this->_loaded[$name]);
+
+        return $this;
     }
 }
diff --git a/src/Datasource/EntityInterface.php b/src/Datasource/EntityInterface.php
index d10832a8af3..8c3354e3639 100644
--- a/src/Datasource/EntityInterface.php
+++ b/src/Datasource/EntityInterface.php
@@ -1,4 +1,6 @@
 
+ * @method bool hasValue(string $field)
+ * @method static patch(array $values, array $options = [])
  */
-interface EntityInterface extends ArrayAccess, JsonSerializable
+interface EntityInterface extends ArrayAccess, JsonSerializable, Stringable
 {
+    /**
+     * Sets hidden fields.
+     *
+     * @param array $fields An array of fields to hide from array exports.
+     * @param bool $merge Merge the new fields with the existing. By default false.
+     * @return $this
+     */
+    public function setHidden(array $fields, bool $merge = false);
 
     /**
-     * Sets one or multiple properties to the specified value
+     * Gets the hidden fields.
      *
-     * @param string|array $property the name of property to set or a list of
-     * properties with their respective values
-     * @param mixed $value The value to set to the property or an array if the
-     * first argument is also an array, in which case will be treated as $options
-     * @param array $options options to be used for setting the property. Allowed option
-     * keys are `setter` and `guard`
-     * @return \Cake\Datasource\EntityInterface
+     * @return array
      */
-    public function set($property, $value = null, array $options = []);
+    public function getHidden(): array;
 
     /**
-     * Returns the value of a property by name
+     * Sets the virtual fields on this entity.
      *
-     * @param string $property the name of the property to retrieve
-     * @return mixed
+     * @param array $fields An array of fields to treat as virtual.
+     * @param bool $merge Merge the new fields with the existing. By default false.
+     * @return $this
      */
-    public function &get($property);
+    public function setVirtual(array $fields, bool $merge = false);
 
     /**
-     * Returns whether this entity contains a property named $property
-     * regardless of if it is empty.
+     * Gets the virtual fields on this entity.
      *
-     * @param string|array $property The property to check.
-     * @return bool
+     * @return array
      */
-    public function has($property);
+    public function getVirtual(): array;
 
     /**
-     * Removes a property or list of properties from this entity
+     * Returns whether a field is an original one.
+     * Original fields are those that an entity was instantiated with.
      *
-     * @param string|array $property The property to unset.
-     * @return \Cake\Datasource\EntityInterface
+     * @param string $name Name
+     * @return bool
      */
-    public function unsetProperty($property);
+    public function isOriginalField(string $name): bool;
 
     /**
-     * Get/Set the hidden properties on this entity.
+     * Returns an array of original fields.
+     * Original fields are those that an entity was initialized with.
      *
-     * If the properties argument is null, the currently hidden properties
-     * will be returned. Otherwise the hidden properties will be set.
+     * @return array
+     */
+    public function getOriginalFields(): array;
+
+    /**
+     * Sets the dirty status of a single field.
      *
-     * @param null|array $properties Either an array of properties to hide or null to get properties
-     * @return array|\Cake\Datasource\EntityInterface
+     * @param string $field the field to set or check status for
+     * @param bool $isDirty true means the field was changed, false means
+     * it was not changed. Default true.
+     * @return $this
      */
-    public function hiddenProperties($properties = null);
+    public function setDirty(string $field, bool $isDirty = true);
 
     /**
-     * Get/Set the virtual properties on this entity.
+     * Checks if the entity is dirty or if a single field of it is dirty.
      *
-     * If the properties argument is null, the currently virtual properties
-     * will be returned. Otherwise the virtual properties will be set.
+     * @param string|null $field The field to check the status for. Null for the whole entity.
+     * @return bool Whether the field was changed or not
+     */
+    public function isDirty(?string $field = null): bool;
+
+    /**
+     * Gets the dirty fields.
      *
-     * @param null|array $properties Either an array of properties to treat as virtual or null to get properties
-     * @return array|\Cake\Datasource\EntityInterface
+     * @return array
      */
-    public function virtualProperties($properties = null);
+    public function getDirty(): array;
 
     /**
-     * Get the list of visible properties.
+     * Returns whether this entity has errors.
      *
-     * @return array A list of properties that are 'visible' in all representations.
+     * @param bool $includeNested true will check nested entities for hasErrors()
+     * @return bool
      */
-    public function visibleProperties();
+    public function hasErrors(bool $includeNested = true): bool;
 
     /**
-     * Returns an array with all the visible properties set in this entity.
+     * Returns all validation errors.
      *
-     * *Note* hidden properties are not visible, and will not be output
-     * by toArray().
+     * @return array
+     */
+    public function getErrors(): array;
+
+    /**
+     * Returns validation errors of a field
      *
+     * @param string $field Field name to get the errors from
      * @return array
      */
-    public function toArray();
+    public function getError(string $field): array;
+
+    /**
+     * Sets error messages to the entity
+     *
+     * @param array $errors The array of errors to set.
+     * @param bool $overwrite Whether to overwrite pre-existing errors for $fields
+     * @return $this
+     */
+    public function setErrors(array $errors, bool $overwrite = false);
+
+    /**
+     * Sets errors for a single field
+     *
+     * @param string $field The field to get errors for, or the array of errors to set.
+     * @param array|string $errors The errors to be set for $field
+     * @param bool $overwrite Whether to overwrite pre-existing errors for $field
+     * @return $this
+     */
+    public function setError(string $field, array|string $errors, bool $overwrite = false);
+
+    /**
+     * Stores whether a field value can be changed or set in this entity.
+     *
+     * @param array|string $field single or list of fields to change its accessibility
+     * @param bool $set true marks the field as accessible, false will
+     * mark it as protected.
+     * @return $this
+     */
+    public function setAccess(array|string $field, bool $set);
+
+    /**
+     * Accessible configuration for this entity.
+     *
+     * @return array
+     */
+    public function getAccessible(): array;
+
+    /**
+     * Checks if a field is accessible
+     *
+     * @param string $field Field name to check
+     * @return bool
+     */
+    public function isAccessible(string $field): bool;
+
+    /**
+     * Sets the source alias
+     *
+     * @param string $alias the alias of the repository
+     * @return $this
+     */
+    public function setSource(string $alias);
+
+    /**
+     * Returns the alias of the repository from which this entity came from.
+     *
+     * @return string
+     */
+    public function getSource(): string;
+
+    /**
+     * Returns an array with the requested original fields
+     * stored in this entity, indexed by field name.
+     *
+     * @param array $fields List of fields to be returned
+     * @return array
+     */
+    public function extractOriginal(array $fields): array;
+
+    /**
+     * Returns an array with only the original fields
+     * stored in this entity, indexed by field name.
+     *
+     * @param array $fields List of fields to be returned
+     * @return array
+     */
+    public function extractOriginalChanged(array $fields): array;
+
+    /**
+     * Sets one or multiple fields to the specified value
+     *
+     * @param array|string $field the name of field to set or a list of
+     * fields with their respective values
+     * @param mixed $value The value to set to the field or an array if the
+     * first argument is also an array, in which case will be treated as $options
+     * @param array $options Options to be used for setting the field. Allowed option
+     * keys are `setter` and `guard`
+     * @return $this
+     */
+    public function set(array|string $field, mixed $value = null, array $options = []);
+
+    /**
+     * Returns the value of a field by name
+     *
+     * @param string $field the name of the field to retrieve
+     * @return mixed
+     */
+    public function &get(string $field): mixed;
+
+    /**
+     * Enable/disable field presence check when accessing a property.
+     *
+     * If enabled an exception will be thrown when trying to access a non-existent property.
+     *
+     * @param bool $value `true` to enable, `false` to disable.
+     */
+    public function requireFieldPresence(bool $value = true): void;
+
+    /**
+     * Returns whether a field has an original value
+     *
+     * @param string $field
+     * @return bool
+     */
+    public function hasOriginal(string $field): bool;
 
     /**
-     * Returns an array with the requested properties
-     * stored in this entity, indexed by property name
+     * Returns the original value of a field.
+     *
+     * @param string $field The name of the field.
+     * @param bool $allowFallback whether to allow falling back to the current field value if no original exists
+     * @return mixed
+     */
+    public function getOriginal(string $field, bool $allowFallback = true): mixed;
+
+    /**
+     * Gets all original values of the entity.
      *
-     * @param array $properties list of properties to be returned
-     * @param bool $onlyDirty Return the requested property only if it is dirty
      * @return array
      */
-    public function extract(array $properties, $onlyDirty = false);
+    public function getOriginalValues(): array;
 
     /**
-     * Sets the dirty status of a single property. If called with no second
-     * argument, it will return whether the property was modified or not
-     * after the object creation.
+     * Returns whether this entity contains a field named $field.
+     *
+     * The method will return `true` even when the field is set to `null`.
      *
-     * When called with no arguments it will return whether or not there are any
-     * dirty property in the entity
+     * @param array|string $field The field to check.
+     * @return bool
+     */
+    public function has(array|string $field): bool;
+
+    /**
+     * Removes a field or list of fields from this entity
      *
-     * @deprecated 3.4.0 Use setDirty() and isDirty() instead.
-     * @param string|null $property the field to set or check status for
-     * @param null|bool $isDirty true means the property was changed, false means
-     * it was not changed and null will make the function return current state
-     * for that property
-     * @return bool whether the property was changed or not
+     * @param array|string $field The field to unset.
+     * @return $this
      */
-    public function dirty($property = null, $isDirty = null);
+    public function unset(array|string $field);
+
+    /**
+     * Get the list of visible fields.
+     *
+     * @return array A list of fields that are 'visible' in all representations.
+     */
+    public function getVisible(): array;
+
+    /**
+     * Returns an array with all the visible fields set in this entity.
+     *
+     * *Note* hidden fields are not visible, and will not be output
+     * by toArray().
+     *
+     * @return array
+     */
+    public function toArray(): array;
+
+    /**
+     * Returns an array with the requested fields
+     * stored in this entity, indexed by field name
+     *
+     * @param array $fields list of fields to be returned
+     * @param bool $onlyDirty Return the requested field only if it is dirty
+     * @return array
+     */
+    public function extract(array $fields, bool $onlyDirty = false): array;
 
     /**
      * Sets the entire entity as clean, which means that it will appear as
-     * no properties being modified or added at all. This is an useful call
+     * no fields being modified or added at all. This is an useful call
      * for an initial object hydration
      *
      * @return void
      */
-    public function clean();
+    public function clean(): void;
 
     /**
-     * Returns whether or not this entity has already been persisted.
-     * This method can return null in the case there is no prior information on
-     * the status of this entity.
+     * Set the status of this entity.
      *
-     * If called with a boolean, this method will set the status of this instance.
-     * Using `true` means that the instance has not been persisted in the database, `false`
-     * that it already is.
+     * Using `true` means that the entity has not been persisted in the database,
+     * `false` indicates that the entity has been persisted.
      *
-     * @param bool|null $new Indicate whether or not this instance has been persisted.
-     * @return bool If it is known whether the entity was already persisted
-     * null otherwise
+     * @param bool $new Indicate whether this entity has been persisted.
+     * @return $this
      */
-    public function isNew($new = null);
+    public function setNew(bool $new);
 
     /**
-     * Sets the error messages for a field or a list of fields. When called
-     * without the second argument it returns the validation
-     * errors for the specified fields. If called with no arguments it returns
-     * all the validation error messages stored in this entity.
-     *
-     * When used as a setter, this method will return this entity instance for method
-     * chaining.
+     * Returns whether this entity has already been persisted.
      *
-     * @deprecated 3.4.0 Use setErrors() and getErrors() instead.
-     * @param string|array|null $field The field to get errors for.
-     * @param string|array|null $errors The errors to be set for $field
-     * @param bool $overwrite Whether or not to overwrite pre-existing errors for $field
-     * @return array|\Cake\Datasource\EntityInterface
+     * @return bool Whether the entity has been persisted.
      */
-    public function errors($field = null, $errors = null, $overwrite = false);
+    public function isNew(): bool;
 
     /**
-     * Stores whether or not a property value can be changed or set in this entity.
-     * The special property `*` can also be marked as accessible or protected, meaning
-     * that any other property specified before will take its value. For example
-     * `$entity->accessible('*', true)` means that any property not specified already
-     * will be accessible by default.
+     * Returns a string representation of this object.
      *
-     * @deprecated 3.4.0 Use setAccess() and isAccessible() instead.
-     * @param string|array $property Either a single or list of properties to change its accessibility.
-     * @param bool|null $set true marks the property as accessible, false will
-     * mark it as protected.
-     * @return \Cake\Datasource\EntityInterface|bool
+     * @return string
+     * @deprecated 5.2.0 Casting an entity to string is deprecated. Use `json_encode()` instead to get a string representation of the entity.
      */
-    public function accessible($property, $set = null);
+    public function __toString(): string;
 }
diff --git a/src/Datasource/EntityTrait.php b/src/Datasource/EntityTrait.php
index c3aac51d8ac..b3fdc06cd60 100644
--- a/src/Datasource/EntityTrait.php
+++ b/src/Datasource/EntityTrait.php
@@ -1,4 +1,6 @@
 
      */
-    protected $_properties = [];
+    protected array $_fields = [];
 
     /**
-     * Holds all properties that have been changed and their original values for this entity
+     * Holds all fields that have been changed and their original values for this entity.
      *
-     * @var array
+     * @var array
      */
-    protected $_original = [];
+    protected array $_original = [];
 
     /**
-     * List of property names that should **not** be included in JSON or Array
-     * representations of this Entity.
+     * Holds all fields that have been initially set on instantiation, or after marking as clean
      *
-     * @var array
+     * @var array
      */
-    protected $_hidden = [];
+    protected array $_originalFields = [];
 
     /**
-     * List of computed or virtual fields that **should** be included in JSON or array
-     * representations of this Entity. If a field is present in both _hidden and _virtual
-     * the field will **not** be in the array/json versions of the entity.
+     * List of field names that should **not** be included in JSON or Array
+     * representations of this Entity.
      *
-     * @var array
+     * @var array
      */
-    protected $_virtual = [];
+    protected array $_hidden = [];
 
     /**
-     * Holds the name of the class for the instance object
-     *
-     * @var string
+     * List of computed or virtual fields that **should** be included in JSON or array
+     * representations of this Entity. If a field is present in both _hidden and _virtual
+     * the field will **not** be in the array/JSON versions of the entity.
      *
-     * @deprecated 3.2 This field is no longer being used
+     * @var array
      */
-    protected $_className;
+    protected array $_virtual = [];
 
     /**
-     * Holds a list of the properties that were modified or added after this object
+     * Holds a list of the fields that were modified or added after this object
      * was originally created.
      *
-     * @var array
+     * @var array
      */
-    protected $_dirty = [];
+    protected array $_dirty = [];
 
     /**
      * Holds a cached list of getters/setters per class
      *
-     * @var array
+     * @var array>>
      */
-    protected static $_accessors = [];
+    protected static array $_accessors = [];
 
     /**
-     * Indicates whether or not this entity is yet to be persisted.
+     * Indicates whether this entity is yet to be persisted.
      * Entities default to assuming they are new. You can use Table::persisted()
      * to set the new flag on an entity based on records in the database.
      *
      * @var bool
      */
-    protected $_new = true;
+    protected bool $_new = true;
 
     /**
-     * List of errors per field as stored in this object
+     * List of errors per field as stored in this object.
      *
-     * @var array
+     * @var array
      */
-    protected $_errors = [];
+    protected array $_errors = [];
 
     /**
-     * List of invalid fields and their data for errors upon validation/patching
+     * List of invalid fields and their data for errors upon validation/patching.
      *
-     * @var array
+     * @var array
      */
-    protected $_invalid = [];
+    protected array $_invalid = [];
 
     /**
-     * Map of properties in this entity that can be safely assigned, each
-     * property name points to a boolean indicating its status. An empty array
-     * means no properties are accessible
+     * Map of fields in this entity that can be safely mass assigned, each
+     * field name points to a boolean indicating its status. An empty array
+     * means no fields are accessible for mass assignment.
      *
-     * The special property '\*' can also be mapped, meaning that any other property
-     * not defined in the map will take its value. For example, `'\*' => true`
-     * means that any property not defined in the map will be accessible by default
+     * The special field '\*' can also be mapped, meaning that any other field
+     * not defined in the map will take its value. For example, `'*' => true`
+     * means that any field not defined in the map will be accessible for mass
+     * assignment by default.
      *
-     * @var array
+     * @var array
      */
-    protected $_accessible = ['*' => true];
+    protected array $_accessible = ['*' => true];
 
     /**
      * The alias of the repository this entity came from
      *
      * @var string
      */
-    protected $_registryAlias;
+    protected string $_registryAlias = '';
+
+    /**
+     * Storing the current visitation status while recursing through entities getting errors.
+     *
+     * @var bool
+     */
+    protected bool $_hasBeenVisited = false;
+
+    /**
+     * Whether the presence of a field is checked when accessing a property.
+     *
+     * If enabled an exception will be thrown when trying to access a non-existent property.
+     *
+     * @var bool
+     */
+    protected bool $requireFieldPresence = false;
 
     /**
-     * Magic getter to access properties that have been set in this entity
+     * Magic getter to access fields that have been set in this entity
      *
-     * @param string $property Name of the property to access
+     * @param string $field Name of the field to access
      * @return mixed
      */
-    public function &__get($property)
+    public function &__get(string $field): mixed
     {
-        return $this->get($property);
+        return $this->getRequiredOrFail($field, $this->requireFieldPresence);
     }
 
     /**
-     * Magic setter to add or edit a property in this entity
+     * Magic setter to add or edit a field in this entity
      *
-     * @param string $property The name of the property to set
-     * @param mixed $value The value to set to the property
+     * @param string $field The name of the field to set
+     * @param mixed $value The value to set to the field
      * @return void
      */
-    public function __set($property, $value)
+    public function __set(string $field, mixed $value): void
     {
-        $this->set($property, $value);
+        $this->set($field, $value);
     }
 
     /**
-     * Returns whether this entity contains a property named $property
-     * regardless of if it is empty.
+     * Returns whether this entity contains a field named $field
+     * and is not set to null.
      *
-     * @param string $property The property to check.
+     * @param string $field The field to check.
      * @return bool
-     * @see \Cake\ORM\Entity::has()
      */
-    public function __isset($property)
+    public function __isset(string $field): bool
     {
-        return $this->has($property);
+        return $this->has($field) && $this->get($field) !== null;
     }
 
     /**
-     * Removes a property from this entity
+     * Removes a field from this entity
      *
-     * @param string $property The property to unset
+     * @param string $field The field to unset
      * @return void
      */
-    public function __unset($property)
+    public function __unset(string $field): void
     {
-        $this->unsetProperty($property);
+        $this->unset($field);
     }
 
     /**
-     * Sets a single property inside this entity.
+     * Sets a single field inside this entity.
      *
      * ### Example:
      *
@@ -180,141 +199,283 @@ public function __unset($property)
      * $entity->set('name', 'Andrew');
      * ```
      *
-     * It is also possible to mass-assign multiple properties to this entity
-     * with one call by passing a hashed array as properties in the form of
-     * property => value pairs
+     * Some times it is handy to bypass setter functions in this entity when assigning
+     * fields. You can achieve this by disabling the `setter` option using the
+     * `$options` parameter:
+     *
+     * ```
+     * $entity->set('name', 'Andrew', ['setter' => false]);
+     * ```
+     *
+     * You can use the `asOriginal` option to set the given field as original, if it wasn't
+     * present when the entity was instantiated.
+     *
+     * ```
+     * $entity = new Entity(['name' => 'andrew', 'id' => 1]);
+     *
+     * $entity->set('phone_number', '555-0134');
+     * print_r($entity->getOriginalFields()) // prints ['name', 'id']
+     *
+     * $entity->set('phone_number', '555-0134', ['asOriginal' => true]);
+     * print_r($entity->getOriginalFields()) // prints ['name', 'id', 'phone_number']
+     * ```
+     *
+     * @param array|string $field The name of field to set.
+     * @param mixed $value The value to set to the field.
+     * @param array $options Options to be used for setting the field. Allowed option
+     * keys are `setter`, `guard` and `asOriginal`
+     * @return $this
+     * @throws \InvalidArgumentException when an empty field name is provided
+     */
+    public function set(array|string $field, mixed $value = null, array $options = [])
+    {
+        if (is_string($field)) {
+            $options += ['guard' => false];
+
+            return $this->patch([$field => $value], $options);
+        }
+
+        deprecationWarning(
+            '5.2.0',
+            sprintf(
+                'Passing an array as the first argument to `%s::set()` is deprecated. '
+                . 'Use `%s::patch()` instead.',
+                static::class,
+                static::class,
+            ),
+        );
+
+        return $this->patch($field, (array)$value);
+    }
+
+    /**
+     * Patch (mass-assign) multiple fields to this entity.
      *
      * ### Example:
      *
      * ```
-     * $entity->set(['name' => 'andrew', 'id' => 1]);
+     * $entity->patch(['name' => 'andrew', 'id' => 1]);
      * echo $entity->name // prints andrew
      * echo $entity->id // prints 1
      * ```
      *
      * Some times it is handy to bypass setter functions in this entity when assigning
-     * properties. You can achieve this by disabling the `setter` option using the
+     * fields. You can achieve this by disabling the `setter` option using the
      * `$options` parameter:
      *
      * ```
-     * $entity->set('name', 'Andrew', ['setter' => false]);
-     * $entity->set(['name' => 'Andrew', 'id' => 1], ['setter' => false]);
+     * $entity->patch(['name' => 'Andrew', 'id' => 1], ['setter' => false]);
      * ```
      *
      * Mass assignment should be treated carefully when accepting user input, by default
-     * entities will guard all fields when properties are assigned in bulk. You can disable
+     * entities will guard all fields when fields are assigned in bulk. You can disable
      * the guarding for a single set call with the `guard` option:
      *
      * ```
-     * $entity->set(['name' => 'Andrew', 'id' => 1], ['guard' => true]);
+     * $entity->patch(['name' => 'Andrew', 'id' => 1], ['guard' => false]);
      * ```
      *
-     * You do not need to use the guard option when assigning properties individually:
+     * You can use the `asOriginal` option to set the given field as original, if it wasn't
+     * present when the entity was instantiated.
      *
      * ```
-     * // No need to use the guard option.
-     * $entity->set('name', 'Andrew');
+     * $entity = new Entity(['name' => 'andrew', 'id' => 1]);
+     *
+     * $entity->patch(['phone_number' => '555-0134']);
+     * print_r($entity->getOriginalFields()) // prints ['name', 'id']
+     *
+     * $entity->patch(['phone_number' => '555-0134'], ['asOriginal' => true]);
+     * print_r($entity->getOriginalFields()) // prints ['name', 'id', 'phone_number']
      * ```
      *
-     * @param string|array $property the name of property to set or a list of
-     * properties with their respective values
-     * @param mixed $value The value to set to the property or an array if the
-     * first argument is also an array, in which case will be treated as $options
-     * @param array $options options to be used for setting the property. Allowed option
-     * keys are `setter` and `guard`
+     * @param array $values Map of fields with their respective values.
+     * @param array $options Options to be used for setting the field. Allowed option
+     * keys are `setter`, `guard` and `asOriginal`
      * @return $this
      * @throws \InvalidArgumentException
      */
-    public function set($property, $value = null, array $options = [])
+    public function patch(array $values, array $options = [])
     {
-        if (is_string($property) && $property !== '') {
-            $guard = false;
-            $property = [$property => $value];
-        } else {
-            $guard = true;
-            $options = (array)$value;
-        }
+        $options += ['setter' => true, 'guard' => true, 'asOriginal' => false];
 
-        if (!is_array($property)) {
-            throw new InvalidArgumentException('Cannot set an empty property');
+        if ($options['asOriginal'] === true) {
+            $this->setOriginalField(array_keys($values));
         }
-        $options += ['setter' => true, 'guard' => $guard];
 
-        foreach ($property as $p => $value) {
-            if ($options['guard'] === true && !$this->isAccessible($p)) {
-                continue;
+        foreach ($values as $name => $value) {
+            $name = (string)$name;
+            if ($name === '') {
+                throw new InvalidArgumentException('Cannot set an empty field');
             }
 
-            $this->setDirty($p, true);
-
-            if (!array_key_exists($p, $this->_original) &&
-                array_key_exists($p, $this->_properties) &&
-                $this->_properties[$p] !== $value
-            ) {
-                $this->_original[$p] = $this->_properties[$p];
+            if ($options['guard'] === true && !$this->isAccessible($name)) {
+                continue;
             }
 
-            if (!$options['setter']) {
-                $this->_properties[$p] = $value;
+            if ($options['asOriginal'] || $this->isModified($name, $value)) {
+                $this->setDirty($name, true);
+            } else {
                 continue;
             }
 
-            $setter = static::_accessor($p, 'set');
-            if ($setter) {
-                $value = $this->{$setter}($value);
+            if ($options['setter']) {
+                $setter = static::_accessor($name, 'set');
+                if ($setter) {
+                    $value = $this->{$setter}($value);
+                }
             }
-            $this->_properties[$p] = $value;
+
+            if (
+                $this->isOriginalField($name) &&
+                !array_key_exists($name, $this->_original) &&
+                array_key_exists($name, $this->_fields) &&
+                $value !== $this->_fields[$name]
+            ) {
+                $this->_original[$name] = $this->_fields[$name];
+            }
+
+            $this->_fields[$name] = $value;
         }
 
         return $this;
     }
 
     /**
-     * Returns the value of a property by name
+     * Check if the provided value is same as existing value for a field.
+     *
+     * This check is used to determine if a field should be set as dirty or not.
+     * It will return `false` for scalar values and objects which haven't changed.
+     * For arrays `true` will be returned always because the original/updated list
+     * could contain references to the same objects, even though those objects
+     * may have changed internally.
+     *
+     * @param string $field The field to check.
+     * @return bool
+     */
+    protected function isModified(string $field, mixed $value): bool
+    {
+        if (!array_key_exists($field, $this->_fields)) {
+            return true;
+        }
+
+        $existing = $this->_fields[$field] ?? null;
+
+        if (($value === null || is_scalar($value)) && $existing === $value) {
+            return false;
+        }
+
+        if (
+            is_object($value)
+            && is_object($existing)
+            && !($value instanceof EntityInterface)
+            && $existing == $value
+        ) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Returns the value of a field by name
      *
-     * @param string $property the name of the property to retrieve
+     * @param string $field the name of the field to retrieve
      * @return mixed
-     * @throws \InvalidArgumentException if an empty property name is passed
+     * @throws \InvalidArgumentException if an empty field name is passed
+     * @throws \Cake\Datasource\Exception\MissingPropertyException when field does not exist and requireFieldPresence is enabled
      */
-    public function &get($property)
+    public function &get(string $field): mixed
     {
-        if (!strlen((string)$property)) {
-            throw new InvalidArgumentException('Cannot get an empty property');
+        return $this->getRequiredOrFail($field, false);
+    }
+
+    /**
+     * Get field with option for requireFieldPresence.
+     *
+     * Note: The returned value might be null if the field is set to null.
+     *
+     * @param string $field the name of the field to retrieve
+     * @param bool $requireFieldPresence Whether to throw an exception if the field is not present
+     * @return mixed
+     * @throws \InvalidArgumentException if an empty field name is passed
+     * @throws \Cake\Datasource\Exception\MissingPropertyException If property does not exist and $requireFieldPresence
+     */
+    public function &getRequiredOrFail(string $field, bool $requireFieldPresence = true): mixed
+    {
+        if ($field === '') {
+            throw new InvalidArgumentException('Cannot get an empty field');
         }
 
         $value = null;
-        $method = static::_accessor($property, 'get');
-
-        if (isset($this->_properties[$property])) {
-            $value =& $this->_properties[$property];
+        $fieldIsPresent = false;
+        if (array_key_exists($field, $this->_fields)) {
+            $fieldIsPresent = true;
+            $value = &$this->_fields[$field];
         }
 
+        $method = static::_accessor($field, 'get');
         if ($method) {
+            // Must be variable before returning: Only variable references should be returned by reference.
             $result = $this->{$method}($value);
 
             return $result;
         }
 
+        if (!$fieldIsPresent && $requireFieldPresence) {
+            throw new MissingPropertyException([
+                'property' => $field,
+                'entity' => $this::class,
+            ]);
+        }
+
         return $value;
     }
 
     /**
-     * Returns the value of an original property by name
+     * Enable/disable field presence check when accessing a property.
+     *
+     * If enabled an exception will be thrown when trying to access a non-existent property.
      *
-     * @param string $property the name of the property for which original value is retrieved.
+     * @param bool $value `true` to enable, `false` to disable.
+     */
+    public function requireFieldPresence(bool $value = true): void
+    {
+        $this->requireFieldPresence = $value;
+    }
+
+    /**
+     * Returns whether a field has an original value
+     *
+     * @param string $field
+     * @return bool
+     */
+    public function hasOriginal(string $field): bool
+    {
+        return array_key_exists($field, $this->_original);
+    }
+
+    /**
+     * Returns the value of an original field by name
+     *
+     * @param string $field the name of the field for which original value is retrieved.
+     * @param bool $allowFallback whether to allow falling back to the current field value if no original exists
      * @return mixed
-     * @throws \InvalidArgumentException if an empty property name is passed.
+     * @throws \InvalidArgumentException if an empty field name is passed or if the field has no original value and $allowFallback is false
      */
-    public function getOriginal($property)
+    public function getOriginal(string $field, bool $allowFallback = true): mixed
     {
-        if (!strlen((string)$property)) {
-            throw new InvalidArgumentException('Cannot get an empty property');
+        if ($field === '') {
+            throw new InvalidArgumentException('Cannot get an empty field');
+        }
+        if (array_key_exists($field, $this->_original)) {
+            return $this->_original[$field];
         }
-        if (array_key_exists($property, $this->_original)) {
-            return $this->_original[$property];
+
+        if (!$allowFallback) {
+            throw new InvalidArgumentException(sprintf('Cannot retrieve original value for field `%s`', $field));
         }
 
-        return $this->get($property);
+        return $this->get($field);
     }
 
     /**
@@ -322,12 +483,15 @@ public function getOriginal($property)
      *
      * @return array
      */
-    public function getOriginalValues()
+    public function getOriginalValues(): array
     {
         $originals = $this->_original;
         $originalKeys = array_keys($originals);
-        foreach ($this->_properties as $key => $value) {
-            if (!in_array($key, $originalKeys)) {
+        foreach ($this->_fields as $key => $value) {
+            if (
+                !in_array($key, $originalKeys, true) &&
+                $this->isOriginalField($key)
+            ) {
                 $originals[$key] = $value;
             }
         }
@@ -336,36 +500,35 @@ public function getOriginalValues()
     }
 
     /**
-     * Returns whether this entity contains a property named $property
-     * that contains a non-null value.
+     * Returns whether this entity contains a field named $field.
+     *
+     * It will return `true` even for fields set to `null`.
      *
      * ### Example:
      *
      * ```
      * $entity = new Entity(['id' => 1, 'name' => null]);
      * $entity->has('id'); // true
-     * $entity->has('name'); // false
+     * $entity->has('name'); // true
      * $entity->has('last_name'); // false
      * ```
      *
-     * You can check multiple properties by passing an array:
+     * You can check multiple fields by passing an array:
      *
      * ```
      * $entity->has(['name', 'last_name']);
      * ```
      *
-     * All properties must not be null to get a truthy result.
+     * When checking multiple fields all fields must have a value (even `null`)
+     * present for the method to return `true`.
      *
-     * When checking multiple properties. All properties must not be null
-     * in order for true to be returned.
-     *
-     * @param string|array $property The property or properties to check.
+     * @param array|string $field The field or fields to check.
      * @return bool
      */
-    public function has($property)
+    public function has(array|string $field): bool
     {
-        foreach ((array)$property as $prop) {
-            if ($this->get($prop) === null) {
+        foreach ((array)$field as $prop) {
+            if (!array_key_exists($prop, $this->_fields) && !static::_accessor($prop, 'get')) {
                 return false;
             }
         }
@@ -374,173 +537,190 @@ public function has($property)
     }
 
     /**
-     * Removes a property or list of properties from this entity
+     * Checks that a field is empty
      *
-     * ### Examples:
+     * This is not working like the PHP `empty()` function. The method will
+     * return true for:
      *
-     * ```
-     * $entity->unsetProperty('name');
-     * $entity->unsetProperty(['name', 'last_name']);
-     * ```
+     * - `''` (empty string)
+     * - `null`
+     * - `[]`
      *
-     * @param string|array $property The property to unset.
-     * @return $this
+     * and false in all other cases.
+     *
+     * @param string $field The field to check.
+     * @return bool
+     * @deprecated 5.3.0 Use hasValue() instead.
      */
-    public function unsetProperty($property)
+    public function isEmpty(string $field): bool
     {
-        $property = (array)$property;
-        foreach ($property as $p) {
-            unset($this->_properties[$p], $this->_dirty[$p]);
+        deprecationWarning('5.3.0', 'isEmpty() is deprecated. Use hasValue() instead.');
+
+        return !$this->hasValue($field);
+    }
+
+    /**
+     * Checks that a field has a value.
+     *
+     * This method will return true for
+     *
+     * - Non-empty strings
+     * - Non-empty arrays
+     * - Any object
+     * - Integer, even `0`
+     * - Float, even 0.0
+     * - Boolean, both `true` and `false`
+     *
+     * and false in all other cases.
+     *
+     * @param string $field The field to check.
+     * @return bool
+     */
+    public function hasValue(string $field): bool
+    {
+        $value = $this->get($field);
+        if (
+            $value === null ||
+            (
+                $value === [] ||
+                $value === ''
+            )
+        ) {
+            return false;
         }
 
-        return $this;
+        return true;
     }
 
     /**
-     * Get/Set the hidden properties on this entity.
+     * Removes a field or list of fields from this entity
      *
-     * If the properties argument is null, the currently hidden properties
-     * will be returned. Otherwise the hidden properties will be set.
+     * ### Examples:
+     *
+     * ```
+     * $entity->unset('name');
+     * $entity->unset(['name', 'last_name']);
+     * ```
      *
-     * @deprecated 3.4.0 Use EntityTrait::setHidden() and EntityTrait::getHidden()
-     * @param null|array $properties Either an array of properties to hide or null to get properties
-     * @return array|$this
+     * @param array|string $field The field to unset.
+     * @return $this
      */
-    public function hiddenProperties($properties = null)
+    public function unset(array|string $field)
     {
-        if ($properties === null) {
-            return $this->_hidden;
+        $field = (array)$field;
+        foreach ($field as $p) {
+            unset($this->_fields[$p], $this->_dirty[$p]);
         }
-        $this->_hidden = $properties;
 
         return $this;
     }
 
     /**
-     * Sets hidden properties.
+     * Sets hidden fields.
      *
-     * @param array $properties An array of properties to hide from array exports.
-     * @param bool $merge Merge the new properties with the existing. By default false.
+     * @param array $fields An array of fields to hide from array exports.
+     * @param bool $merge Merge the new fields with the existing. By default false.
      * @return $this
      */
-    public function setHidden(array $properties, $merge = false)
+    public function setHidden(array $fields, bool $merge = false)
     {
         if ($merge === false) {
-            $this->_hidden = $properties;
+            $this->_hidden = $fields;
 
             return $this;
         }
 
-        $properties = array_merge($this->_hidden, $properties);
-        $this->_hidden = array_unique($properties);
+        $fields = array_merge($this->_hidden, $fields);
+        $this->_hidden = array_unique($fields);
 
         return $this;
     }
 
     /**
-     * Gets the hidden properties.
+     * Gets the hidden fields.
      *
-     * @return array
+     * @return array
      */
-    public function getHidden()
+    public function getHidden(): array
     {
         return $this->_hidden;
     }
 
     /**
-     * Get/Set the virtual properties on this entity.
+     * Sets the virtual fields on this entity.
      *
-     * If the properties argument is null, the currently virtual properties
-     * will be returned. Otherwise the virtual properties will be set.
-     *
-     * @deprecated 3.4.0 Use EntityTrait::getVirtual() and EntityTrait::setVirtual()
-     * @param null|array $properties Either an array of properties to treat as virtual or null to get properties
-     * @return array|$this
-     */
-    public function virtualProperties($properties = null)
-    {
-        if ($properties === null) {
-            return $this->getVirtual();
-        }
-
-        return $this->setVirtual($properties);
-    }
-
-    /**
-     * Sets the virtual properties on this entity.
-     *
-     * @param array $properties An array of properties to treat as virtual.
-     * @param bool $merge Merge the new properties with the existing. By default false.
+     * @param array $fields An array of fields to treat as virtual.
+     * @param bool $merge Merge the new fields with the existing. By default false.
      * @return $this
      */
-    public function setVirtual(array $properties, $merge = false)
+    public function setVirtual(array $fields, bool $merge = false)
     {
         if ($merge === false) {
-            $this->_virtual = $properties;
+            $this->_virtual = $fields;
 
             return $this;
         }
 
-        $properties = array_merge($this->_virtual, $properties);
-        $this->_virtual = array_unique($properties);
+        $fields = array_merge($this->_virtual, $fields);
+        $this->_virtual = array_unique($fields);
 
         return $this;
     }
 
     /**
-     * Gets the virtual properties on this entity.
+     * Gets the virtual fields on this entity.
      *
-     * @return array
+     * @return array
      */
-    public function getVirtual()
+    public function getVirtual(): array
     {
         return $this->_virtual;
     }
 
     /**
-     * Get the list of visible properties.
+     * Gets the list of visible fields.
      *
-     * The list of visible properties is all standard properties
-     * plus virtual properties minus hidden properties.
+     * The list of visible fields is all standard fields
+     * plus virtual fields minus hidden fields.
      *
-     * @return array A list of properties that are 'visible' in all
+     * @return array A list of fields that are 'visible' in all
      *     representations.
      */
-    public function visibleProperties()
+    public function getVisible(): array
     {
-        $properties = array_keys($this->_properties);
-        $properties = array_merge($properties, $this->_virtual);
+        $fields = array_keys($this->_fields);
+        $fields = array_merge($fields, $this->_virtual);
 
-        return array_diff($properties, $this->_hidden);
+        return array_diff($fields, $this->_hidden);
     }
 
     /**
-     * Returns an array with all the properties that have been set
+     * Returns an array with all the fields that have been set
      * to this entity
      *
-     * This method will recursively transform entities assigned to properties
+     * This method will recursively transform entities assigned to fields
      * into arrays as well.
      *
-     * @return array
+     * @return array
      */
-    public function toArray()
+    public function toArray(): array
     {
         $result = [];
-        foreach ($this->visibleProperties() as $property) {
-            $value = $this->get($property);
+        foreach ($this->getVisible() as $field) {
+            $value = $this->get($field);
             if (is_array($value)) {
-                $result[$property] = [];
+                $result[$field] = [];
                 foreach ($value as $k => $entity) {
                     if ($entity instanceof EntityInterface) {
-                        $result[$property][$k] = $entity->toArray();
+                        $result[$field][$k] = $entity->toArray();
                     } else {
-                        $result[$property][$k] = $entity;
+                        $result[$field][$k] = $entity;
                     }
                 }
             } elseif ($value instanceof EntityInterface) {
-                $result[$property] = $value->toArray();
+                $result[$field] = $value->toArray();
             } else {
-                $result[$property] = $value;
+                $result[$field] = $value;
             }
         }
 
@@ -548,33 +728,33 @@ public function toArray()
     }
 
     /**
-     * Returns the properties that will be serialized as JSON
+     * Returns the fields that will be serialized as JSON
      *
-     * @return array
+     * @return array
      */
-    public function jsonSerialize()
+    public function jsonSerialize(): array
     {
-        return $this->extract($this->visibleProperties());
+        return $this->extract($this->getVisible());
     }
 
     /**
      * Implements isset($entity);
      *
-     * @param mixed $offset The offset to check.
+     * @param string $offset The offset to check.
      * @return bool Success
      */
-    public function offsetExists($offset)
+    public function offsetExists(mixed $offset): bool
     {
-        return $this->has($offset);
+        return $this->__isset($offset);
     }
 
     /**
      * Implements $entity[$offset];
      *
-     * @param mixed $offset The offset to get.
+     * @param string $offset The offset to get.
      * @return mixed
      */
-    public function &offsetGet($offset)
+    public function &offsetGet(mixed $offset): mixed
     {
         return $this->get($offset);
     }
@@ -582,11 +762,11 @@ public function &offsetGet($offset)
     /**
      * Implements $entity[$offset] = $value;
      *
-     * @param mixed $offset The offset to set.
+     * @param string $offset The offset to set.
      * @param mixed $value The value to set.
      * @return void
      */
-    public function offsetSet($offset, $value)
+    public function offsetSet(mixed $offset, mixed $value): void
     {
         $this->set($offset, $value);
     }
@@ -594,12 +774,12 @@ public function offsetSet($offset, $value)
     /**
      * Implements unset($result[$offset]);
      *
-     * @param mixed $offset The offset to remove.
+     * @param string $offset The offset to remove.
      * @return void
      */
-    public function offsetUnset($offset)
+    public function offsetUnset(mixed $offset): void
     {
-        $this->unsetProperty($offset);
+        $this->unset($offset);
     }
 
     /**
@@ -610,7 +790,7 @@ public function offsetUnset($offset)
      * @param string $type the accessor type ('get' or 'set')
      * @return string method name or empty string (no method available)
      */
-    protected static function _accessor($property, $type)
+    protected static function _accessor(string $property, string $type): string
     {
         $class = static::class;
 
@@ -618,17 +798,17 @@ protected static function _accessor($property, $type)
             return static::$_accessors[$class][$type][$property];
         }
 
-        if (!empty(static::$_accessors[$class])) {
+        if (isset(static::$_accessors[$class])) {
             return static::$_accessors[$class][$type][$property] = '';
         }
 
-        if ($class === 'Cake\ORM\Entity') {
+        if (static::class === self::class) {
             return '';
         }
 
         foreach (get_class_methods($class) as $method) {
             $prefix = substr($method, 1, 3);
-            if ($method[0] !== '_' || ($prefix !== 'get' && $prefix !== 'set')) {
+            if (!str_starts_with($method, '_') || ($prefix !== 'get' && $prefix !== 'set')) {
                 continue;
             }
             $field = lcfirst(substr($method, 4));
@@ -647,19 +827,19 @@ protected static function _accessor($property, $type)
     }
 
     /**
-     * Returns an array with the requested properties
-     * stored in this entity, indexed by property name
+     * Returns an array with the requested fields
+     * stored in this entity, indexed by field name
      *
-     * @param array $properties list of properties to be returned
-     * @param bool $onlyDirty Return the requested property only if it is dirty
-     * @return array
+     * @param array $fields list of fields to be returned
+     * @param bool $onlyDirty Return the requested field only if it is dirty
+     * @return array
      */
-    public function extract(array $properties, $onlyDirty = false)
+    public function extract(array $fields, bool $onlyDirty = false): array
     {
         $result = [];
-        foreach ($properties as $property) {
-            if (!$onlyDirty || $this->isDirty($property)) {
-                $result[$property] = $this->get($property);
+        foreach ($fields as $field) {
+            if (!$onlyDirty || $this->isDirty($field)) {
+                $result[$field] = $this->has($field) ? $this->get($field) : null;
             }
         }
 
@@ -667,42 +847,50 @@ public function extract(array $properties, $onlyDirty = false)
     }
 
     /**
-     * Returns an array with the requested original properties
-     * stored in this entity, indexed by property name.
+     * Returns an array with the requested original fields
+     * stored in this entity, indexed by field name, if they exist.
      *
-     * Properties that are unchanged from their original value will be included in the
+     * Fields that are unchanged from their original value will be included in the
      * return of this method.
      *
-     * @param array $properties List of properties to be returned
-     * @return array
+     * @param array $fields List of fields to be returned
+     * @return array
      */
-    public function extractOriginal(array $properties)
+    public function extractOriginal(array $fields): array
     {
         $result = [];
-        foreach ($properties as $property) {
-            $result[$property] = $this->getOriginal($property);
+        foreach ($fields as $field) {
+            if ($this->hasOriginal($field)) {
+                $result[$field] = $this->getOriginal($field);
+            } elseif ($this->isOriginalField($field)) {
+                $result[$field] = $this->get($field);
+            }
         }
 
         return $result;
     }
 
     /**
-     * Returns an array with only the original properties
-     * stored in this entity, indexed by property name.
+     * Returns an array with only the original fields
+     * stored in this entity, indexed by field name, if they exist.
      *
-     * This method will only return properties that have been modified since
-     * the entity was built. Unchanged properties will be omitted.
+     * This method will only return fields that have been modified since
+     * the entity was built. Unchanged fields will be omitted.
      *
-     * @param array $properties List of properties to be returned
-     * @return array
+     * @param array $fields List of fields to be returned
+     * @return array
      */
-    public function extractOriginalChanged(array $properties)
+    public function extractOriginalChanged(array $fields): array
     {
         $result = [];
-        foreach ($properties as $property) {
-            $original = $this->getOriginal($property);
-            if ($original !== $this->get($property)) {
-                $result[$property] = $original;
+        foreach ($fields as $field) {
+            if (!$this->hasOriginal($field)) {
+                continue;
+            }
+
+            $original = $this->getOriginal($field);
+            if ($original !== $this->get($field)) {
+                $result[$field] = $original;
             }
         }
 
@@ -710,124 +898,181 @@ public function extractOriginalChanged(array $properties)
     }
 
     /**
-     * Sets the dirty status of a single property. If called with no second
-     * argument, it will return whether the property was modified or not
-     * after the object creation.
+     * Returns whether a field is an original one
      *
-     * When called with no arguments it will return whether or not there are any
-     * dirty property in the entity
+     * @return bool
+     */
+    public function isOriginalField(string $name): bool
+    {
+        return in_array($name, $this->_originalFields, true);
+    }
+
+    /**
+     * Returns an array of original fields.
+     * Original fields are those that the entity was initialized with.
      *
-     * @deprecated 3.4.0 Use EntityTrait::setDirty() and EntityTrait::isDirty()
-     * @param string|null $property the field to set or check status for
-     * @param null|bool $isDirty true means the property was changed, false means
-     * it was not changed and null will make the function return current state
-     * for that property
-     * @return bool Whether the property was changed or not
+     * @return array
      */
-    public function dirty($property = null, $isDirty = null)
+    public function getOriginalFields(): array
     {
-        if ($property === null) {
-            return $this->isDirty();
-        }
+        return $this->_originalFields;
+    }
 
-        if ($isDirty === null) {
-            return $this->isDirty($property);
+    /**
+     * Sets the given field or a list of fields to as original.
+     * Normally there is no need to call this method manually.
+     *
+     * @param array|string $field the name of a field or a list of fields to set as original
+     * @param bool $merge
+     * @return $this
+     */
+    protected function setOriginalField(string|array $field, bool $merge = true)
+    {
+        if (!$merge) {
+            $this->_originalFields = (array)$field;
+
+            return $this;
         }
 
-        $this->setDirty($property, $isDirty);
+        $fields = (array)$field;
+        foreach ($fields as $field) {
+            $field = (string)$field;
+            if (!$this->isOriginalField($field)) {
+                $this->_originalFields[] = $field;
+            }
+        }
 
-        return true;
+        return $this;
     }
 
     /**
-     * Sets the dirty status of a single property.
+     * Sets the dirty status of a single field.
      *
-     * @param string $property the field to set or check status for
-     * @param bool $isDirty true means the property was changed, false means
-     * it was not changed
+     * @param string $field the field to set or check status for
+     * @param bool $isDirty true means the field was changed, false means
+     * it was not changed. Defaults to true.
      * @return $this
      */
-    public function setDirty($property, $isDirty)
+    public function setDirty(string $field, bool $isDirty = true)
     {
         if ($isDirty === false) {
-            unset($this->_dirty[$property]);
+            $this->setOriginalField($field);
+
+            unset($this->_dirty[$field], $this->_original[$field]);
 
             return $this;
         }
 
-        $this->_dirty[$property] = true;
-        unset($this->_errors[$property], $this->_invalid[$property]);
+        $this->_dirty[$field] = true;
+        unset($this->_errors[$field], $this->_invalid[$field]);
 
         return $this;
     }
 
     /**
-     * Checks if the entity is dirty or if a single property of it is dirty.
+     * Checks if the entity is dirty or if a single field of it is dirty.
      *
-     * @param string $property the field to check the status for
-     * @return bool Whether the property was changed or not
+     * @param string|null $field The field to check the status for. Null for the whole entity.
+     * @return bool Whether the field was changed or not
      */
-    public function isDirty($property = null)
+    public function isDirty(?string $field = null): bool
     {
-        if ($property === null) {
-            return !empty($this->_dirty);
-        }
-
-        return isset($this->_dirty[$property]);
+        return $field === null
+            ? $this->_dirty !== []
+            : isset($this->_dirty[$field]);
     }
 
     /**
-     * Gets the dirty properties.
+     * Gets the dirty fields.
      *
-     * @return array
+     * @return array
      */
-    public function getDirty()
+    public function getDirty(): array
     {
         return array_keys($this->_dirty);
     }
 
     /**
      * Sets the entire entity as clean, which means that it will appear as
-     * no properties being modified or added at all. This is an useful call
+     * no fields being modified or added at all. This is an useful call
      * for an initial object hydration
      *
      * @return void
      */
-    public function clean()
+    public function clean(): void
     {
         $this->_dirty = [];
         $this->_errors = [];
         $this->_invalid = [];
         $this->_original = [];
+        $this->setOriginalField(array_keys($this->_fields), false);
     }
 
     /**
-     * Returns whether or not this entity has already been persisted.
-     * This method can return null in the case there is no prior information on
-     * the status of this entity.
+     * Set the status of this entity.
      *
-     * If called with a boolean it will set the known status of this instance,
-     * true means that the instance is not yet persisted in the database, false
-     * that it already is.
+     * Using `true` means that the entity has not been persisted in the database,
+     * `false` that it already is.
      *
-     * @param bool|null $new true if it is known this instance was not yet persisted
-     * @return bool Whether or not the entity has been persisted.
+     * @param bool $new Indicate whether this entity has been persisted.
+     * @return $this
      */
-    public function isNew($new = null)
+    public function setNew(bool $new)
     {
-        if ($new === null) {
-            return $this->_new;
+        if ($new) {
+            foreach ($this->_fields as $k => $p) {
+                $this->_dirty[$k] = true;
+            }
         }
 
-        $new = (bool)$new;
+        $this->_new = $new;
 
-        if ($new) {
-            foreach ($this->_properties as $k => $p) {
-                $this->_dirty[$k] = true;
+        return $this;
+    }
+
+    /**
+     * Returns whether this entity has already been persisted.
+     *
+     * @return bool Whether the entity has been persisted.
+     */
+    public function isNew(): bool
+    {
+        return $this->_new;
+    }
+
+    /**
+     * Returns whether this entity has errors.
+     *
+     * @param bool $includeNested true will check nested entities for hasErrors()
+     * @return bool
+     */
+    public function hasErrors(bool $includeNested = true): bool
+    {
+        if ($this->_hasBeenVisited) {
+            // While recursing through entities, each entity should only be visited once. See https://github.com/cakephp/cakephp/issues/17318
+            return false;
+        }
+
+        if (Hash::filter($this->_errors)) {
+            return true;
+        }
+
+        if ($includeNested === false) {
+            return false;
+        }
+
+        $this->_hasBeenVisited = true;
+        try {
+            foreach ($this->_fields as $field) {
+                if ($this->_readHasErrors($field)) {
+                    return true;
+                }
             }
+        } finally {
+            $this->_hasBeenVisited = false;
         }
 
-        return $this->_new = $new;
+        return false;
     }
 
     /**
@@ -835,19 +1080,31 @@ public function isNew($new = null)
      *
      * @return array
      */
-    public function getErrors()
+    public function getErrors(): array
     {
-        $diff = array_diff_key($this->_properties, $this->_errors);
-
-        return $this->_errors + (new Collection($diff))
-            ->filter(function ($value) {
-                return is_array($value) || $value instanceof EntityInterface;
-            })
-            ->map(function ($value) {
-                return $this->_readError($value);
-            })
-            ->filter()
-            ->toArray();
+        if ($this->_hasBeenVisited) {
+            // While recursing through entities, each entity should only be visited once. See https://github.com/cakephp/cakephp/issues/17318
+            return [];
+        }
+
+        $diff = array_diff_key($this->_fields, $this->_errors);
+
+        $this->_hasBeenVisited = true;
+        try {
+            $errors = $this->_errors + (new Collection($diff))
+                ->filter(function ($value) {
+                    return is_array($value) || $value instanceof EntityInterface;
+                })
+                ->map(function ($value) {
+                    return $this->_readError($value);
+                })
+                ->filter()
+                ->toArray();
+        } finally {
+            $this->_hasBeenVisited = false;
+        }
+
+        return $errors;
     }
 
     /**
@@ -856,14 +1113,9 @@ public function getErrors()
      * @param string $field Field name to get the errors from
      * @return array
      */
-    public function getError($field)
+    public function getError(string $field): array
     {
-        $errors = isset($this->_errors[$field]) ? $this->_errors[$field] : [];
-        if ($errors) {
-            return $errors;
-        }
-
-        return $this->_nestedErrors($field);
+        return $this->_errors[$field] ?? $this->_nestedErrors($field);
     }
 
     /**
@@ -873,20 +1125,36 @@ public function getError($field)
      *
      * ```
      * // Sets the error messages for multiple fields at once
-     * $entity->errors(['salary' => ['message'], 'name' => ['another message']);
+     * $entity->setErrors(['salary' => ['message'], 'name' => ['another message']]);
      * ```
      *
-     * @param array $fields The array of errors to set.
-     * @param bool $overwrite Whether or not to overwrite pre-existing errors for $fields
+     * @param array $errors The array of errors to set.
+     * @param bool $overwrite Whether to overwrite pre-existing errors for $fields
      * @return $this
      */
-    public function setErrors(array $fields, $overwrite = false)
+    public function setErrors(array $errors, bool $overwrite = false)
     {
-        foreach ($fields as $f => $error) {
+        if ($overwrite) {
+            foreach ($errors as $f => $error) {
+                $this->_errors[$f] = (array)$error;
+            }
+
+            return $this;
+        }
+
+        foreach ($errors as $f => $error) {
             $this->_errors += [$f => []];
-            $this->_errors[$f] = $overwrite ?
-                (array)$error :
-                array_merge($this->_errors[$f], (array)$error);
+
+            // String messages are appended to the list,
+            // while more complex error structures need their
+            // keys preserved for nested validator.
+            if (is_string($error)) {
+                $this->_errors[$f][] = $error;
+            } else {
+                foreach ($error as $k => $v) {
+                    $this->_errors[$f][$k] = $v;
+                }
+            }
         }
 
         return $this;
@@ -899,101 +1167,77 @@ public function setErrors(array $fields, $overwrite = false)
      *
      * ```
      * // Sets the error messages for a single field
-     * $entity->errors('salary', ['must be numeric', 'must be a positive number']);
+     * $entity->setError('salary', ['must be numeric', 'must be a positive number']);
      * ```
      *
      * @param string $field The field to get errors for, or the array of errors to set.
-     * @param string|array $errors The errors to be set for $field
-     * @param bool $overwrite Whether or not to overwrite pre-existing errors for $field
+     * @param array|string $errors The errors to be set for $field
+     * @param bool $overwrite Whether to overwrite pre-existing errors for $field
      * @return $this
      */
-    public function setError($field, $errors, $overwrite = false)
+    public function setError(string $field, array|string $errors, bool $overwrite = false)
     {
         if (is_string($errors)) {
             $errors = [$errors];
         }
 
-        return $this->setErrors([$field => $errors], $overwrite);
-    }
-
-    /**
-     * Sets the error messages for a field or a list of fields. When called
-     * without the second argument it returns the validation
-     * errors for the specified fields. If called with no arguments it returns
-     * all the validation error messages stored in this entity and any other nested
-     * entity.
-     *
-     * ### Example
-     *
-     * ```
-     * // Sets the error messages for a single field
-     * $entity->errors('salary', ['must be numeric', 'must be a positive number']);
-     *
-     * // Returns the error messages for a single field
-     * $entity->errors('salary');
-     *
-     * // Returns all error messages indexed by field name
-     * $entity->errors();
-     *
-     * // Sets the error messages for multiple fields at once
-     * $entity->errors(['salary' => ['message'], 'name' => ['another message']);
-     * ```
-     *
-     * When used as a setter, this method will return this entity instance for method
-     * chaining.
-     *
-     * @deprecated 3.4.0 Use EntityTrait::setError(), EntityTrait::setErrors(), EntityTrait::getError() and EntityTrait::getErrors()
-     * @param string|array|null $field The field to get errors for, or the array of errors to set.
-     * @param string|array|null $errors The errors to be set for $field
-     * @param bool $overwrite Whether or not to overwrite pre-existing errors for $field
-     * @return array|$this
-     */
-    public function errors($field = null, $errors = null, $overwrite = false)
-    {
-        if ($field === null) {
-            return $this->getErrors();
-        }
-
-        if (is_string($field) && $errors === null) {
-            return $this->getError($field);
-        }
+        // Handle dotted field paths by creating nested error structure
+        if (str_contains($field, '.')) {
+            $nested = Hash::insert([], $field, $errors);
 
-        if (!is_array($field)) {
-            $field = [$field => $errors];
+            return $this->setErrors($nested, $overwrite);
         }
 
-        return $this->setErrors($field, $overwrite);
+        return $this->setErrors([$field => $errors], $overwrite);
     }
 
     /**
      * Auxiliary method for getting errors in nested entities
      *
      * @param string $field the field in this entity to check for errors
-     * @return array errors in nested entity if any
+     * @return array Errors in nested entity if any
      */
-    protected function _nestedErrors($field)
+    protected function _nestedErrors(string $field): array
     {
-        $path = explode('.', $field);
-
         // Only one path element, check for nested entity with error.
-        if (count($path) === 1) {
-            return $this->_readError($this->get($path[0]));
+        if (!str_contains($field, '.')) {
+            if (!$this->has($field)) {
+                return [];
+            }
+
+            $entity = $this->get($field);
+            if ($entity instanceof EntityInterface || is_iterable($entity)) {
+                return $this->_readError($entity);
+            }
+
+            return [];
+        }
+        // Try reading the errors data with field as a simple path
+        $error = Hash::get($this->_errors, $field);
+        if ($error !== null) {
+            return $error;
         }
+        $path = explode('.', $field);
 
+        // Traverse down the related entities/arrays for
+        // the relevant entity.
         $entity = $this;
         $len = count($path);
         while ($len) {
+            /** @var string $part */
             $part = array_shift($path);
             $len = count($path);
             $val = null;
             if ($entity instanceof EntityInterface) {
-                $val = $entity->get($part);
+                if ($entity->has($part)) {
+                    $val = $entity->get($part);
+                }
             } elseif (is_array($entity)) {
-                $val = isset($entity[$part]) ? $entity[$part] : false;
+                $val = $entity[$part] ?? false;
             }
 
-            if (is_array($val) ||
-                $val instanceof Traversable ||
+            if (
+                is_iterable($val) ||
                 $val instanceof EntityInterface
             ) {
                 $entity = $val;
@@ -1009,37 +1253,60 @@ protected function _nestedErrors($field)
         return [];
     }
 
+    /**
+     * Reads if there are errors for one or many objects.
+     *
+     * @param \Cake\Datasource\EntityInterface|array $object The object to read errors from.
+     * @return bool
+     */
+    protected function _readHasErrors(mixed $object): bool
+    {
+        if ($object instanceof EntityInterface && $object->hasErrors()) {
+            return true;
+        }
+
+        if (is_array($object)) {
+            foreach ($object as $value) {
+                if ($this->_readHasErrors($value)) {
+                    return true;
+                }
+            }
+        }
+
+        return false;
+    }
+
     /**
      * Read the error(s) from one or many objects.
      *
-     * @param array|\Cake\Datasource\EntityTrait $object The object to read errors from.
+     * @param \Cake\Datasource\EntityInterface|iterable $object The object to read errors from.
      * @param string|null $path The field name for errors.
      * @return array
      */
-    protected function _readError($object, $path = null)
+    protected function _readError(EntityInterface|iterable $object, ?string $path = null): array
     {
+        if ($path !== null && $object instanceof EntityInterface) {
+            return $object->getError($path);
+        }
         if ($object instanceof EntityInterface) {
-            return $object->errors($path);
+            return $object->getErrors();
         }
-        if (is_array($object)) {
-            $array = array_map(function ($val) {
-                if ($val instanceof EntityInterface) {
-                    return $val->errors();
-                }
-            }, $object);
 
-            return array_filter($array);
-        }
+        $array = array_map(function ($val) {
+            if ($val instanceof EntityInterface) {
+                return $val->getErrors();
+            }
+        }, (array)$object);
 
-        return [];
+        return array_filter($array);
     }
 
     /**
      * Get a list of invalid fields and their data for errors upon validation/patching
      *
-     * @return array
+     * @return array
      */
-    public function getInvalid()
+    public function getInvalid(): array
     {
         return $this->_invalid;
     }
@@ -1048,30 +1315,28 @@ public function getInvalid()
      * Get a single value of an invalid field. Returns null if not set.
      *
      * @param string $field The name of the field.
-     * @return mixed
+     * @return mixed|null
      */
-    public function getInvalidField($field)
+    public function getInvalidField(string $field): mixed
     {
-        $value = isset($this->_invalid[$field]) ? $this->_invalid[$field] : null;
-
-        return $value;
+        return $this->_invalid[$field] ?? null;
     }
 
     /**
      * Set fields as invalid and not patchable into the entity.
      *
      * This is useful for batch operations when one needs to get the original value for an error message after patching.
-     * This value could not be patched into the entity and is simply copied into the _invalid property for debugging purposes
-     * or to be able to log it away.
+     * This value could not be patched into the entity and is simply copied into the _invalid property for debugging
+     * purposes or to be able to log it away.
      *
-     * @param array $fields The values to set.
-     * @param bool $overwrite Whether or not to overwrite pre-existing values for $field.
+     * @param array $fields The values to set.
+     * @param bool $overwrite Whether to overwrite pre-existing values for $field.
      * @return $this
      */
-    public function setInvalid(array $fields, $overwrite = false)
+    public function setInvalid(array $fields, bool $overwrite = false)
     {
         foreach ($fields as $field => $value) {
-            if ($overwrite === true) {
+            if ($overwrite) {
                 $this->_invalid[$field] = $value;
                 continue;
             }
@@ -1088,7 +1353,7 @@ public function setInvalid(array $fields, $overwrite = false)
      * @param mixed $value The invalid value to be set for $field.
      * @return $this
      */
-    public function setInvalidField($field, $value)
+    public function setInvalidField(string $field, mixed $value)
     {
         $this->_invalid[$field] = $value;
 
@@ -1096,96 +1361,13 @@ public function setInvalidField($field, $value)
     }
 
     /**
-     * Sets a field as invalid and not patchable into the entity.
-     *
-     * This is useful for batch operations when one needs to get the original value for an error message after patching.
-     * This value could not be patched into the entity and is simply copied into the _invalid property for debugging purposes
-     * or to be able to log it away.
-     *
-     * @deprecated 3.5 Use getInvalid()/getInvalidField()/setInvalid() instead.
-     * @param string|array|null $field The field to get invalid value for, or the value to set.
-     * @param mixed|null $value The invalid value to be set for $field.
-     * @param bool $overwrite Whether or not to overwrite pre-existing values for $field.
-     * @return $this|mixed
-     */
-    public function invalid($field = null, $value = null, $overwrite = false)
-    {
-        if ($field === null) {
-            return $this->_invalid;
-        }
-
-        if (is_string($field) && $value === null) {
-            $value = isset($this->_invalid[$field]) ? $this->_invalid[$field] : null;
-
-            return $value;
-        }
-
-        if (!is_array($field)) {
-            $field = [$field => $value];
-        }
-
-        foreach ($field as $f => $value) {
-            if ($overwrite) {
-                $this->_invalid[$f] = $value;
-                continue;
-            }
-            $this->_invalid += [$f => $value];
-        }
-
-        return $this;
-    }
-
-    /**
-     * Stores whether or not a property value can be changed or set in this entity.
-     * The special property `*` can also be marked as accessible or protected, meaning
-     * that any other property specified before will take its value. For example
-     * `$entity->accessible('*', true)` means that any property not specified already
-     * will be accessible by default.
-     *
-     * You can also call this method with an array of properties, in which case they
-     * will each take the accessibility value specified in the second argument.
-     *
-     * ### Example:
-     *
-     * ```
-     * $entity->accessible('id', true); // Mark id as not protected
-     * $entity->accessible('author_id', false); // Mark author_id as protected
-     * $entity->accessible(['id', 'user_id'], true); // Mark both properties as accessible
-     * $entity->accessible('*', false); // Mark all properties as protected
-     * ```
-     *
-     * When called without the second param it will return whether or not the property
-     * can be set.
-     *
-     * ### Example:
-     *
-     * ```
-     * $entity->accessible('id'); // Returns whether it can be set or not
-     * ```
-     *
-     * @deprecated 3.4.0 Use EntityTrait::setAccess() and EntityTrait::isAccessible()
-     * @param string|array $property single or list of properties to change its accessibility
-     * @param bool|null $set true marks the property as accessible, false will
-     * mark it as protected.
-     * @return $this|bool
-     */
-    public function accessible($property, $set = null)
-    {
-        if ($set === null) {
-            return $this->isAccessible($property);
-        }
-
-        return $this->setAccess($property, $set);
-    }
-
-    /**
-     * Stores whether or not a property value can be changed or set in this entity.
-     * The special property `*` can also be marked as accessible or protected, meaning
-     * that any other property specified before will take its value. For example
-     * `$entity->setAccess('*', true)` means that any property not specified already
+     * Stores whether a field value can be changed or set in this entity.
+     * The special field `*` can also be marked as accessible or protected, meaning
+     * that any other field specified before will take its value. For example
+     * `$entity->setAccess('*', true)` means that any field not specified already
      * will be accessible by default.
      *
-     * You can also call this method with an array of properties, in which case they
+     * You can also call this method with an array of fields, in which case they
      * will each take the accessibility value specified in the second argument.
      *
      * ### Example:
@@ -1193,35 +1375,44 @@ public function accessible($property, $set = null)
      * ```
      * $entity->setAccess('id', true); // Mark id as not protected
      * $entity->setAccess('author_id', false); // Mark author_id as protected
-     * $entity->setAccess(['id', 'user_id'], true); // Mark both properties as accessible
-     * $entity->setAccess('*', false); // Mark all properties as protected
+     * $entity->setAccess(['id', 'user_id'], true); // Mark both fields as accessible
+     * $entity->setAccess('*', false); // Mark all fields as protected
      * ```
      *
-     * @param string|array $property single or list of properties to change its accessibility
-     * @param bool $set true marks the property as accessible, false will
+     * @param array|string $field Single or list of fields to change its accessibility
+     * @param bool $set True marks the field as accessible, false will
      * mark it as protected.
      * @return $this
      */
-    public function setAccess($property, $set)
+    public function setAccess(array|string $field, bool $set)
     {
-        if ($property === '*') {
-            $this->_accessible = array_map(function ($p) use ($set) {
-                return (bool)$set;
-            }, $this->_accessible);
-            $this->_accessible['*'] = (bool)$set;
+        if ($field === '*') {
+            $this->_accessible = array_map(fn() => $set, $this->_accessible);
+            $this->_accessible['*'] = $set;
 
             return $this;
         }
 
-        foreach ((array)$property as $prop) {
-            $this->_accessible[$prop] = (bool)$set;
+        foreach ((array)$field as $prop) {
+            $this->_accessible[$prop] = $set;
         }
 
         return $this;
     }
 
     /**
-     * Checks if a property is accessible
+     * Returns the raw accessible configuration for this entity.
+     * The `*` wildcard refers to all fields.
+     *
+     * @return array
+     */
+    public function getAccessible(): array
+    {
+        return $this->_accessible;
+    }
+
+    /**
+     * Checks if a field is accessible
      *
      * ### Example:
      *
@@ -1229,14 +1420,12 @@ public function setAccess($property, $set)
      * $entity->isAccessible('id'); // Returns whether it can be set or not
      * ```
      *
-     * @param string $property Property name to check
+     * @param string $field Field name to check
      * @return bool
      */
-    public function isAccessible($property)
+    public function isAccessible(string $field): bool
     {
-        $value = isset($this->_accessible[$property]) ?
-            $this->_accessible[$property] :
-            null;
+        $value = $this->_accessible[$field] ?? null;
 
         return ($value === null && !empty($this->_accessible['*'])) || $value;
     }
@@ -1246,7 +1435,7 @@ public function isAccessible($property)
      *
      * @return string
      */
-    public function getSource()
+    public function getSource(): string
     {
         return $this->_registryAlias;
     }
@@ -1257,7 +1446,7 @@ public function getSource()
      * @param string $alias the alias of the repository
      * @return $this
      */
-    public function setSource($alias)
+    public function setSource(string $alias)
     {
         $this->_registryAlias = $alias;
 
@@ -1265,53 +1454,46 @@ public function setSource($alias)
     }
 
     /**
-     * Returns the alias of the repository from which this entity came from.
-     *
-     * If called with no arguments, it returns the alias of the repository
-     * this entity came from if it is known.
-     *
-     * @deprecated 3.4.0 Use EntityTrait::getSource() and EntityTrait::setSource()
-     * @param string|null $alias the alias of the repository
-     * @return string|$this
-     */
-    public function source($alias = null)
-    {
-        if (is_null($alias)) {
-            return $this->getSource();
-        }
-
-        $this->setSource($alias);
-
-        return $this;
-    }
-
-    /**
-     * Returns a string representation of this object in a human readable format.
+     * Returns a string representation of this object in a human-readable format.
      *
      * @return string
+     * @deprecated 5.2.0 Casting an entity to string is deprecated. Use json_encode() instead to get a string representation of the entity.
      */
-    public function __toString()
+    public function __toString(): string
     {
-        return json_encode($this, JSON_PRETTY_PRINT);
+        deprecationWarning(
+            '5.2.0',
+            'Casting an entity to string is deprecated. ' .
+            'Use json_encode() instead to get a string representation of the entity.',
+        );
+
+        return (string)json_encode($this, JSON_PRETTY_PRINT);
     }
 
     /**
      * Returns an array that can be used to describe the internal state of this
      * object.
      *
-     * @return array
+     * @return array
      */
-    public function __debugInfo()
+    public function __debugInfo(): array
     {
-        return $this->_properties + [
+        $fields = $this->_fields;
+        foreach ($this->_virtual as $field) {
+            $fields[$field] = $this->$field;
+        }
+
+        return $fields + [
             '[new]' => $this->isNew(),
             '[accessible]' => $this->_accessible,
             '[dirty]' => $this->_dirty,
             '[original]' => $this->_original,
+            '[originalFields]' => $this->_originalFields,
             '[virtual]' => $this->_virtual,
+            '[hasErrors]' => $this->hasErrors(),
             '[errors]' => $this->_errors,
             '[invalid]' => $this->_invalid,
-            '[repository]' => $this->_registryAlias
+            '[repository]' => $this->_registryAlias,
         ];
     }
 }
diff --git a/src/Datasource/Exception/InvalidPrimaryKeyException.php b/src/Datasource/Exception/InvalidPrimaryKeyException.php
index 9a033a781ca..ac7a72b1b4e 100644
--- a/src/Datasource/Exception/InvalidPrimaryKeyException.php
+++ b/src/Datasource/Exception/InvalidPrimaryKeyException.php
@@ -1,4 +1,6 @@
 >
      */
-    protected static $_modelFactories = [];
+    protected static array $_modelFactories = [];
 
     /**
-     * Register a callable to generate repositories of a given type.
+     * Register a locator to return repositories of a given type.
      *
      * @param string $type The name of the repository type the factory function is for.
-     * @param callable $factory The factory function used to create instances.
+     * @param \Cake\Datasource\Locator\LocatorInterface $factory The factory function used to create instances.
      * @return void
      */
-    public static function add($type, callable $factory)
+    public static function add(string $type, LocatorInterface $factory): void
     {
         static::$_modelFactories[$type] = $factory;
     }
@@ -44,7 +49,7 @@ public static function add($type, callable $factory)
      * @param string $type The name of the repository type to drop the factory for.
      * @return void
      */
-    public static function drop($type)
+    public static function drop(string $type): void
     {
         unset(static::$_modelFactories[$type]);
     }
@@ -53,22 +58,18 @@ public static function drop($type)
      * Get the factory for the specified repository type.
      *
      * @param string $type The repository type to get the factory for.
-     * @throws InvalidArgumentException If the specified repository type has no factory.
-     * @return callable The factory for the repository type.
+     * @throws \InvalidArgumentException If the specified repository type has no factory.
+     * @return \Cake\Datasource\Locator\LocatorInterface The factory for the repository type.
      */
-    public static function get($type)
+    public static function get(string $type): LocatorInterface
     {
-        if (!isset(static::$_modelFactories['Table'])) {
-            static::$_modelFactories['Table'] = [TableRegistry::getTableLocator(), 'get'];
-        }
-
-        if (!isset(static::$_modelFactories[$type])) {
-            throw new InvalidArgumentException(sprintf(
-                'Unknown repository type "%s". Make sure you register a type before trying to use it.',
-                $type
-            ));
+        if (isset(static::$_modelFactories[$type])) {
+            return static::$_modelFactories[$type];
         }
 
-        return static::$_modelFactories[$type];
+        throw new InvalidArgumentException(sprintf(
+            'Unknown repository type `%s`. Make sure you register a type before trying to use it.',
+            $type,
+        ));
     }
 }
diff --git a/src/Datasource/FixtureInterface.php b/src/Datasource/FixtureInterface.php
index 320e3b87099..9c0b95ed84f 100644
--- a/src/Datasource/FixtureInterface.php
+++ b/src/Datasource/FixtureInterface.php
@@ -1,4 +1,6 @@
  $fields The values to set.
+     * @param bool $overwrite Whether to overwrite pre-existing values for $field.
+     * @return $this
+     */
+    public function setInvalid(array $fields, bool $overwrite = false);
+
+    /**
+     * Get a single value of an invalid field. Returns null if not set.
+     *
+     * @param string $field The name of the field.
+     * @return mixed|null
+     */
+    public function getInvalidField(string $field): mixed;
+
+    /**
+     * Sets a field as invalid and not patchable into the entity.
      *
-     * @param string|array|null $field The field to get invalid value for, or the value to set.
-     * @param mixed|null $value The invalid value to be set for $field.
-     * @param bool $overwrite Whether or not to overwrite pre-existing values for $field.
-     * @return $this|mixed
-     * @deprecated 3.5.0 Use getInvalid()/getInvalidField() and setInvalid()/setInvalidField() instead.
+     * @param string $field The value to set.
+     * @param mixed $value The invalid value to be set for $field.
+     * @return $this
      */
-    public function invalid($field = null, $value = null, $overwrite = false);
+    public function setInvalidField(string $field, mixed $value);
 }
diff --git a/src/Datasource/LICENSE.txt b/src/Datasource/LICENSE.txt
index 0c4b7932c31..b938c9e8ed3 100644
--- a/src/Datasource/LICENSE.txt
+++ b/src/Datasource/LICENSE.txt
@@ -1,7 +1,7 @@
 The MIT License (MIT)
 
 CakePHP(tm) : The Rapid Development PHP Framework (https://cakephp.org)
-Copyright (c) 2005-2016, Cake Software Foundation, Inc. (https://cakefoundation.org)
+Copyright (c) 2005-2020, Cake Software Foundation, Inc. (https://cakefoundation.org)
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/src/Datasource/Locator/AbstractLocator.php b/src/Datasource/Locator/AbstractLocator.php
new file mode 100644
index 00000000000..192ca68db3e
--- /dev/null
+++ b/src/Datasource/Locator/AbstractLocator.php
@@ -0,0 +1,118 @@
+
+ */
+abstract class AbstractLocator implements LocatorInterface
+{
+    /**
+     * Instances that belong to the registry.
+     *
+     * @var array
+     */
+    protected array $instances = [];
+
+    /**
+     * Contains a list of options that were passed to get() method.
+     *
+     * @var array
+     */
+    protected array $options = [];
+
+    /**
+     * {@inheritDoc}
+     *
+     * @param string $alias The alias name you want to get.
+     * @param array $options The options you want to build the table with.
+     * @return TRepo
+     * @throws \Cake\Core\Exception\CakeException When trying to get alias for which instance
+     *   has already been created with different options.
+     */
+    public function get(string $alias, array $options = []): RepositoryInterface
+    {
+        $storeOptions = $options;
+        unset($storeOptions['allowFallbackClass']);
+
+        if (isset($this->instances[$alias])) {
+            if ($storeOptions && isset($this->options[$alias]) && $this->options[$alias] !== $storeOptions) {
+                throw new CakeException(sprintf(
+                    'You cannot configure `%s`, it already exists in the registry.',
+                    $alias,
+                ));
+            }
+
+            return $this->instances[$alias];
+        }
+
+        $this->options[$alias] = $storeOptions;
+
+        return $this->instances[$alias] = $this->createInstance($alias, $options);
+    }
+
+    /**
+     * Create an instance of a given classname.
+     *
+     * @param string $alias Repository alias.
+     * @param array $options The options you want to build the instance with.
+     * @return TRepo
+     */
+    abstract protected function createInstance(string $alias, array $options): RepositoryInterface;
+
+    /**
+     * @inheritDoc
+     */
+    public function set(string $alias, RepositoryInterface $repository): RepositoryInterface
+    {
+        return $this->instances[$alias] = $repository;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function exists(string $alias): bool
+    {
+        return isset($this->instances[$alias]);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function remove(string $alias): void
+    {
+        unset(
+            $this->instances[$alias],
+            $this->options[$alias],
+        );
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function clear(): void
+    {
+        $this->instances = [];
+        $this->options = [];
+    }
+}
diff --git a/src/Datasource/Locator/LocatorInterface.php b/src/Datasource/Locator/LocatorInterface.php
new file mode 100644
index 00000000000..c66ee8a028c
--- /dev/null
+++ b/src/Datasource/Locator/LocatorInterface.php
@@ -0,0 +1,70 @@
+ $options The options you want to build the table with.
+     * @return TRepo
+     * @throws \RuntimeException When trying to get alias for which instance
+     *   has already been created with different options.
+     */
+    public function get(string $alias, array $options = []): RepositoryInterface;
+
+    /**
+     * Set a repository instance.
+     *
+     * @param string $alias The alias to set.
+     * @param TRepo $repository The repository to set.
+     * @return TRepo
+     */
+    public function set(string $alias, RepositoryInterface $repository): RepositoryInterface;
+
+    /**
+     * Check to see if an instance exists in the registry.
+     *
+     * @param string $alias The alias to check for.
+     * @return bool
+     */
+    public function exists(string $alias): bool;
+
+    /**
+     * Removes a repository instance from the registry.
+     *
+     * @param string $alias The alias to remove.
+     * @return void
+     */
+    public function remove(string $alias): void;
+
+    /**
+     * Clears the registry of configuration and instances.
+     *
+     * @return void
+     */
+    public function clear(): void;
+}
diff --git a/src/Datasource/ModelAwareTrait.php b/src/Datasource/ModelAwareTrait.php
index 8ce11459a67..53033715635 100644
--- a/src/Datasource/ModelAwareTrait.php
+++ b/src/Datasource/ModelAwareTrait.php
@@ -1,4 +1,6 @@
 
      */
-    protected $_modelFactories = [];
+    protected array $_modelFactories = [];
 
     /**
      * The model type to use.
      *
      * @var string
      */
-    protected $_modelType = 'Table';
+    protected string $_modelType = 'Table';
 
     /**
-     * Set the modelClass and modelKey properties based on conventions.
+     * Set the modelClass property based on conventions.
      *
-     * If the properties are already set they will not be overwritten
+     * If the property is already set it will not be overwritten
      *
      * @param string $name Class name.
      * @return void
      */
-    protected function _setModelClass($name)
+    protected function _setModelClass(string $name): void
     {
-        if (empty($this->modelClass)) {
-            $this->modelClass = $name;
-        }
+        $this->modelClass ??= $name;
     }
 
     /**
-     * Loads and constructs repository objects required by this object
+     * Fetch or construct a model instance from a locator.
+     *
+     * Uses a modelFactory based on `$modelType` to fetch and construct a `RepositoryInterface`
+     * and return it. The default `modelType` can be defined with `setModelType()`.
      *
-     * Typically used to load ORM Table objects as required. Can
-     * also be used to load other types of repository objects your application uses.
+     * Unlike `loadModel()` this method will *not* set an object property.
      *
      * If a repository provider does not return an object a MissingModelException will
      * be thrown.
      *
-     * @param string|null $modelClass Name of model class to load. Defaults to $this->modelClass
-     * @param string|null $modelType The type of repository to load. Defaults to the modelType() value.
+     * @param string|null $modelClass Name of model class to load. Defaults to $this->modelClass.
+     *  The name can be an alias like `'Post'` or FQCN like `App\Model\Table\PostsTable::class`.
+     * @param string|null $modelType The type of repository to load. Defaults to the getModelType() value.
      * @return \Cake\Datasource\RepositoryInterface The model instance created.
      * @throws \Cake\Datasource\Exception\MissingModelException If the model class cannot be found.
-     * @throws \InvalidArgumentException When using a type that has not been registered.
-     * @throws \UnexpectedValueException If no model type has been defined
+     * @throws \UnexpectedValueException If $modelClass argument is not provided
+     *   and ModelAwareTrait::$modelClass property value is empty.
      */
-    public function loadModel($modelClass = null, $modelType = null)
+    public function fetchModel(?string $modelClass = null, ?string $modelType = null): RepositoryInterface
     {
-        if ($modelClass === null) {
-            $modelClass = $this->modelClass;
+        $modelClass ??= $this->modelClass;
+        if (!$modelClass) {
+            throw new UnexpectedValueException('Default modelClass is empty');
         }
-        if ($modelType === null) {
-            $modelType = $this->getModelType();
-
-            if ($modelType === null) {
-                throw new UnexpectedValueException('No model type has been defined');
-            }
-        }
-
-        list(, $alias) = pluginSplit($modelClass, true);
-
-        if (isset($this->{$alias})) {
-            return $this->{$alias};
+        $modelType ??= $this->getModelType();
+
+        $options = [];
+        if (!str_contains($modelClass, '\\')) {
+            [, $alias] = pluginSplit($modelClass, true);
+        } else {
+            $options['className'] = $modelClass;
+            $alias = substr(
+                $modelClass,
+                strrpos($modelClass, '\\') + 1,
+                -strlen($modelType),
+            );
+            $modelClass = $alias;
         }
 
-        if (isset($this->_modelFactories[$modelType])) {
-            $factory = $this->_modelFactories[$modelType];
-        }
-        if (!isset($factory)) {
-            $factory = FactoryLocator::get($modelType);
+        $factory = $this->_modelFactories[$modelType] ?? FactoryLocator::get($modelType);
+        if ($factory instanceof LocatorInterface) {
+            $instance = $factory->get($modelClass, $options);
+        } else {
+            $instance = $factory($modelClass, $options);
         }
-        $this->{$alias} = $factory($modelClass);
-        if (!$this->{$alias}) {
-            throw new MissingModelException([$modelClass, $modelType]);
+        if ($instance) {
+            return $instance;
         }
 
-        return $this->{$alias};
+        throw new MissingModelException([$modelClass, $modelType]);
     }
 
     /**
      * Override a existing callable to generate repositories of a given type.
      *
      * @param string $type The name of the repository type the factory function is for.
-     * @param callable $factory The factory function used to create instances.
+     * @param \Cake\Datasource\Locator\LocatorInterface|callable $factory The factory function used to create instances.
      * @return void
      */
-    public function modelFactory($type, callable $factory)
+    public function modelFactory(string $type, LocatorInterface|callable $factory): void
     {
         $this->_modelFactories[$type] = $factory;
     }
@@ -134,7 +142,7 @@ public function modelFactory($type, callable $factory)
      *
      * @return string
      */
-    public function getModelType()
+    public function getModelType(): string
     {
         return $this->_modelType;
     }
@@ -143,32 +151,12 @@ public function getModelType()
      * Set the model type to be used by this class
      *
      * @param string $modelType The model type
-     *
      * @return $this
      */
-    public function setModelType($modelType)
+    public function setModelType(string $modelType)
     {
         $this->_modelType = $modelType;
 
         return $this;
     }
-
-    /**
-     * Set or get the model type to be used by this class
-     *
-     * @deprecated 3.5.0 Use getModelType()/setModelType() instead.
-     * @param string|null $modelType The model type or null to retrieve the current
-     *
-     * @return string|$this
-     */
-    public function modelType($modelType = null)
-    {
-        if ($modelType === null) {
-            return $this->_modelType;
-        }
-
-        $this->_modelType = $modelType;
-
-        return $this;
-    }
 }
diff --git a/src/Datasource/Paginator.php b/src/Datasource/Paginator.php
deleted file mode 100644
index d702f167e4b..00000000000
--- a/src/Datasource/Paginator.php
+++ /dev/null
@@ -1,435 +0,0 @@
- 1,
-        'limit' => 20,
-        'maxLimit' => 100,
-        'whitelist' => ['limit', 'sort', 'page', 'direction']
-    ];
-
-    /**
-     * Paging params after pagination operation is done.
-     *
-     * @var array
-     */
-    protected $_pagingParams = [];
-
-    /**
-     * Handles automatic pagination of model records.
-     *
-     * ### Configuring pagination
-     *
-     * When calling `paginate()` you can use the $settings parameter to pass in
-     * pagination settings. These settings are used to build the queries made
-     * and control other pagination settings.
-     *
-     * If your settings contain a key with the current table's alias. The data
-     * inside that key will be used. Otherwise the top level configuration will
-     * be used.
-     *
-     * ```
-     *  $settings = [
-     *    'limit' => 20,
-     *    'maxLimit' => 100
-     *  ];
-     *  $results = $paginator->paginate($table, $settings);
-     * ```
-     *
-     * The above settings will be used to paginate any repository. You can configure
-     * repository specific settings by keying the settings with the repository alias.
-     *
-     * ```
-     *  $settings = [
-     *    'Articles' => [
-     *      'limit' => 20,
-     *      'maxLimit' => 100
-     *    ],
-     *    'Comments' => [ ... ]
-     *  ];
-     *  $results = $paginator->paginate($table, $settings);
-     * ```
-     *
-     * This would allow you to have different pagination settings for
-     * `Articles` and `Comments` repositories.
-     *
-     * ### Controlling sort fields
-     *
-     * By default CakePHP will automatically allow sorting on any column on the
-     * repository object being paginated. Often times you will want to allow
-     * sorting on either associated columns or calculated fields. In these cases
-     * you will need to define a whitelist of all the columns you wish to allow
-     * sorting on. You can define the whitelist in the `$settings` parameter:
-     *
-     * ```
-     * $settings = [
-     *   'Articles' => [
-     *     'finder' => 'custom',
-     *     'sortWhitelist' => ['title', 'author_id', 'comment_count'],
-     *   ]
-     * ];
-     * ```
-     *
-     * Passing an empty array as whitelist disallows sorting altogether.
-     *
-     * ### Paginating with custom finders
-     *
-     * You can paginate with any find type defined on your table using the
-     * `finder` option.
-     *
-     * ```
-     *  $settings = [
-     *    'Articles' => [
-     *      'finder' => 'popular'
-     *    ]
-     *  ];
-     *  $results = $paginator->paginate($table, $settings);
-     * ```
-     *
-     * Would paginate using the `find('popular')` method.
-     *
-     * You can also pass an already created instance of a query to this method:
-     *
-     * ```
-     * $query = $this->Articles->find('popular')->matching('Tags', function ($q) {
-     *   return $q->where(['name' => 'CakePHP'])
-     * });
-     * $results = $paginator->paginate($query);
-     * ```
-     *
-     * ### Scoping Request parameters
-     *
-     * By using request parameter scopes you can paginate multiple queries in
-     * the same controller action:
-     *
-     * ```
-     * $articles = $paginator->paginate($articlesQuery, ['scope' => 'articles']);
-     * $tags = $paginator->paginate($tagsQuery, ['scope' => 'tags']);
-     * ```
-     *
-     * Each of the above queries will use different query string parameter sets
-     * for pagination data. An example URL paginating both results would be:
-     *
-     * ```
-     * /dashboard?articles[page]=1&tags[page]=2
-     * ```
-     *
-     * @param \Cake\Datasource\RepositoryInterface|\Cake\Datasource\QueryInterface $object The table or query to paginate.
-     * @param array $params Request params
-     * @param array $settings The settings/configuration used for pagination.
-     * @return \Cake\Datasource\ResultSetInterface Query results
-     * @throws \Cake\Datasource\Exception\PageOutOfBoundsException
-     */
-    public function paginate($object, array $params = [], array $settings = [])
-    {
-        $query = null;
-        if ($object instanceof QueryInterface) {
-            $query = $object;
-            $object = $query->repository();
-        }
-
-        $alias = $object->alias();
-        $defaults = $this->getDefaults($alias, $settings);
-        $options = $this->mergeOptions($params, $defaults);
-        $options = $this->validateSort($object, $options);
-        $options = $this->checkLimit($options);
-
-        $options += ['page' => 1, 'scope' => null];
-        $options['page'] = (int)$options['page'] < 1 ? 1 : (int)$options['page'];
-        list($finder, $options) = $this->_extractFinder($options);
-
-        if (empty($query)) {
-            $query = $object->find($finder, $options);
-        } else {
-            $query->applyOptions($options);
-        }
-
-        $cleanQuery = clone $query;
-        $results = $query->all();
-        $numResults = count($results);
-        $count = $cleanQuery->count();
-
-        $page = $options['page'];
-        $limit = $options['limit'];
-        $pageCount = max((int)ceil($count / $limit), 1);
-        $requestedPage = $page;
-        $page = min($page, $pageCount);
-
-        $order = (array)$options['order'];
-        $sortDefault = $directionDefault = false;
-        if (!empty($defaults['order']) && count($defaults['order']) === 1) {
-            $sortDefault = key($defaults['order']);
-            $directionDefault = current($defaults['order']);
-        }
-
-        $paging = [
-            'finder' => $finder,
-            'page' => $page,
-            'current' => $numResults,
-            'count' => $count,
-            'perPage' => $limit,
-            'prevPage' => $page > 1,
-            'nextPage' => $count > ($page * $limit),
-            'pageCount' => $pageCount,
-            'sort' => key($order),
-            'direction' => current($order),
-            'limit' => $defaults['limit'] != $limit ? $limit : null,
-            'sortDefault' => $sortDefault,
-            'directionDefault' => $directionDefault,
-            'scope' => $options['scope'],
-        ];
-
-        $this->_pagingParams = [$alias => $paging];
-
-        if ($requestedPage > $page) {
-            throw new PageOutOfBoundsException([
-                'requestedPage' => $requestedPage,
-                'pagingParams' => $this->_pagingParams
-            ]);
-        }
-
-        return $results;
-    }
-
-    /**
-     * Extracts the finder name and options out of the provided pagination options.
-     *
-     * @param array $options the pagination options.
-     * @return array An array containing in the first position the finder name
-     *   and in the second the options to be passed to it.
-     */
-    protected function _extractFinder($options)
-    {
-        $type = !empty($options['finder']) ? $options['finder'] : 'all';
-        unset($options['finder'], $options['maxLimit']);
-
-        if (is_array($type)) {
-            $options = (array)current($type) + $options;
-            $type = key($type);
-        }
-
-        return [$type, $options];
-    }
-
-    /**
-     * Get paging params after pagination operation.
-     *
-     * @return array
-     */
-    public function getPagingParams()
-    {
-        return $this->_pagingParams;
-    }
-
-    /**
-     * Merges the various options that Paginator uses.
-     * Pulls settings together from the following places:
-     *
-     * - General pagination settings
-     * - Model specific settings.
-     * - Request parameters
-     *
-     * The result of this method is the aggregate of all the option sets
-     * combined together. You can change config value `whitelist` to modify
-     * which options/values can be set using request parameters.
-     *
-     * @param array $params Request params.
-     * @param array $settings The settings to merge with the request data.
-     * @return array Array of merged options.
-     */
-    public function mergeOptions($params, $settings)
-    {
-        if (!empty($settings['scope'])) {
-            $scope = $settings['scope'];
-            $params = !empty($params[$scope]) ? (array)$params[$scope] : [];
-        }
-        $params = array_intersect_key($params, array_flip($this->getConfig('whitelist')));
-
-        return array_merge($settings, $params);
-    }
-
-    /**
-     * Get the settings for a $model. If there are no settings for a specific
-     * repository, the general settings will be used.
-     *
-     * @param string $alias Model name to get settings for.
-     * @param array $settings The settings which is used for combining.
-     * @return array An array of pagination settings for a model,
-     *   or the general settings.
-     */
-    public function getDefaults($alias, $settings)
-    {
-        if (isset($settings[$alias])) {
-            $settings = $settings[$alias];
-        }
-
-        $defaults = $this->getConfig();
-        $maxLimit = isset($settings['maxLimit']) ? $settings['maxLimit'] : $defaults['maxLimit'];
-        $limit = isset($settings['limit']) ? $settings['limit'] : $defaults['limit'];
-
-        if ($limit > $maxLimit) {
-            $limit = $maxLimit;
-        }
-
-        $settings['maxLimit'] = $maxLimit;
-        $settings['limit'] = $limit;
-
-        return $settings + $defaults;
-    }
-
-    /**
-     * Validate that the desired sorting can be performed on the $object.
-     *
-     * Only fields or virtualFields can be sorted on. The direction param will
-     * also be sanitized. Lastly sort + direction keys will be converted into
-     * the model friendly order key.
-     *
-     * You can use the whitelist parameter to control which columns/fields are
-     * available for sorting. This helps prevent users from ordering large
-     * result sets on un-indexed values.
-     *
-     * If you need to sort on associated columns or synthetic properties you
-     * will need to use a whitelist.
-     *
-     * Any columns listed in the sort whitelist will be implicitly trusted.
-     * You can use this to sort on synthetic columns, or columns added in custom
-     * find operations that may not exist in the schema.
-     *
-     * @param \Cake\Datasource\RepositoryInterface $object Repository object.
-     * @param array $options The pagination options being used for this request.
-     * @return array An array of options with sort + direction removed and
-     *   replaced with order if possible.
-     */
-    public function validateSort(RepositoryInterface $object, array $options)
-    {
-        if (isset($options['sort'])) {
-            $direction = null;
-            if (isset($options['direction'])) {
-                $direction = strtolower($options['direction']);
-            }
-            if (!in_array($direction, ['asc', 'desc'])) {
-                $direction = 'asc';
-            }
-            $options['order'] = [$options['sort'] => $direction];
-        }
-        unset($options['sort'], $options['direction']);
-
-        if (empty($options['order'])) {
-            $options['order'] = [];
-        }
-        if (!is_array($options['order'])) {
-            return $options;
-        }
-
-        $inWhitelist = false;
-        if (isset($options['sortWhitelist'])) {
-            $field = key($options['order']);
-            $inWhitelist = in_array($field, $options['sortWhitelist'], true);
-            if (!$inWhitelist) {
-                $options['order'] = [];
-
-                return $options;
-            }
-        }
-
-        $options['order'] = $this->_prefix($object, $options['order'], $inWhitelist);
-
-        return $options;
-    }
-
-    /**
-     * Prefixes the field with the table alias if possible.
-     *
-     * @param \Cake\Datasource\RepositoryInterface $object Repository object.
-     * @param array $order Order array.
-     * @param bool $whitelisted Whether or not the field was whitelisted.
-     * @return array Final order array.
-     */
-    protected function _prefix(RepositoryInterface $object, $order, $whitelisted = false)
-    {
-        $tableAlias = $object->alias();
-        $tableOrder = [];
-        foreach ($order as $key => $value) {
-            if (is_numeric($key)) {
-                $tableOrder[] = $value;
-                continue;
-            }
-            $field = $key;
-            $alias = $tableAlias;
-
-            if (strpos($key, '.') !== false) {
-                list($alias, $field) = explode('.', $key);
-            }
-            $correctAlias = ($tableAlias === $alias);
-
-            if ($correctAlias && $whitelisted) {
-                // Disambiguate fields in schema. As id is quite common.
-                if ($object->hasField($field)) {
-                    $field = $alias . '.' . $field;
-                }
-                $tableOrder[$field] = $value;
-            } elseif ($correctAlias && $object->hasField($field)) {
-                $tableOrder[$tableAlias . '.' . $field] = $value;
-            } elseif (!$correctAlias && $whitelisted) {
-                $tableOrder[$alias . '.' . $field] = $value;
-            }
-        }
-
-        return $tableOrder;
-    }
-
-    /**
-     * Check the limit parameter and ensure it's within the maxLimit bounds.
-     *
-     * @param array $options An array of options with a limit key to be checked.
-     * @return array An array of options for pagination.
-     */
-    public function checkLimit(array $options)
-    {
-        $options['limit'] = (int)$options['limit'];
-        if (empty($options['limit']) || $options['limit'] < 1) {
-            $options['limit'] = 1;
-        }
-        $options['limit'] = max(min($options['limit'], $options['maxLimit']), 1);
-
-        return $options;
-    }
-}
diff --git a/src/Datasource/PaginatorInterface.php b/src/Datasource/PaginatorInterface.php
deleted file mode 100644
index 54ccf31fc71..00000000000
--- a/src/Datasource/PaginatorInterface.php
+++ /dev/null
@@ -1,38 +0,0 @@
- ['title', 'created', 'author_id']
+     *
+     *   // Map with SortField objects
+     *   'sortableFields' => [
+     *       'name' => 'Users.name',
+     *       'newest' => [
+     *           SortField::desc('created'),
+     *           SortField::asc('title'),
+     *       ],
+     *   ]
+     *
+     *   // Callable with builder
+     *   'sortableFields' => function(SortableFieldsBuilder $builder) {
+     *       return $builder
+     *           ->add('name', SortField::asc('Users.name'))
+     *           ->add('popularity', SortField::desc('score', locked: true), 'created');
+     *   }
+     *   ```
+     * - `finder` - The table finder to use. Defaults to `all`.
+     * - `scope` - If specified this scope will be used to get the paging options
+     *   from the query params passed to paginate(). Scopes allow namespacing the
+     *   paging options and allows paginating multiple models in the same action.
+     *   Default `null`.
+     *
+     * @var array
+     */
+    protected array $_defaultConfig = [
+        'page' => 1,
+        'limit' => 20,
+        'maxLimit' => 100,
+        'allowedParameters' => ['limit', 'sort', 'page', 'direction'],
+        'sortableFields' => null,
+        'finder' => 'all',
+        'scope' => null,
+    ];
+
+    /**
+     * Calculated paging params.
+     *
+     * @var array
+     */
+    protected array $pagingParams = [
+        'limit' => null,
+        'maxLimit' => null,
+        'count' => null,
+        'totalCount' => null,
+        'perPage' => null,
+        'pageCount' => null,
+        'currentPage' => null,
+        'requestedPage' => null,
+        'start' => null,
+        'end' => null,
+        'hasPrevPage' => null,
+        'hasNextPage' => null,
+        'sort' => null,
+        'sortDefault' => null,
+        'direction' => null,
+        'directionDefault' => null,
+        'completeSort' => null,
+        'alias' => null,
+        'scope' => null,
+    ];
+
+    /**
+     * Handles automatic pagination of model records.
+     *
+     * ### Configuring pagination
+     *
+     * When calling `paginate()` you can use the $settings parameter to pass in
+     * pagination settings. These settings are used to build the queries made
+     * and control other pagination settings.
+     *
+     * If your settings contain a key with the current table's alias. The data
+     * inside that key will be used. Otherwise, the top level configuration will
+     * be used.
+     *
+     * ```
+     *  $settings = [
+     *    'limit' => 20,
+     *    'maxLimit' => 100
+     *  ];
+     *  $results = $paginator->paginate($table, $settings);
+     * ```
+     *
+     * The above settings will be used to paginate any repository. You can configure
+     * repository specific settings by keying the settings with the repository alias.
+     *
+     * ```
+     *  $settings = [
+     *    'Articles' => [
+     *      'limit' => 20,
+     *      'maxLimit' => 100
+     *    ],
+     *    'Comments' => [ ... ]
+     *  ];
+     *  $results = $paginator->paginate($table, $settings);
+     * ```
+     *
+     * This would allow you to have different pagination settings for
+     * `Articles` and `Comments` repositories.
+     *
+     * ### Controlling sort fields
+     *
+     * By default CakePHP will automatically allow sorting on any column on the
+     * repository object being paginated. Often times you will want to allow
+     * sorting on either associated columns or calculated fields. In these cases
+     * you will need to define an allowed list of all the columns you wish to allow
+     * sorting on. You can define the allowed sort fields in the `$settings` parameter:
+     *
+     * ```
+     * $settings = [
+     *   'Articles' => [
+     *     'finder' => 'custom',
+     *     'sortableFields' => ['title', 'author_id', 'comment_count'],
+     *   ]
+     * ];
+     * ```
+     *
+     * Passing an empty array as sortableFields disallows sorting altogether.
+     *
+     * ### Paginating with custom finders
+     *
+     * You can paginate with any find type defined on your table using the
+     * `finder` option.
+     *
+     * ```
+     *  $settings = [
+     *    'Articles' => [
+     *      'finder' => 'popular'
+     *    ]
+     *  ];
+     *  $results = $paginator->paginate($table, $settings);
+     * ```
+     *
+     * Would paginate using the `find('popular')` method.
+     *
+     * You can also pass an already created instance of a query to this method:
+     *
+     * ```
+     * $query = $this->Articles->find('popular')->matching('Tags', function ($q) {
+     *   return $q->where(['name' => 'CakePHP'])
+     * });
+     * $results = $paginator->paginate($query);
+     * ```
+     *
+     * ### Scoping Request parameters
+     *
+     * By using request parameter scopes you can paginate multiple queries in
+     * the same controller action:
+     *
+     * ```
+     * $articles = $paginator->paginate($articlesQuery, ['scope' => 'articles']);
+     * $tags = $paginator->paginate($tagsQuery, ['scope' => 'tags']);
+     * ```
+     *
+     * Each of the above queries will use different query string parameter sets
+     * for pagination data. An example URL paginating both results would be:
+     *
+     * ```
+     * /dashboard?articles[page]=1&tags[page]=2
+     * ```
+     *
+     * @param mixed $target The repository or query
+     *   to paginate.
+     * @param array $params Request params
+     * @param array $settings The settings/configuration used for pagination.
+     * @return \Cake\Datasource\Paging\PaginatedInterface
+     * @throws \Cake\Datasource\Paging\Exception\PageOutOfBoundsException
+     */
+    public function paginate(
+        mixed $target,
+        array $params = [],
+        array $settings = [],
+    ): PaginatedInterface {
+        $query = null;
+        if ($target instanceof QueryInterface) {
+            $query = $target;
+            $target = $query->getRepository();
+            if ($target === null) {
+                throw new CakeException('No repository set for query.');
+            }
+        }
+
+        assert(
+            $target instanceof RepositoryInterface,
+            'Pagination target must be an instance of `' . QueryInterface::class
+                . '` or `' . RepositoryInterface::class . '`.',
+        );
+
+        $data = $this->extractData($target, $params, $settings);
+        $query = $this->getQuery($target, $query, $data);
+
+        $countQuery = clone $query;
+        $items = $this->getItems($query, $data);
+        $this->pagingParams['count'] = count($items);
+        $this->pagingParams['totalCount'] = $this->getCount($countQuery, $data);
+
+        $pagingParams = $this->buildParams($data);
+        if ($pagingParams['requestedPage'] > $pagingParams['currentPage']) {
+            throw new PageOutOfBoundsException([
+                'requestedPage' => $pagingParams['requestedPage'],
+                'pagingParams' => $pagingParams,
+            ]);
+        }
+
+        return $this->buildPaginated($items, $pagingParams);
+    }
+
+    /**
+     * Build paginated result set.
+     *
+     * @param \Cake\Datasource\ResultSetInterface $items
+     * @param array $pagingParams
+     * @return \Cake\Datasource\Paging\PaginatedInterface
+     */
+    protected function buildPaginated(ResultSetInterface $items, array $pagingParams): PaginatedInterface
+    {
+        return new PaginatedResultSet($items, $pagingParams);
+    }
+
+    /**
+     * Get query for fetching paginated results.
+     *
+     * @param \Cake\Datasource\RepositoryInterface $object Repository instance.
+     * @param \Cake\Datasource\QueryInterface|null $query Query Instance.
+     * @param array $data Pagination data.
+     * @return \Cake\Datasource\QueryInterface
+     */
+    protected function getQuery(RepositoryInterface $object, ?QueryInterface $query, array $data): QueryInterface
+    {
+        $options = $data['options'];
+        $queryOptions = array_intersect_key(
+            $options,
+            ['order' => null, 'page' => null, 'limit' => null],
+        );
+
+        $args = [];
+        $type = $options['finder'] ?? null;
+        if (is_array($type)) {
+            $args = (array)current($type);
+            $type = key($type);
+        }
+
+        if ($query === null) {
+            $query = $object->find($type ?? 'all', ...$args);
+        } elseif ($type !== null) {
+            $query->find($type, ...$args);
+        }
+
+        $query->applyOptions($queryOptions);
+
+        return $query;
+    }
+
+    /**
+     * Get paginated items.
+     *
+     * @param \Cake\Datasource\QueryInterface $query Query to fetch items.
+     * @param array $data Paging data.
+     * @return \Cake\Datasource\ResultSetInterface
+     */
+    protected function getItems(QueryInterface $query, array $data): ResultSetInterface
+    {
+        return $query->all();
+    }
+
+    /**
+     * Get total count of records.
+     *
+     * @param \Cake\Datasource\QueryInterface $query Query instance.
+     * @param array $data Pagination data.
+     * @return int|null
+     */
+    protected function getCount(QueryInterface $query, array $data): ?int
+    {
+        return $query->count();
+    }
+
+    /**
+     * Extract pagination data needed
+     *
+     * @param \Cake\Datasource\RepositoryInterface $object The repository object.
+     * @param array $params Request params
+     * @param array $settings The settings/configuration used for pagination.
+     * @return array
+     */
+    protected function extractData(RepositoryInterface $object, array $params, array $settings): array
+    {
+        $alias = $object->getAlias();
+        $defaults = $this->getDefaults($alias, $settings);
+
+        $validSettings = array_keys($this->_defaultConfig);
+        $validSettings[] = 'order';
+        $extraSettings = array_diff_key($defaults, array_flip($validSettings));
+        if ($extraSettings) {
+            triggerWarning(
+                'Passing query options as paginator settings is no longer supported.'
+                . ' Use a custom finder through the `finder` config or pass a SelectQuery instance to paginate().'
+                . ' Extra keys found are: `' . implode('`, `', array_keys($extraSettings)) . '`.',
+            );
+        }
+
+        $options = $this->mergeOptions($params, $defaults);
+        $options = $this->validateSort($object, $options);
+        $options = $this->checkLimit($options);
+
+        $options['page'] = max((int)$options['page'], 1);
+
+        return compact('defaults', 'options', 'alias');
+    }
+
+    /**
+     * Build pagination params.
+     *
+     * @param array $data Paginator data containing keys 'options',
+     *  'defaults', 'alias'.
+     * @return array Paging params.
+     */
+    protected function buildParams(array $data): array
+    {
+        $this->pagingParams = [
+            'perPage' => $data['options']['limit'],
+            'requestedPage' => $data['options']['page'],
+            'alias' => $data['alias'],
+            'scope' => $data['options']['scope'],
+            'maxLimit' => $data['options']['maxLimit'],
+        ] + $this->pagingParams;
+
+        $this->addPageCountParams($data);
+        $this->addStartEndParams($data);
+        $this->addPrevNextParams($data);
+        $this->addSortingParams($data);
+
+        $this->pagingParams['limit'] = (int)$data['defaults']['limit'] !== (int)$data['options']['limit']
+            ? $data['options']['limit']
+            : null;
+
+        // Add sortableFields configuration for view helpers
+        if (isset($data['options']['sortableFields'])) {
+            $sortableFields = $data['options']['sortableFields'];
+            if ($sortableFields instanceof SortableFieldsBuilder) {
+                $this->pagingParams['sortableFields'] = $sortableFields->toArray();
+            }
+        }
+
+        return $this->pagingParams;
+    }
+
+    /**
+     * Add "currentPage" and "pageCount" params.
+     *
+     * @param array $data Paginator data.
+     * @return void
+     */
+    protected function addPageCountParams(array $data): void
+    {
+        $page = $data['options']['page'];
+        $pageCount = null;
+
+        if ($this->pagingParams['totalCount'] !== null) {
+            $pageCount = max((int)ceil($this->pagingParams['totalCount'] / $this->pagingParams['perPage']), 1);
+            $page = min($page, $pageCount);
+        } elseif ($this->pagingParams['count'] === 0 && $this->pagingParams['requestedPage'] > 1) {
+            $page = 1;
+        }
+
+        $this->pagingParams['currentPage'] = $page;
+        $this->pagingParams['pageCount'] = $pageCount;
+    }
+
+    /**
+     * Add "start" and "end" params.
+     *
+     * @param array $data Paginator data.
+     * @return void
+     */
+    protected function addStartEndParams(array $data): void
+    {
+        $start = 0;
+        $end = 0;
+        if ($this->pagingParams['count'] > 0) {
+            $start = (($this->pagingParams['currentPage'] - 1) * $this->pagingParams['perPage']) + 1;
+            $end = $start + $this->pagingParams['count'] - 1;
+        }
+
+        $this->pagingParams['start'] = $start;
+        $this->pagingParams['end'] = $end;
+    }
+
+    /**
+     * Add "prevPage" and "nextPage" params.
+     *
+     * @param array $data Paging data.
+     * @return void
+     */
+    protected function addPrevNextParams(array $data): void
+    {
+        $this->pagingParams['hasPrevPage'] = $this->pagingParams['currentPage'] > 1;
+        if ($this->pagingParams['totalCount'] === null) {
+            $this->pagingParams['hasNextPage'] = true;
+        } else {
+            $this->pagingParams['hasNextPage'] = $this->pagingParams['totalCount']
+                > $this->pagingParams['currentPage'] * $this->pagingParams['perPage'];
+        }
+    }
+
+    /**
+     * Add sorting / ordering params.
+     *
+     * @param array $data Paging data.
+     * @return void
+     */
+    protected function addSortingParams(array $data): void
+    {
+        $defaults = $data['defaults'];
+        $order = (array)$data['options']['order'];
+        $sortDefault = false;
+        $directionDefault = false;
+
+        if (!empty($defaults['order']) && count($defaults['order']) >= 1) {
+            $sortDefault = key($defaults['order']);
+            $directionDefault = current($defaults['order']);
+        }
+        if (isset($data['options']['sortDirection'])) {
+            $direction = $data['options']['sortDirection'];
+        } else {
+            $direction = isset($data['options']['sort']) && count($order) ? current($order) : null;
+        }
+
+        $this->pagingParams = [
+            'sort' => $data['options']['sort'],
+            'direction' => $direction,
+            'sortDefault' => $sortDefault,
+            'directionDefault' => $directionDefault,
+            'completeSort' => $order,
+        ] + $this->pagingParams;
+    }
+
+    /**
+     * Merges the various options that Paginator uses.
+     * Pulls settings together from the following places:
+     *
+     * - General pagination settings
+     * - Model specific settings.
+     * - Request parameters
+     *
+     * The result of this method is the aggregate of all the option sets
+     * combined together. You can change config value `allowedParameters` to modify
+     * which options/values can be set using request parameters.
+     *
+     * @param array $params Request params.
+     * @param array $settings The settings to merge with the request data.
+     * @return array Array of merged options.
+     */
+    protected function mergeOptions(array $params, array $settings): array
+    {
+        if (!empty($settings['scope'])) {
+            $scope = $settings['scope'];
+            $params = (array)($params[$scope] ?? []);
+        }
+        $params = array_intersect_key($params, array_flip($this->getConfig('allowedParameters')));
+
+        return array_merge($settings, $params);
+    }
+
+    /**
+     * Get the settings for a $model. If there are no settings for a specific
+     * repository, the general settings will be used.
+     *
+     * @param string $alias Model name to get settings for.
+     * @param array $settings The settings which is used for combining.
+     * @return array An array of pagination settings for a model,
+     *   or the general settings.
+     */
+    protected function getDefaults(string $alias, array $settings): array
+    {
+        if (isset($settings[$alias])) {
+            $settings = $settings[$alias];
+        }
+
+        $defaults = $this->getConfig();
+
+        $maxLimit = $settings['maxLimit'] ?? $defaults['maxLimit'];
+        $limit = $settings['limit'] ?? $defaults['limit'];
+
+        if ($limit > $maxLimit) {
+            $limit = $maxLimit;
+        }
+
+        $settings['maxLimit'] = $maxLimit;
+        $settings['limit'] = $limit;
+
+        return $settings + $defaults;
+    }
+
+    /**
+     * Validate that the desired sorting can be performed on the $object.
+     *
+     * Only fields or virtualFields can be sorted on. The direction param will
+     * also be sanitized. Lastly sort + direction keys will be converted into
+     * the model friendly order key.
+     *
+     /**
+     * You can use the allowedParameters option to control which columns/fields are
+     * available for sorting via URL parameters. This helps prevent users from ordering large
+     * result sets on un-indexed values.
+     *
+     * If you need to sort on associated columns or synthetic properties you
+     * will need to use the `sortableFields` option.
+     *
+     * Any columns listed in the allowed sort fields will be implicitly trusted.
+     * You can use this to sort on synthetic columns, or columns added in custom
+     * find operations that may not exist in the schema.
+     *
+     * The default order options provided to paginate() will be merged with the user's
+     * requested sorting field/direction.
+     *
+     * @param \Cake\Datasource\RepositoryInterface $object Repository object.
+     * @param array $options The pagination options being used for this request.
+     * @return array An array of options with sort + direction removed and
+     *   replaced with order if possible.
+     */
+    protected function validateSort(RepositoryInterface $object, array $options): array
+    {
+        // Check if we have sortableFields configured
+        $sortableFields = $options['sortableFields'] ?? null;
+        $builder = $sortableFields instanceof SortableFieldsBuilder
+            ? $sortableFields
+            : SortableFieldsBuilder::create($sortableFields);
+
+        // Store the converted builder for later use in paging params
+        if ($builder !== null) {
+            $options['sortableFields'] = $builder;
+        }
+
+        $sortAllowed = $builder !== null;
+
+        // Resolve a default `order` that uses builder/alias/combined sort keys.
+        // Keys the builder does not know (plain columns) pass through unchanged,
+        // because the default order is developer-controlled, not user input.
+        $defaultSortKey = null;
+        $defaultSortDirection = null;
+        if ($builder !== null && isset($options['order']) && is_array($options['order'])) {
+            $resolvedOrder = [];
+            $leadingResolved = null;
+            foreach ($options['order'] as $field => $dir) {
+                // Builder direction matching is lowercase-only, so normalize
+                // here just like parseSortParams() does for user input.
+                $direction = is_string($dir) ? strtolower($dir) : $dir;
+                $resolved = is_string($field) && is_string($direction)
+                    ? $builder->resolve($field, $direction, true)
+                    : null;
+                if ($resolved === null) {
+                    // Pass through with the original direction; the DB treats
+                    // the ORDER BY keyword case-insensitively.
+                    $resolvedOrder[$field] = $dir;
+
+                    continue;
+                }
+
+                // Only surface the alias as the active default sort when it is
+                // the leading `order` entry; otherwise a plain field ahead of it
+                // is the effective primary sort.
+                if ($resolvedOrder === []) {
+                    $defaultSortKey = $field;
+                    $defaultSortDirection = $direction;
+                    $leadingResolved = $resolved;
+                }
+                foreach ($resolved as $resolvedField => $resolvedDir) {
+                    $resolvedOrder[$resolvedField] = $resolvedDir;
+                }
+            }
+            $options['order'] = $resolvedOrder;
+
+            // If a later `order` entry overrode any field produced by the
+            // leading alias, the default order is no longer equivalent to
+            // selecting that alias, so do not advertise it as the active sort.
+            if (
+                $leadingResolved !== null
+                && array_slice($resolvedOrder, 0, count($leadingResolved), true) !== $leadingResolved
+            ) {
+                $defaultSortKey = null;
+                $defaultSortDirection = null;
+            }
+        }
+
+        if (isset($options['sort'])) {
+            // Parse sort and direction parameters
+            $sortParams = $this->parseSortParams($options);
+
+            // Update options with parsed sort key (handles combined format)
+            $options['sort'] = $sortParams['sortKey'];
+
+            if ($builder !== null) {
+                // Use builder to resolve sort key
+                $order = $builder->resolve(
+                    $sortParams['sortKey'],
+                    $sortParams['direction'],
+                    $sortParams['directionSpecified'],
+                );
+
+                if ($order === null) {
+                    // Invalid sort key, clear sort
+                    $options['order'] = [];
+                    $options['sort'] = null;
+                    unset($options['direction']);
+
+                    return $options;
+                }
+
+                // Merge with existing order - existing order comes AFTER our resolved order
+                $existingOrder = isset($options['order']) && is_array($options['order']) ? $options['order'] : [];
+                $modelAlias = $object->getAlias();
+                // Only keep fields from existing order that aren't already in our resolved order
+                // Account for prefixed vs unprefixed field names (e.g., 'modified' vs 'Alerts.modified')
+                foreach ($existingOrder as $field => $dir) {
+                    // Check if this field is already in $order, accounting for
+                    // prefixed vs unprefixed names in either direction (e.g.
+                    // `modified` vs `Alerts.modified`). Both must be deduped,
+                    // otherwise _prefix() later collapses them and the existing
+                    // (default) entry would override the requested sort.
+                    $alreadyInOrder = isset($order[$field]);
+                    if (!$alreadyInOrder && str_contains($field, '.')) {
+                        [$alias, $fieldName] = explode('.', $field, 2);
+                        if ($alias === $modelAlias && isset($order[$fieldName])) {
+                            $alreadyInOrder = true;
+                        }
+                    }
+                    if (!$alreadyInOrder && !str_contains($field, '.') && isset($order[$modelAlias . '.' . $field])) {
+                        $alreadyInOrder = true;
+                    }
+                    if (!$alreadyInOrder) {
+                        $order[$field] = $dir;
+                    }
+                }
+                $options['order'] = $order;
+                $options['sortDirection'] = $sortParams['direction'];
+            } else {
+                // No sortableFields configured - allow any field (default behavior)
+                $order = isset($options['order']) && is_array($options['order']) ? $options['order'] : [];
+                if ($order && $sortParams['sortKey'] && !str_contains($sortParams['sortKey'], '.')) {
+                    $order = $this->_removeAliases($order, $object->getAlias());
+                }
+
+                $options['order'] = [$sortParams['sortKey'] => $sortParams['direction']] + $order;
+            }
+        } else {
+            $options['sort'] = null;
+        }
+
+        unset($options['direction']);
+
+        if (empty($options['order'])) {
+            $options['order'] = [];
+        }
+        if (!is_array($options['order'])) {
+            return $options;
+        }
+
+        if ($options['sort'] === null) {
+            if ($defaultSortKey !== null) {
+                // Highlight the alias/combined key in PaginatorHelper without
+                // forcing a query string for the default sort. Report the alias
+                // direction so it matches the equivalent click-driven sort.
+                $options['sort'] = $defaultSortKey;
+                $options['sortDirection'] = $defaultSortDirection;
+            } elseif (count($options['order']) >= 1 && !is_numeric(key($options['order']))) {
+                $options['sort'] = key($options['order']);
+            }
+        }
+
+        $options['order'] = $this->_prefix($object, $options['order'], $sortAllowed);
+
+        return $options;
+    }
+
+    /**
+     * Remove alias if needed.
+     *
+     * @param array $fields Current fields
+     * @param string $model Current model alias
+     * @return array $fields Unaliased fields where applicable
+     */
+    protected function _removeAliases(array $fields, string $model): array
+    {
+        $result = [];
+        foreach ($fields as $field => $sort) {
+            if (is_int($field)) {
+                throw new CakeException(sprintf(
+                    'The `order` config must be an associative array. Found invalid value with numeric key: `%s`',
+                    $sort,
+                ));
+            }
+
+            if (!str_contains($field, '.')) {
+                $result[$field] = $sort;
+                continue;
+            }
+
+            [$alias, $currentField] = explode('.', $field);
+
+            if ($alias === $model) {
+                $result[$currentField] = $sort;
+                continue;
+            }
+
+            $result[$field] = $sort;
+        }
+
+        return $result;
+    }
+
+    /**
+     * Prefixes the field with the table alias if possible.
+     *
+     * @param \Cake\Datasource\RepositoryInterface $object Repository object.
+     * @param array $order Order array.
+     * @param bool $allowed Whether the field was allowed.
+     * @return array Final order array.
+     */
+    protected function _prefix(RepositoryInterface $object, array $order, bool $allowed = false): array
+    {
+        $tableAlias = $object->getAlias();
+        $tableOrder = [];
+        foreach ($order as $key => $value) {
+            if (is_numeric($key)) {
+                $tableOrder[] = $value;
+                continue;
+            }
+            $field = $key;
+            $alias = $tableAlias;
+
+            if (str_contains($key, '.')) {
+                [$alias, $field] = explode('.', $key);
+            }
+            $correctAlias = ($tableAlias === $alias);
+
+            if ($correctAlias && $allowed) {
+                // Disambiguate fields in schema. As id is quite common.
+                if ($object->hasField($field)) {
+                    $field = $alias . '.' . $field;
+                }
+                $tableOrder[$field] = $value;
+            } elseif ($correctAlias && $object->hasField($field)) {
+                $tableOrder[$tableAlias . '.' . $field] = $value;
+            } elseif (!$correctAlias && $allowed) {
+                $tableOrder[$alias . '.' . $field] = $value;
+            }
+        }
+
+        return $tableOrder;
+    }
+
+    /**
+     * Parse sort parameters from options.
+     *
+     * Extracts and normalizes sort key and direction from pagination options.
+     * Supports both traditional format (?sort=field&direction=asc) and
+     * combined format (?sort=field-asc).
+     *
+     * @param array $options The options array
+     * @return array{sortKey: string, direction: string, directionSpecified: bool}
+     */
+    protected function parseSortParams(array $options): array
+    {
+        $sortKey = $options['sort'];
+        $direction = isset($options['direction']) ? strtolower($options['direction']) : SortField::ASC;
+        $directionSpecified = isset($options['direction']);
+
+        // Check for combined sort-direction format (e.g., 'title-asc' or 'title-desc')
+        if (preg_match('/^(.+)-(asc|desc)$/i', $sortKey, $matches)) {
+            $sortKey = $matches[1];
+            $direction = strtolower($matches[2]);
+            $directionSpecified = true;
+        }
+
+        // Validate direction
+        if (!in_array($direction, [SortField::ASC, SortField::DESC], true)) {
+            $direction = SortField::ASC;
+        }
+
+        return [
+            'sortKey' => $sortKey,
+            'direction' => $direction,
+            'directionSpecified' => $directionSpecified,
+        ];
+    }
+
+    /**
+     * Check the limit parameter and ensure it's within the maxLimit bounds.
+     *
+     * @param array $options An array of options with a limit key to be checked.
+     * @return array An array of options for pagination.
+     */
+    protected function checkLimit(array $options): array
+    {
+        $options['limit'] = (int)$options['limit'];
+        if ($options['limit'] < 1) {
+            $options['limit'] = 1;
+        }
+        $options['limit'] = max(min($options['limit'], $options['maxLimit']), 1);
+
+        return $options;
+    }
+}
diff --git a/src/Datasource/Paging/PaginatedInterface.php b/src/Datasource/Paging/PaginatedInterface.php
new file mode 100644
index 00000000000..7c1461d1235
--- /dev/null
+++ b/src/Datasource/Paging/PaginatedInterface.php
@@ -0,0 +1,95 @@
+
+ * @method array toArray() Get the paginated items as an array
+ */
+interface PaginatedInterface extends Countable, Traversable
+{
+    /**
+     * Get current page number.
+     *
+     * @return int
+     */
+    public function currentPage(): int;
+
+    /**
+     * Get items per page.
+     *
+     * @return int
+     */
+    public function perPage(): int;
+
+    /**
+     * Get Total items counts.
+     *
+     * @return int|null
+     */
+    public function totalCount(): ?int;
+
+    /**
+     * Get total page count.
+     *
+     * @return int|null
+     */
+    public function pageCount(): ?int;
+
+    /**
+     * Get whether there's a previous page.
+     *
+     * @return bool
+     */
+    public function hasPrevPage(): bool;
+
+    /**
+     * Get whether there's a next page.
+     *
+     * @return bool
+     */
+    public function hasNextPage(): bool;
+
+    /**
+     * Get paginated items.
+     *
+     * @return iterable
+     */
+    public function items(): iterable;
+
+    /**
+     * Get paging param.
+     *
+     * @param string $name
+     * @return mixed
+     */
+    public function pagingParam(string $name): mixed;
+
+    /**
+     * Get all paging params.
+     *
+     * @return array
+     */
+    public function pagingParams(): array;
+}
diff --git a/src/Datasource/Paging/PaginatedResultSet.php b/src/Datasource/Paging/PaginatedResultSet.php
new file mode 100644
index 00000000000..a23b6c3a480
--- /dev/null
+++ b/src/Datasource/Paging/PaginatedResultSet.php
@@ -0,0 +1,194 @@
+
+ * @implements \Cake\Datasource\Paging\PaginatedInterface
+ */
+class PaginatedResultSet implements IteratorAggregate, JsonSerializable, PaginatedInterface
+{
+    /**
+     * Resultset instance.
+     *
+     * @var \Traversable
+     */
+    protected Traversable $results;
+
+    /**
+     * Paging params.
+     *
+     * @var array
+     */
+    protected array $params = [];
+
+    /**
+     * Constructor
+     *
+     * @param \Traversable $results Resultset instance.
+     * @param array $params Paging params.
+     */
+    public function __construct(Traversable $results, array $params)
+    {
+        $this->results = $results;
+        $this->params = $params;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function count(): int
+    {
+        return $this->params['count'];
+    }
+
+    /**
+     * Get the paginated items as an array.
+     *
+     * This will exhaust the iterator `items`.
+     *
+     * @return array
+     */
+    public function toArray(): array
+    {
+        return $this->jsonSerialize();
+    }
+
+    /**
+     * Get paginated items.
+     *
+     * @return \Traversable The paginated items result set.
+     */
+    public function items(): Traversable
+    {
+        return $this->results;
+    }
+
+    /**
+     * Provide data which should be serialized to JSON.
+     *
+     * @return array
+     */
+    public function jsonSerialize(): array
+    {
+        return iterator_to_array($this->items());
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function totalCount(): ?int
+    {
+        return $this->params['totalCount'];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function perPage(): int
+    {
+        return $this->params['perPage'];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function pageCount(): ?int
+    {
+        return $this->params['pageCount'];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function currentPage(): int
+    {
+        return $this->params['currentPage'];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function hasPrevPage(): bool
+    {
+        return $this->params['hasPrevPage'];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function hasNextPage(): bool
+    {
+        return $this->params['hasNextPage'];
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function pagingParam(string $name): mixed
+    {
+        return $this->params[$name] ?? null;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function pagingParams(): array
+    {
+        return $this->params;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function getIterator(): Traversable
+    {
+        return $this->results;
+    }
+
+    /**
+     * Proxies method calls to internal result set instance.
+     *
+     * @param string $name Method name
+     * @param array $arguments Arguments
+     * @return mixed
+     */
+    public function __call(string $name, array $arguments): mixed
+    {
+        deprecationWarning(
+            '5.1.0',
+            sprintf(
+                'Calling `%s` methods, such as `%s()`, on PaginatedResultSet is deprecated. ' .
+                'You must call `items()` first (for example, `items()->%s()`).',
+                $this->results::class,
+                $name,
+                $name,
+            ),
+        );
+
+        return $this->results->$name(...$arguments);
+    }
+}
diff --git a/src/Datasource/Paging/PaginatorInterface.php b/src/Datasource/Paging/PaginatorInterface.php
new file mode 100644
index 00000000000..ec17ade2c2f
--- /dev/null
+++ b/src/Datasource/Paging/PaginatorInterface.php
@@ -0,0 +1,37 @@
+
+     */
+    public function paginate(
+        mixed $target,
+        array $params = [],
+        array $settings = [],
+    ): PaginatedInterface;
+}
diff --git a/src/Datasource/Paging/SimplePaginator.php b/src/Datasource/Paging/SimplePaginator.php
new file mode 100644
index 00000000000..078c0f6d3d7
--- /dev/null
+++ b/src/Datasource/Paging/SimplePaginator.php
@@ -0,0 +1,93 @@
+
+     */
+    protected function getItems(QueryInterface $query, array $data): ResultSetInterface
+    {
+        return $query->limit($data['options']['limit'] + 1)->all();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function buildParams(array $data): array
+    {
+        $hasNextPage = false;
+        if ($this->pagingParams['count'] > $data['options']['limit']) {
+            $hasNextPage = true;
+            $this->pagingParams['count'] -= 1;
+        }
+
+        parent::buildParams($data);
+
+        $this->pagingParams['hasNextPage'] = $hasNextPage;
+
+        return $this->pagingParams;
+    }
+
+    /**
+     * Build paginated result set.
+     *
+     * Since the query fetches an extra record, drop the last record if records
+     * fetched exceeds the limit/per page.
+     *
+     * @param \Cake\Datasource\ResultSetInterface $items
+     * @param array $pagingParams
+     * @return \Cake\Datasource\Paging\PaginatedInterface
+     */
+    protected function buildPaginated(ResultSetInterface $items, array $pagingParams): PaginatedInterface
+    {
+        if (count($items) > $this->pagingParams['perPage']) {
+            $items = $items->take($this->pagingParams['perPage']);
+        }
+
+        return new PaginatedResultSet($items, $pagingParams);
+    }
+
+    /**
+     * Simple pagination does not perform any count query, so this method returns `null`.
+     *
+     * @param \Cake\Datasource\QueryInterface $query Query instance.
+     * @param array $data Pagination data.
+     * @return int|null
+     */
+    protected function getCount(QueryInterface $query, array $data): ?int
+    {
+        return null;
+    }
+}
diff --git a/src/Datasource/Paging/SortField.php b/src/Datasource/Paging/SortField.php
new file mode 100644
index 00000000000..2125bf04cbc
--- /dev/null
+++ b/src/Datasource/Paging/SortField.php
@@ -0,0 +1,119 @@
+field;
+    }
+
+    /**
+     * Get the sort direction to use.
+     *
+     * @param string $requestedDirection The direction requested by the user
+     * @param bool $directionSpecified Whether a direction was explicitly specified
+     * @return string
+     */
+    public function getDirection(string $requestedDirection, bool $directionSpecified): string
+    {
+        if ($this->locked) {
+            return $this->defaultDirection ?? self::ASC;
+        }
+
+        if (!$directionSpecified && $this->defaultDirection) {
+            return $this->defaultDirection;
+        }
+
+        if ($this->defaultDirection === static::DESC) {
+            return $requestedDirection === static::DESC ? static::ASC : static::DESC;
+        }
+
+        return $requestedDirection;
+    }
+
+    /**
+     * Check if the sort direction is locked.
+     *
+     * @return bool
+     */
+    public function isLocked(): bool
+    {
+        return $this->locked;
+    }
+}
diff --git a/src/Datasource/Paging/SortableFieldsBuilder.php b/src/Datasource/Paging/SortableFieldsBuilder.php
new file mode 100644
index 00000000000..3ba64d6c9fc
--- /dev/null
+++ b/src/Datasource/Paging/SortableFieldsBuilder.php
@@ -0,0 +1,269 @@
+|string> The sortable fields map being built
+     */
+    protected array $map = [];
+
+    /**
+     * @var bool Whether this builder represents a simple array format
+     */
+    protected bool $isSimpleArray = false;
+
+    /**
+     * Create builder from various sortableFields configurations.
+     *
+     * @param \Closure|array|null $config The sortableFields configuration
+     * @return static|null Builder instance or null if no config
+     */
+    public static function create(array|Closure|null $config): ?static
+    {
+        if ($config === null) {
+            return null;
+        }
+
+        if ($config instanceof Closure) {
+            return static::fromCallable($config);
+        }
+
+        return static::fromArray($config);
+    }
+
+    /**
+     * Create builder from array configuration.
+     *
+     * Handles both simple array format (['field1', 'field2']) and
+     * associative map format (['key' => 'field', ...]).
+     *
+     * @param array $config Array configuration
+     * @return static
+     */
+    public static function fromArray(array $config): static
+    {
+        $builder = new static();
+        $hasNumericKeys = false;
+
+        // Check if it's a simple array format
+        foreach ($config as $key => $value) {
+            if (is_int($key)) {
+                $hasNumericKeys = true;
+                break;
+            }
+        }
+
+        if ($hasNumericKeys) {
+            // Simple or mixed format - convert numeric keys
+            $builder->isSimpleArray = true;
+            foreach ($config as $key => $value) {
+                if (is_int($key) && is_string($value)) {
+                    // Numeric key with string value: 'field' becomes 'field' => ['field']
+                    $builder->add($value, $value);
+                } else {
+                    // String key: use as-is
+                    $builder->set($key, $value);
+                }
+            }
+        } else {
+            // Associative map format
+            foreach ($config as $key => $value) {
+                $builder->set($key, $value);
+            }
+        }
+
+        return $builder;
+    }
+
+    /**
+     * Create builder from callable factory.
+     *
+     * @param \Closure $factory Closure that receives builder and returns it
+     * @return static
+     */
+    public static function fromCallable(Closure $factory): static
+    {
+        $builder = new static();
+
+        return $factory($builder);
+    }
+
+    /**
+     * Add a sort key with its associated SortField objects.
+     *
+     * @param string $sortKey The sort key name
+     * @param \Cake\Datasource\Paging\SortField|string ...$fields The sort fields to add
+     * @return $this
+     */
+    public function add(string $sortKey, SortField|string ...$fields)
+    {
+        if ($fields === []) {
+            // If no fields provided, use the key as the field name
+            $this->map[$sortKey] = [$sortKey];
+        } else {
+            $this->map[$sortKey] = $fields;
+        }
+
+        return $this;
+    }
+
+    /**
+     * Set a sort key with type-safe validation.
+     *
+     * Internal method used by fromArray() to ensure type safety while preserving
+     * backward compatibility with string and array representations.
+     *
+     * @param string $sortKey The sort key name
+     * @param mixed $value The sort field(s) - can be string, SortField, or array
+     * @return $this
+     */
+    protected function set(string $sortKey, mixed $value)
+    {
+        if (is_string($value)) {
+            $this->map[$sortKey] = $value;
+        } elseif ($value instanceof SortField) {
+            $this->map[$sortKey] = [$value];
+        } elseif (is_array($value)) {
+            $this->add($sortKey, ...$value);
+        } else {
+            throw new InvalidArgumentException(sprintf(
+                'Invalid sortable field value type for key `%s`. Expected string, array, or SortField, got `%s`.',
+                $sortKey,
+                get_debug_type($value),
+            ));
+        }
+
+        return $this;
+    }
+
+    /**
+     * Return the complete sortable fields map.
+     *
+     * @return array|string>
+     */
+    public function toArray(): array
+    {
+        return $this->map;
+    }
+
+    /**
+     * Resolve a sort key to its corresponding ORDER BY clause.
+     *
+     * @param string $sortKey The sort key from URL
+     * @param string $direction The requested direction (asc/desc)
+     * @param bool $directionSpecified Whether direction was explicitly specified
+     * @return array|null Array of field => direction pairs, or null if invalid
+     */
+    public function resolve(
+        string $sortKey,
+        string $direction,
+        bool $directionSpecified = true,
+    ): ?array {
+        // Check if sort key exists in map
+        if (!isset($this->map[$sortKey])) {
+            return null;
+        }
+
+        $mapping = $this->map[$sortKey];
+
+        // Empty array means use key as field
+        if ($mapping === []) {
+            return [$sortKey => $direction];
+        }
+
+        return $this->resolveMapping($mapping, $direction, $directionSpecified);
+    }
+
+    /**
+     * Resolve a mapping configuration to ORDER BY clause.
+     *
+     * @param mixed $mapping The mapping to resolve
+     * @param string $direction The requested direction
+     * @param bool $directionSpecified Whether direction was explicitly specified
+     * @return array Array of field => direction pairs
+     */
+    protected function resolveMapping(mixed $mapping, string $direction, bool $directionSpecified): array
+    {
+        // Single string: 'name' => 'Users.name'
+        if (is_string($mapping)) {
+            return [$mapping => $direction];
+        }
+
+        // Array of fields/SortField objects
+        if (is_array($mapping)) {
+            return $this->resolveArrayMapping($mapping, $direction, $directionSpecified);
+        }
+
+        return [];
+    }
+
+    /**
+     * Resolve an array mapping to ORDER BY clause.
+     *
+     * @param array $fields Array of fields or SortField objects
+     * @param string $direction The requested direction
+     * @param bool $directionSpecified Whether direction was explicitly specified
+     * @return array Array of field => direction pairs
+     */
+    protected function resolveArrayMapping(array $fields, string $direction, bool $directionSpecified): array
+    {
+        $order = [];
+        $shouldInvert = $directionSpecified && $direction === SortField::DESC;
+
+        foreach ($fields as $key => $value) {
+            if ($value instanceof SortField) {
+                // SortField object with locked/default directions
+                $field = $value->getField();
+                $fieldDirection = $value->getDirection($direction, $directionSpecified);
+                $order[$field] = $fieldDirection;
+            } elseif (is_int($key)) {
+                // Numeric array: ['field1', 'field2'] - use requested direction
+                $order[$value] = $direction;
+            } elseif (is_string($value)) {
+                // Associative array with default directions per field
+                // Format: ['field1' => 'ASC', 'field2' => 'DESC']
+                $defaultDirection = strtolower($value);
+
+                if ($shouldInvert) {
+                    // Invert the direction when toggling to desc
+                    $fieldDirection = $defaultDirection === SortField::ASC ? SortField::DESC : SortField::ASC;
+                } else {
+                    // Use default direction (for asc or no direction specified)
+                    $fieldDirection = $defaultDirection;
+                }
+
+                $order[$key] = $fieldDirection;
+            } else {
+                // Fallback for other cases
+                $order[$key] = $direction;
+            }
+        }
+
+        return $order;
+    }
+}
diff --git a/src/Datasource/QueryCacher.php b/src/Datasource/QueryCacher.php
index cf33e640ccd..1c9d0517113 100644
--- a/src/Datasource/QueryCacher.php
+++ b/src/Datasource/QueryCacher.php
@@ -1,4 +1,6 @@
 _key = $key;
-
-        if (!is_string($config) && !($config instanceof CacheEngine)) {
-            throw new RuntimeException('Cache configs must be strings or CacheEngine instances.');
-        }
         $this->_config = $config;
     }
 
@@ -67,14 +59,14 @@ public function __construct($key, $config)
      * Load the cached results from the cache or run the query.
      *
      * @param object $query The query the cache read is for.
-     * @return \Cake\ORM\ResultSet|null Either the cached results or null.
+     * @return mixed|null Either the cached results or null.
      */
-    public function fetch($query)
+    public function fetch(object $query): mixed
     {
         $key = $this->_resolveKey($query);
         $storage = $this->_resolveCacher();
-        $result = $storage->read($key);
-        if (empty($result)) {
+        $result = $storage->get($key);
+        if (!$result) {
             return null;
         }
 
@@ -88,12 +80,12 @@ public function fetch($query)
      * @param \Traversable $results The result set to store.
      * @return bool True if the data was successfully cached, false on failure
      */
-    public function store($query, Traversable $results)
+    public function store(object $query, Traversable $results): bool
     {
         $key = $this->_resolveKey($query);
         $storage = $this->_resolveCacher();
 
-        return $storage->write($key, $results);
+        return $storage->set($key, $results);
     }
 
     /**
@@ -101,9 +93,9 @@ public function store($query, Traversable $results)
      *
      * @param object $query The query to generate a key for.
      * @return string
-     * @throws \RuntimeException
+     * @throws \Cake\Core\Exception\CakeException
      */
-    protected function _resolveKey($query)
+    protected function _resolveKey(object $query): string
     {
         if (is_string($this->_key)) {
             return $this->_key;
@@ -112,7 +104,7 @@ protected function _resolveKey($query)
         $key = $func($query);
         if (!is_string($key)) {
             $msg = sprintf('Cache key functions must return a string. Got %s.', var_export($key, true));
-            throw new RuntimeException($msg);
+            throw new CakeException($msg);
         }
 
         return $key;
@@ -121,12 +113,12 @@ protected function _resolveKey($query)
     /**
      * Get the cache engine.
      *
-     * @return \Cake\Cache\CacheEngine
+     * @return \Psr\SimpleCache\CacheInterface
      */
-    protected function _resolveCacher()
+    protected function _resolveCacher(): CacheInterface
     {
         if (is_string($this->_config)) {
-            return Cache::engine($this->_config);
+            return Cache::pool($this->_config);
         }
 
         return $this->_config;
diff --git a/src/Datasource/QueryInterface.php b/src/Datasource/QueryInterface.php
index 1e1ff46e48a..435126ec16d 100644
--- a/src/Datasource/QueryInterface.php
+++ b/src/Datasource/QueryInterface.php
@@ -1,4 +1,6 @@
  value array representing a single aliased field
@@ -38,19 +54,19 @@ interface QueryInterface
      *
      * @param string $field The field to alias
      * @param string|null $alias the alias used to prefix the field
-     * @return string
+     * @return array
      */
-    public function aliasField($field, $alias = null);
+    public function aliasField(string $field, ?string $alias = null): array;
 
     /**
      * Runs `aliasField()` for each field in the provided list and returns
      * the result under a single array.
      *
-     * @param array $fields The fields to alias
+     * @param array $fields The fields to alias
      * @param string|null $defaultAlias The default alias
-     * @return string[]
+     * @return array
      */
-    public function aliasFields($fields, $defaultAlias = null);
+    public function aliasFields(array $fields, ?string $defaultAlias = null): array;
 
     /**
      * Fetch the results for this query.
@@ -61,9 +77,11 @@ public function aliasFields($fields, $defaultAlias = null);
      * ResultSetDecorator is a traversable object that implements the methods found
      * on Cake\Collection\Collection.
      *
-     * @return \Cake\Datasource\ResultSetInterface
+     * @template TKey of array-key
+     * @template TValue of mixed
+     * @return \Cake\Datasource\ResultSetInterface
      */
-    public function all();
+    public function all(): ResultSetInterface;
 
     /**
      * Populates or adds parts to current query clauses using an array.
@@ -101,7 +119,7 @@ public function all();
      *  ->limit(10)
      * ```
      *
-     * @param array $options list of query clauses to apply new parts to.
+     * @param array $options list of query clauses to apply new parts to.
      * @return $this
      */
     public function applyOptions(array $options);
@@ -119,10 +137,10 @@ public function applyOptions(array $options);
      * a single query.
      *
      * @param string $finder The finder method to use.
-     * @param array $options The options for the finder.
-     * @return $this Returns a modified query.
+     * @param mixed ...$args Arguments that match up to finder-specific parameters
+     * @return static Returns a modified query.
      */
-    public function find($finder, array $options = []);
+    public function find(string $finder, mixed ...$args): static;
 
     /**
      * Returns the first result out of executing this query, if the query has not been
@@ -136,14 +154,14 @@ public function find($finder, array $options = []);
      *
      * @return mixed the first result from the ResultSet
      */
-    public function first();
+    public function first(): mixed;
 
     /**
      * Returns the total amount of results for the query.
      *
      * @return int
      */
-    public function count();
+    public function count(): int;
 
     /**
      * Sets the number of records that should be retrieved from database,
@@ -155,13 +173,13 @@ public function count();
      *
      * ```
      * $query->limit(10) // generates LIMIT 10
-     * $query->limit($query->newExpr()->add(['1 + 1'])); // LIMIT (1 + 1)
+     * $query->limit($query->expr()->add(['1 + 1'])); // LIMIT (1 + 1)
      * ```
      *
-     * @param int $num number of records to be returned
+     * @param int|null $limit number of records to be returned
      * @return $this
      */
-    public function limit($num);
+    public function limit(?int $limit);
 
     /**
      * Sets the number of records that should be skipped from the original result set
@@ -175,13 +193,65 @@ public function limit($num);
      *
      * ```
      *  $query->offset(10) // generates OFFSET 10
-     *  $query->offset($query->newExpr()->add(['1 + 1'])); // OFFSET (1 + 1)
+     *  $query->offset($query->expr()->add(['1 + 1'])); // OFFSET (1 + 1)
+     * ```
+     *
+     * @param int|null $offset number of records to be skipped
+     * @return $this
+     */
+    public function offset(?int $offset);
+
+    /**
+     * Adds a single or multiple fields to be used in the ORDER clause for this query.
+     * Fields can be passed as an array of strings, array of expression
+     * objects, a single expression or a single string.
+     *
+     * If an array is passed, keys will be used as the field itself and the value will
+     * represent the order in which such field should be ordered. When called multiple
+     * times with the same fields as key, the last order definition will prevail over
+     * the others.
+     *
+     * By default this function will append any passed argument to the list of fields
+     * to be selected, unless the second argument is set to true.
+     *
+     * ### Examples:
+     *
+     * ```
+     * $query->orderBy(['title' => 'DESC', 'author_id' => 'ASC']);
+     * ```
+     *
+     * Produces:
+     *
+     * `ORDER BY title DESC, author_id ASC`
+     *
+     * ```
+     * $query
+     *     ->orderBy(['title' => $query->expr('DESC NULLS FIRST')])
+     *     ->orderBy('author_id');
+     * ```
+     *
+     * Will generate:
+     *
+     * `ORDER BY title DESC NULLS FIRST, author_id`
+     *
+     * ```
+     * $expression = $query->expr()->add(['id % 2 = 0']);
+     * $query->orderBy($expression)->orderBy(['title' => 'ASC']);
      * ```
      *
-     * @param int $num number of records to be skipped
+     * Will become:
+     *
+     * `ORDER BY (id %2 = 0), title ASC`
+     *
+     * If you need to set complex expressions as order conditions, you
+     * should use `orderByAsc()` or `orderByDesc()`.
+     *
+     * @param \Closure|array|string $fields fields to be added to the list
+     * @param bool $overwrite whether to reset order with field list or not
      * @return $this
+     * @deprecated 5.0.0 Use orderBy() instead now that CollectionInterface methods are no longer proxied.
      */
-    public function offset($num);
+    public function order(Closure|array|string $fields, bool $overwrite = false);
 
     /**
      * Adds a single or multiple fields to be used in the ORDER clause for this query.
@@ -199,7 +269,7 @@ public function offset($num);
      * ### Examples:
      *
      * ```
-     * $query->order(['title' => 'DESC', 'author_id' => 'ASC']);
+     * $query->orderBy(['title' => 'DESC', 'author_id' => 'ASC']);
      * ```
      *
      * Produces:
@@ -207,7 +277,9 @@ public function offset($num);
      * `ORDER BY title DESC, author_id ASC`
      *
      * ```
-     * $query->order(['title' => 'DESC NULLS FIRST'])->order('author_id');
+     * $query
+     *     ->orderBy(['title' => $query->expr('DESC NULLS FIRST')])
+     *     ->orderBy('author_id');
      * ```
      *
      * Will generate:
@@ -215,8 +287,8 @@ public function offset($num);
      * `ORDER BY title DESC NULLS FIRST, author_id`
      *
      * ```
-     * $expression = $query->newExpr()->add(['id % 2 = 0']);
-     * $query->order($expression)->order(['title' => 'ASC']);
+     * $expression = $query->expr()->add(['id % 2 = 0']);
+     * $query->orderBy($expression)->orderBy(['title' => 'ASC']);
      * ```
      *
      * Will become:
@@ -224,13 +296,13 @@ public function offset($num);
      * `ORDER BY (id %2 = 0), title ASC`
      *
      * If you need to set complex expressions as order conditions, you
-     * should use `orderAsc()` or `orderDesc()`.
+     * should use `orderByAsc()` or `orderByDesc()`.
      *
-     * @param array|string $fields fields to be added to the list
+     * @param \Closure|array|string $fields fields to be added to the list
      * @param bool $overwrite whether to reset order with field list or not
      * @return $this
      */
-    public function order($fields, $overwrite = false);
+    public function orderBy(Closure|array|string $fields, bool $overwrite = false);
 
     /**
      * Set the page of results you want.
@@ -239,30 +311,39 @@ public function order($fields, $overwrite = false);
      * in the record set you want as results. If empty the limit will default to
      * the existing limit clause, and if that too is empty, then `25` will be used.
      *
-     * Pages should start at 1.
+     * Pages must start at 1.
      *
      * @param int $num The page number you want.
      * @param int|null $limit The number of rows you want in the page. If null
      *  the current limit clause will be used.
      * @return $this
+     * @throws \InvalidArgumentException If page number < 1.
      */
-    public function page($num, $limit = null);
+    public function page(int $num, ?int $limit = null);
 
     /**
      * Returns an array representation of the results after executing the query.
      *
      * @return array
      */
-    public function toArray();
+    public function toArray(): array;
+
+    /**
+     * Set the default Table object that will be used by this query
+     * and form the `FROM` clause.
+     *
+     * @param \Cake\Datasource\RepositoryInterface $repository The default repository object to use
+     * @return $this
+     */
+    public function setRepository(RepositoryInterface $repository);
 
     /**
      * Returns the default repository object that will be used by this query,
-     * that is, the repository that will appear in the from clause.
+     * that is, the repository that will appear in the "from" clause.
      *
-     * @param \Cake\Datasource\RepositoryInterface|null $repository The default repository object to use
-     * @return \Cake\Datasource\RepositoryInterface|$this
+     * @return \Cake\Datasource\RepositoryInterface|null $repository The default repository object to use
      */
-    public function repository(RepositoryInterface $repository = null);
+    public function getRepository(): ?RepositoryInterface;
 
     /**
      * Adds a condition or set of conditions to be used in the WHERE clause for this
@@ -276,7 +357,7 @@ public function repository(RepositoryInterface $repository = null);
      * conditions specified using the AND operator. Additionally, values can be
      * expressed using expression objects which can include other query objects.
      *
-     * Any conditions created with this methods can be used with any SELECT, UPDATE
+     * Any conditions created with this method can be used with any SELECT, UPDATE
      * and DELETE type of queries.
      *
      * ### Conditions using operators:
@@ -324,7 +405,7 @@ public function repository(RepositoryInterface $repository = null);
      * ### Using expressions objects:
      *
      * ```
-     *  $exp = $query->newExpr()->add(['id !=' => 100, 'author_id' != 1])->tieWith('OR');
+     *  $exp = $query->expr()->add(['id !=' => 100, 'author_id' != 1])->tieWith('OR');
      *  $query->where(['published' => true], ['published' => 'boolean'])->where($exp);
      * ```
      *
@@ -336,17 +417,17 @@ public function repository(RepositoryInterface $repository = null);
      *
      * ### Adding conditions in multiple steps:
      *
-     * You can use callable functions to construct complex expressions, functions
+     * You can use callback to construct complex expressions, functions
      * receive as first argument a new QueryExpression object and this query instance
-     * as second argument. Functions must return an expression object, that will be
-     * added the list of conditions for the query using the AND operator.
+     * as second argument. Functions must return an expression object that will be
+     * added to the list of conditions for the query using the AND operator.
      *
      * ```
      *  $query
      *  ->where(['title !=' => 'Hello World'])
      *  ->where(function ($exp, $query) {
-     *      $or = $exp->or_(['id' => 1]);
-     *      $and = $exp->and_(['id >' => 2, 'id <' => 10]);
+     *      $or = $exp->or(['id' => 1]);
+     *      $and = $exp->and(['id >' => 2, 'id <' => 10]);
      *  return $or->add($and);
      *  });
      * ```
@@ -368,13 +449,13 @@ public function repository(RepositoryInterface $repository = null);
      * Please note that when using the array notation or the expression objects, all
      * values will be correctly quoted and transformed to the correspondent database
      * data type automatically for you, thus securing your application from SQL injections.
-     * If you use string conditions make sure that your values are correctly quoted.
+     * If you use string conditions, make sure that your values are correctly quoted.
      * The safest thing you can do is to never use string conditions.
      *
-     * @param string|array|callable|null $conditions The conditions to filter on.
-     * @param array $types associative array of type names used to bind values to query
+     * @param \Closure|array|string|null $conditions The conditions to filter on.
+     * @param array $types Associative array of type names used to bind values to query
      * @param bool $overwrite whether to reset conditions with passed list or not
      * @return $this
      */
-    public function where($conditions = null, $types = [], $overwrite = false);
+    public function where(Closure|array|string|null $conditions = null, array $types = [], bool $overwrite = false);
 }
diff --git a/src/Datasource/QueryTrait.php b/src/Datasource/QueryTrait.php
deleted file mode 100644
index 178b95a30a0..00000000000
--- a/src/Datasource/QueryTrait.php
+++ /dev/null
@@ -1,531 +0,0 @@
-_repository;
-        }
-        $this->_repository = $table;
-
-        return $this;
-    }
-
-    /**
-     * Set the result set for a query.
-     *
-     * Setting the resultset of a query will make execute() a no-op. Instead
-     * of executing the SQL query and fetching results, the ResultSet provided to this
-     * method will be returned.
-     *
-     * This method is most useful when combined with results stored in a persistent cache.
-     *
-     * @param \Cake\Datasource\ResultSetInterface $results The results this query should return.
-     * @return $this
-     */
-    public function setResult($results)
-    {
-        $this->_results = $results;
-
-        return $this;
-    }
-
-    /**
-     * Executes this query and returns a results iterator. This function is required
-     * for implementing the IteratorAggregate interface and allows the query to be
-     * iterated without having to call execute() manually, thus making it look like
-     * a result set instead of the query itself.
-     *
-     * @return \Iterator
-     */
-    public function getIterator()
-    {
-        return $this->all();
-    }
-
-    /**
-     * Enable result caching for this query.
-     *
-     * If a query has caching enabled, it will do the following when executed:
-     *
-     * - Check the cache for $key. If there are results no SQL will be executed.
-     *   Instead the cached results will be returned.
-     * - When the cached data is stale/missing the result set will be cached as the query
-     *   is executed.
-     *
-     * ### Usage
-     *
-     * ```
-     * // Simple string key + config
-     * $query->cache('my_key', 'db_results');
-     *
-     * // Function to generate key.
-     * $query->cache(function ($q) {
-     *   $key = serialize($q->clause('select'));
-     *   $key .= serialize($q->clause('where'));
-     *   return md5($key);
-     * });
-     *
-     * // Using a pre-built cache engine.
-     * $query->cache('my_key', $engine);
-     *
-     * // Disable caching
-     * $query->cache(false);
-     * ```
-     *
-     * @param false|string|\Closure $key Either the cache key or a function to generate the cache key.
-     *   When using a function, this query instance will be supplied as an argument.
-     * @param string|\Cake\Cache\CacheEngine $config Either the name of the cache config to use, or
-     *   a cache config instance.
-     * @return $this
-     */
-    public function cache($key, $config = 'default')
-    {
-        if ($key === false) {
-            $this->_cache = null;
-
-            return $this;
-        }
-        $this->_cache = new QueryCacher($key, $config);
-
-        return $this;
-    }
-
-    /**
-     * Returns the current configured query `_eagerLoaded` value
-     *
-     * @return bool
-     */
-    public function isEagerLoaded()
-    {
-        return $this->_eagerLoaded;
-    }
-
-    /**
-     * Sets the query instance to be an eager loaded query. If no argument is
-     * passed, the current configured query `_eagerLoaded` value is returned.
-     *
-     * @deprecated 3.5.0 Use isEagerLoaded() for the getter part instead.
-     * @param bool|null $value Whether or not to eager load.
-     * @return $this|\Cake\ORM\Query
-     */
-    public function eagerLoaded($value = null)
-    {
-        if ($value === null) {
-            return $this->_eagerLoaded;
-        }
-        $this->_eagerLoaded = $value;
-
-        return $this;
-    }
-
-    /**
-     * Returns a key => value array representing a single aliased field
-     * that can be passed directly to the select() method.
-     * The key will contain the alias and the value the actual field name.
-     *
-     * If the field is already aliased, then it will not be changed.
-     * If no $alias is passed, the default table for this query will be used.
-     *
-     * @param string $field The field to alias
-     * @param string|null $alias the alias used to prefix the field
-     * @return array
-     */
-    public function aliasField($field, $alias = null)
-    {
-        $namespaced = strpos($field, '.') !== false;
-        $aliasedField = $field;
-
-        if ($namespaced) {
-            list($alias, $field) = explode('.', $field);
-        }
-
-        if (!$alias) {
-            $alias = $this->repository()->getAlias();
-        }
-
-        $key = sprintf('%s__%s', $alias, $field);
-        if (!$namespaced) {
-            $aliasedField = $alias . '.' . $field;
-        }
-
-        return [$key => $aliasedField];
-    }
-
-    /**
-     * Runs `aliasField()` for each field in the provided list and returns
-     * the result under a single array.
-     *
-     * @param array $fields The fields to alias
-     * @param string|null $defaultAlias The default alias
-     * @return array
-     */
-    public function aliasFields($fields, $defaultAlias = null)
-    {
-        $aliased = [];
-        foreach ($fields as $alias => $field) {
-            if (is_numeric($alias) && is_string($field)) {
-                $aliased += $this->aliasField($field, $defaultAlias);
-                continue;
-            }
-            $aliased[$alias] = $field;
-        }
-
-        return $aliased;
-    }
-
-    /**
-     * Fetch the results for this query.
-     *
-     * Will return either the results set through setResult(), or execute this query
-     * and return the ResultSetDecorator object ready for streaming of results.
-     *
-     * ResultSetDecorator is a traversable object that implements the methods found
-     * on Cake\Collection\Collection.
-     *
-     * @return \Cake\Datasource\ResultSetInterface
-     */
-    public function all()
-    {
-        if ($this->_results !== null) {
-            return $this->_results;
-        }
-
-        if ($this->_cache) {
-            $results = $this->_cache->fetch($this);
-        }
-        if (!isset($results)) {
-            $results = $this->_decorateResults($this->_execute());
-            if ($this->_cache) {
-                $this->_cache->store($this, $results);
-            }
-        }
-        $this->_results = $results;
-
-        return $this->_results;
-    }
-
-    /**
-     * Returns an array representation of the results after executing the query.
-     *
-     * @return array
-     */
-    public function toArray()
-    {
-        return $this->all()->toArray();
-    }
-
-    /**
-     * Register a new MapReduce routine to be executed on top of the database results
-     * Both the mapper and caller callable should be invokable objects.
-     *
-     * The MapReduce routing will only be run when the query is executed and the first
-     * result is attempted to be fetched.
-     *
-     * If the first argument is set to null, it will return the list of previously
-     * registered map reduce routines.
-     *
-     * If the third argument is set to true, it will erase previous map reducers
-     * and replace it with the arguments passed.
-     *
-     * @param callable|null $mapper The mapper callable.
-     * @param callable|null $reducer The reducing function.
-     * @param bool $overwrite Set to true to overwrite existing map + reduce functions.
-     * @return $this|array
-     * @see \Cake\Collection\Iterator\MapReduce for details on how to use emit data to the map reducer.
-     */
-    public function mapReduce(callable $mapper = null, callable $reducer = null, $overwrite = false)
-    {
-        if ($overwrite) {
-            $this->_mapReduce = [];
-        }
-        if ($mapper === null) {
-            return $this->_mapReduce;
-        }
-        $this->_mapReduce[] = compact('mapper', 'reducer');
-
-        return $this;
-    }
-
-    /**
-     * Registers a new formatter callback function that is to be executed when trying
-     * to fetch the results from the database.
-     *
-     * Formatting callbacks will get a first parameter, a `ResultSetDecorator`, that
-     * can be traversed and modified at will.
-     *
-     * Callbacks are required to return an iterator object, which will be used as
-     * the return value for this query's result. Formatter functions are applied
-     * after all the `MapReduce` routines for this query have been executed.
-     *
-     * If the first argument is set to null, it will return the list of previously
-     * registered map reduce routines.
-     *
-     * If the second argument is set to true, it will erase previous formatters
-     * and replace them with the passed first argument.
-     *
-     * ### Example:
-     *
-     * ```
-     * // Return all results from the table indexed by id
-     * $query->select(['id', 'name'])->formatResults(function ($results) {
-     *   return $results->indexBy('id');
-     * });
-     *
-     * // Add a new column to the ResultSet
-     * $query->select(['name', 'birth_date'])->formatResults(function ($results) {
-     *   return $results->map(function ($row) {
-     *     $row['age'] = $row['birth_date']->diff(new DateTime)->y;
-     *     return $row;
-     *   });
-     * });
-     * ```
-     *
-     * @param callable|null $formatter The formatting callable.
-     * @param bool|int $mode Whether or not to overwrite, append or prepend the formatter.
-     * @return $this|array
-     */
-    public function formatResults(callable $formatter = null, $mode = 0)
-    {
-        if ($mode === self::OVERWRITE) {
-            $this->_formatters = [];
-        }
-        if ($formatter === null) {
-            return $this->_formatters;
-        }
-
-        if ($mode === self::PREPEND) {
-            array_unshift($this->_formatters, $formatter);
-
-            return $this;
-        }
-
-        $this->_formatters[] = $formatter;
-
-        return $this;
-    }
-
-    /**
-     * Returns the first result out of executing this query, if the query has not been
-     * executed before, it will set the limit clause to 1 for performance reasons.
-     *
-     * ### Example:
-     *
-     * ```
-     * $singleUser = $query->select(['id', 'username'])->first();
-     * ```
-     *
-     * @return \Cake\Datasource\EntityInterface|array|null The first result from the ResultSet.
-     */
-    public function first()
-    {
-        if ($this->_dirty) {
-            $this->limit(1);
-        }
-
-        return $this->all()->first();
-    }
-
-    /**
-     * Get the first result from the executing query or raise an exception.
-     *
-     * @throws \Cake\Datasource\Exception\RecordNotFoundException When there is no first record.
-     * @return \Cake\Datasource\EntityInterface|array The first result from the ResultSet.
-     */
-    public function firstOrFail()
-    {
-        $entity = $this->first();
-        if (!$entity) {
-            throw new RecordNotFoundException(sprintf(
-                'Record not found in table "%s"',
-                $this->repository()->table()
-            ));
-        }
-
-        return $entity;
-    }
-
-    /**
-     * Returns an array with the custom options that were applied to this query
-     * and that were not already processed by another method in this class.
-     *
-     * ### Example:
-     *
-     * ```
-     *  $query->applyOptions(['doABarrelRoll' => true, 'fields' => ['id', 'name']);
-     *  $query->getOptions(); // Returns ['doABarrelRoll' => true]
-     * ```
-     *
-     * @see \Cake\ORM\Query::applyOptions() to read about the options that will
-     * be processed by this class and not returned by this function
-     * @return array
-     */
-    public function getOptions()
-    {
-        return $this->_options;
-    }
-
-    /**
-     * Enables calling methods from the result set as if they were from this class
-     *
-     * @param string $method the method to call
-     * @param array $arguments list of arguments for the method to call
-     * @return mixed
-     * @throws \BadMethodCallException if no such method exists in result set
-     */
-    public function __call($method, $arguments)
-    {
-        $resultSetClass = $this->_decoratorClass();
-        if (in_array($method, get_class_methods($resultSetClass))) {
-            $results = $this->all();
-
-            return $results->$method(...$arguments);
-        }
-        throw new BadMethodCallException(
-            sprintf('Unknown method "%s"', $method)
-        );
-    }
-
-    /**
-     * Populates or adds parts to current query clauses using an array.
-     * This is handy for passing all query clauses at once.
-     *
-     * @param array $options the options to be applied
-     * @return $this
-     */
-    abstract public function applyOptions(array $options);
-
-    /**
-     * Executes this query and returns a traversable object containing the results
-     *
-     * @return \Traversable
-     */
-    abstract protected function _execute();
-
-    /**
-     * Decorates the results iterator with MapReduce routines and formatters
-     *
-     * @param \Traversable $result Original results
-     * @return \Cake\Datasource\ResultSetInterface
-     */
-    protected function _decorateResults($result)
-    {
-        $decorator = $this->_decoratorClass();
-        foreach ($this->_mapReduce as $functions) {
-            $result = new MapReduce($result, $functions['mapper'], $functions['reducer']);
-        }
-
-        if (!empty($this->_mapReduce)) {
-            $result = new $decorator($result);
-        }
-
-        foreach ($this->_formatters as $formatter) {
-            $result = $formatter($result);
-        }
-
-        if (!empty($this->_formatters) && !($result instanceof $decorator)) {
-            $result = new $decorator($result);
-        }
-
-        return $result;
-    }
-
-    /**
-     * Returns the name of the class to be used for decorating results
-     *
-     * @return string
-     */
-    protected function _decoratorClass()
-    {
-        return 'Cake\Datasource\ResultSetDecorator';
-    }
-}
diff --git a/src/Datasource/README.md b/src/Datasource/README.md
index 9eae4c035da..768e4f4e627 100644
--- a/src/Datasource/README.md
+++ b/src/Datasource/README.md
@@ -11,7 +11,7 @@ interfaces provided by this package.
 
 A repository is a class capable of interfacing with a data source using operations such as
 `find`, `save` and  `delete` by using intermediate query objects for expressing commands to
-the data store and returning Entities as the single result unit of such system.
+the data store and returning Entities as the single result unit of such a system.
 
 In the case of a Relational database, a Repository would be a `Table`, which can be return single
 or multiple `Entity` objects by using a `Query`.
@@ -27,7 +27,7 @@ Additionally, this package provides a few traits and classes you can use in your
 
 * `EntityTrait` - Contains the default implementation for the `EntityInterface`.
 * `QueryTrait` - Exposes the methods for creating a query object capable of returning decoratable collections.
-* `ResultSetDecorator` - Decorates any traversable object so it complies with `ResultSetInterface`.
+* `ResultSetDecorator` - Decorates any traversable object, so it complies with `ResultSetInterface`.
 
 
 ## Connections
@@ -44,13 +44,13 @@ easy:
 ```php
 use Cake\Datasource\ConnectionManager;
 
-ConnectionManager::config('master', [
+ConnectionManager::config('connection-one', [
     'className' => 'MyApp\Connections\CustomConnection',
     'param1' => 'value',
     'param2' => 'another value'
 ]);
 
-ConnectionManager::config('slave', [
+ConnectionManager::config('connection-two', [
     'className' => 'MyApp\Connections\CustomConnection',
     'param1' => 'different value',
     'param2' => 'another value'
@@ -79,4 +79,4 @@ $conn = ConnectionManager::config('other', $connectionInstance);
 
 ## Documentation
 
-Please make sure you check the [official API documentation](https://api.cakephp.org/3.x/namespace-Cake.Datasource.html)
+Please make sure you check the [official API documentation](https://api.cakephp.org/4.x/namespace-Cake.Datasource.html)
diff --git a/src/Datasource/RepositoryInterface.php b/src/Datasource/RepositoryInterface.php
index 1a15d33493b..59d8594d415 100644
--- a/src/Datasource/RepositoryInterface.php
+++ b/src/Datasource/RepositoryInterface.php
@@ -1,4 +1,6 @@
 get($id);
      *
-     * $article = $articles->get($id, ['contain' => ['Comments]]);
+     * $article = $articles->get($id, ['contain' => ['Comments']]);
      * ```
      *
      * @param mixed $primaryKey primary key value to find
-     * @param array|\ArrayAccess $options options accepted by `Table::find()`
+     * @param array|string $finder The finder to use. Passing an options array is deprecated.
+     * @param \Psr\SimpleCache\CacheInterface|string|null $cache The cache config to use.
+     *   Defaults to `null`, i.e. no caching.
+     * @param \Closure|string|null $cacheKey The cache key to use. If not provided
+     *   one will be autogenerated if `$cache` is not null.
      * @throws \Cake\Datasource\Exception\RecordNotFoundException if the record with such id
      * could not be found
      * @return \Cake\Datasource\EntityInterface
      * @see \Cake\Datasource\RepositoryInterface::find()
      */
-    public function get($primaryKey, $options = []);
+    public function get(
+        mixed $primaryKey,
+        array|string $finder = 'all',
+        CacheInterface|string|null $cache = null,
+        Closure|string|null $cacheKey = null,
+        mixed ...$args,
+    ): EntityInterface;
 
     /**
      * Creates a new Query instance for this repository
      *
-     * @return \Cake\ORM\Query
+     * @return \Cake\Datasource\QueryInterface
      */
-    public function query();
+    public function query(): QueryInterface;
 
     /**
      * Update all matching records.
      *
      * Sets the $fields to the provided values based on $conditions.
-     * This method will *not* trigger beforeSave/afterSave events. If you need those
+     * This method will *not* trigger beforeSave/afterSave events. If you need those,
      * first load a collection of records and update them.
      *
-     * @param string|array|callable|\Cake\Database\Expression\QueryExpression $fields A hash of field => new value.
-     * @param mixed $conditions Conditions to be used, accepts anything Query::where()
+     * @param \Closure|array|string $fields A hash of field => new value.
+     * @param \Closure|array|string|null $conditions Conditions to be used, accepts anything Query::where()
      * can take.
      * @return int Count Returns the affected rows.
      */
-    public function updateAll($fields, $conditions);
+    public function updateAll(Closure|array|string $fields, Closure|array|string|null $conditions): int;
 
     /**
      * Deletes all records matching the provided conditions.
      *
      * This method will *not* trigger beforeDelete/afterDelete events. If you
-     * need those first load a collection of records and delete them.
+     * need those, first load a collection of records and delete them.
      *
      * This method will *not* execute on associations' `cascade` attribute. You should
      * use database foreign keys + ON CASCADE rules if you need cascading deletes combined
      * with this method.
      *
-     * @param mixed $conditions Conditions to be used, accepts anything Query::where()
+     * @param \Closure|array|string|null $conditions Conditions to be used, accepts anything Query::where()
      * can take.
      * @return int Returns the number of affected rows.
      * @see \Cake\Datasource\RepositoryInterface::delete()
      */
-    public function deleteAll($conditions);
+    public function deleteAll(Closure|array|string|null $conditions): int;
 
     /**
      * Returns true if there is any record in this repository matching the specified
      * conditions.
      *
-     * @param array|\ArrayAccess $conditions list of conditions to pass to the query
+     * @param \Closure|array|string|null $conditions list of conditions to pass to the query
      * @return bool
      */
-    public function exists($conditions);
+    public function exists(Closure|array|string|null $conditions): bool;
 
     /**
      * Persists an entity based on the fields that are marked as dirty and
@@ -131,10 +168,10 @@ public function exists($conditions);
      * of any error.
      *
      * @param \Cake\Datasource\EntityInterface $entity the entity to be saved
-     * @param array|\ArrayAccess $options The options to use when saving.
+     * @param array $options The options to use when saving.
      * @return \Cake\Datasource\EntityInterface|false
      */
-    public function save(EntityInterface $entity, $options = []);
+    public function save(EntityInterface $entity, array $options = []): EntityInterface|false;
 
     /**
      * Delete a single entity.
@@ -143,10 +180,21 @@ public function save(EntityInterface $entity, $options = []);
      * based on the 'dependent' option used when defining the association.
      *
      * @param \Cake\Datasource\EntityInterface $entity The entity to remove.
-     * @param array|\ArrayAccess $options The options for the delete.
+     * @param array $options The options for the delete.
      * @return bool success
      */
-    public function delete(EntityInterface $entity, $options = []);
+    public function delete(EntityInterface $entity, array $options = []): bool;
+
+    /**
+     * This creates a new entity object.
+     *
+     * Careful: This does not trigger any field validation.
+     * This entity can be persisted without validation error as empty record.
+     * Always patch in required fields before saving.
+     *
+     * @return \Cake\Datasource\EntityInterface
+     */
+    public function newEmptyEntity(): EntityInterface;
 
     /**
      * Create a new entity + associated entities from an array.
@@ -162,11 +210,11 @@ public function delete(EntityInterface $entity, $options = []);
      * on the primary key data existing in the database when the entity
      * is saved. Until the entity is saved, it will be a detached record.
      *
-     * @param array|null $data The data to build an entity with.
-     * @param array $options A list of options for the object hydration.
+     * @param array $data The data to build an entity with.
+     * @param array $options A list of options for the object hydration.
      * @return \Cake\Datasource\EntityInterface
      */
-    public function newEntity($data = null, array $options = []);
+    public function newEntity(array $data, array $options = []): EntityInterface;
 
     /**
      * Create a list of entities + associated entities from an array.
@@ -181,10 +229,10 @@ public function newEntity($data = null, array $options = []);
      * The hydrated entities can then be iterated and saved.
      *
      * @param array $data The data to build an entity with.
-     * @param array $options A list of options for the objects hydration.
-     * @return \Cake\Datasource\EntityInterface[] An array of hydrated records.
+     * @param array $options A list of options for the objects hydration.
+     * @return array<\Cake\Datasource\EntityInterface> An array of hydrated records.
      */
-    public function newEntities(array $data, array $options = []);
+    public function newEntities(array $data, array $options = []): array;
 
     /**
      * Merges the passed `$data` into `$entity` respecting the accessible
@@ -200,10 +248,10 @@ public function newEntities(array $data, array $options = []);
      * @param \Cake\Datasource\EntityInterface $entity the entity that will get the
      * data merged in
      * @param array $data key value list of fields to be merged into the entity
-     * @param array $options A list of options for the object hydration.
+     * @param array $options A list of options for the object hydration.
      * @return \Cake\Datasource\EntityInterface
      */
-    public function patchEntity(EntityInterface $entity, array $data, array $options = []);
+    public function patchEntity(EntityInterface $entity, array $data, array $options = []): EntityInterface;
 
     /**
      * Merges each of the elements passed in `$data` into the entities
@@ -217,11 +265,11 @@ public function patchEntity(EntityInterface $entity, array $data, array $options
      * $article = $this->Articles->patchEntities($articles, $this->request->getData());
      * ```
      *
-     * @param \Cake\Datasource\EntityInterface[]|\Traversable $entities the entities that will get the
+     * @param iterable<\Cake\Datasource\EntityInterface> $entities the entities that will get the
      * data merged in
      * @param array $data list of arrays to be merged into the entities
-     * @param array $options A list of options for the objects hydration.
-     * @return \Cake\Datasource\EntityInterface[]
+     * @param array $options A list of options for the objects hydration.
+     * @return array<\Cake\Datasource\EntityInterface>
      */
-    public function patchEntities($entities, array $data, array $options = []);
+    public function patchEntities(iterable $entities, array $data, array $options = []): array;
 }
diff --git a/src/Datasource/ResultSetDecorator.php b/src/Datasource/ResultSetDecorator.php
index 6d6f626c1e3..688cc6c8bff 100644
--- a/src/Datasource/ResultSetDecorator.php
+++ b/src/Datasource/ResultSetDecorator.php
@@ -1,4 +1,6 @@
 
+ * @implements \Cake\Datasource\ResultSetInterface
  */
 class ResultSetDecorator extends Collection implements ResultSetInterface
 {
-
     /**
-     * Make this object countable.
-     *
-     * Part of the Countable interface. Calling this method
-     * will convert the underlying traversable object into an array and
-     * get the count of the underlying data.
-     *
-     * @return int
+     * @inheritDoc
      */
-    public function count()
+    public function __debugInfo(): array
     {
-        if ($this->getInnerIterator() instanceof Countable) {
-            return $this->getInnerIterator()->count();
-        }
+        $parentInfo = parent::__debugInfo();
+        $limit = Configure::read('App.ResultSetDebugLimit', 10);
 
-        return count($this->toArray());
+        return array_merge($parentInfo, ['items' => $this->take($limit)->toArray()]);
     }
 }
diff --git a/src/Datasource/ResultSetInterface.php b/src/Datasource/ResultSetInterface.php
index f6166e08b75..29a7813d337 100644
--- a/src/Datasource/ResultSetInterface.php
+++ b/src/Datasource/ResultSetInterface.php
@@ -1,4 +1,6 @@
 
  */
-interface ResultSetInterface extends CollectionInterface, Countable, Serializable
+interface ResultSetInterface extends CollectionInterface
 {
 }
diff --git a/src/Datasource/RuleInvoker.php b/src/Datasource/RuleInvoker.php
index cfb8c144419..bfd78946aa3 100644
--- a/src/Datasource/RuleInvoker.php
+++ b/src/Datasource/RuleInvoker.php
@@ -1,4 +1,6 @@
 
      */
-    protected $options = [];
+    protected array $options = [];
 
     /**
      * Rule callable
@@ -59,10 +63,10 @@ class RuleInvoker
      * rule $scope.
      *
      * @param callable $rule The rule to be invoked.
-     * @param string $name The name of the rule. Used in error messsages.
-     * @param array $options The options for the rule. See above.
+     * @param string|null $name The name of the rule. Used in error messages.
+     * @param array $options The options for the rule. See above.
      */
-    public function __construct(callable $rule, $name, array $options = [])
+    public function __construct(callable $rule, ?string $name, array $options = [])
     {
         $this->rule = $rule;
         $this->name = $name;
@@ -74,7 +78,7 @@ public function __construct(callable $rule, $name, array $options = [])
      *
      * Old options will be merged with the new ones.
      *
-     * @param array $options The options to set.
+     * @param array $options The options to set.
      * @return $this
      */
     public function setOptions(array $options)
@@ -89,10 +93,10 @@ public function setOptions(array $options)
      *
      * Only truthy names will be set.
      *
-     * @param string $name The name to set.
+     * @param string|null $name The name to set.
      * @return $this
      */
-    public function setName($name)
+    public function setName(?string $name)
     {
         if ($name) {
             $this->name = $name;
@@ -107,36 +111,37 @@ public function setName($name)
      * @param \Cake\Datasource\EntityInterface $entity The entity the rule
      *   should apply to.
      * @param array $scope The rule's scope/options.
-     * @return bool Whether or not the rule passed.
+     * @return bool Whether the rule passed.
      */
-    public function __invoke($entity, $scope)
+    public function __invoke(EntityInterface $entity, array $scope): bool
     {
         $rule = $this->rule;
         $pass = $rule($entity, $this->options + $scope);
-        if ($pass === true || empty($this->options['errorField'])) {
-            return $pass === true;
+        if ($pass === true) {
+            return true;
         }
 
-        $message = 'invalid';
-        if (isset($this->options['message'])) {
-            $message = $this->options['message'];
-        }
+        $message = $this->options['message'] ?? 'invalid';
         if (is_string($pass)) {
             $message = $pass;
         }
+        if ($message instanceof Closure) {
+            $message = $message($entity, $this->options + $scope);
+        }
         if ($this->name) {
             $message = [$this->name => $message];
         } else {
             $message = [$message];
         }
-        $errorField = $this->options['errorField'];
-        $entity->errors($errorField, $message);
+
+        $errorField = $this->options['errorField'] ?? ($this->name ?? '_rule');
+        $entity->setError($errorField, $message);
 
         if ($entity instanceof InvalidPropertyInterface && isset($entity->{$errorField})) {
             $invalidValue = $entity->{$errorField};
             $entity->setInvalidField($errorField, $invalidValue);
         }
 
-        return $pass === true;
+        return false;
     }
 }
diff --git a/src/Datasource/RulesAwareTrait.php b/src/Datasource/RulesAwareTrait.php
index 98c161dadbd..8f74ebdeeab 100644
--- a/src/Datasource/RulesAwareTrait.php
+++ b/src/Datasource/RulesAwareTrait.php
@@ -1,4 +1,6 @@
 |array|null $options The options To be passed to the rules.
      * @return bool
      */
-    public function checkRules(EntityInterface $entity, $operation = RulesChecker::CREATE, $options = null)
-    {
+    public function checkRules(
+        EntityInterface $entity,
+        string $operation = RulesChecker::CREATE,
+        ArrayObject|array|null $options = null,
+    ): bool {
         $rules = $this->rulesChecker();
         $options = $options ?: new ArrayObject();
         $options = is_array($options) ? new ArrayObject($options) : $options;
@@ -56,7 +60,7 @@ public function checkRules(EntityInterface $entity, $operation = RulesChecker::C
         if ($hasEvents) {
             $event = $this->dispatchEvent(
                 'Model.beforeRules',
-                compact('entity', 'options', 'operation')
+                compact('entity', 'options', 'operation'),
             );
             if ($event->isStopped()) {
                 return $event->getResult();
@@ -68,7 +72,7 @@ public function checkRules(EntityInterface $entity, $operation = RulesChecker::C
         if ($hasEvents) {
             $event = $this->dispatchEvent(
                 'Model.afterRules',
-                compact('entity', 'options', 'result', 'operation')
+                compact('entity', 'options', 'result', 'operation'),
             );
 
             if ($event->isStopped()) {
@@ -89,12 +93,16 @@ public function checkRules(EntityInterface $entity, $operation = RulesChecker::C
      * @see \Cake\Datasource\RulesChecker
      * @return \Cake\Datasource\RulesChecker
      */
-    public function rulesChecker()
+    public function rulesChecker(): RulesChecker
     {
         if ($this->_rulesChecker !== null) {
             return $this->_rulesChecker;
         }
-        $class = defined('static::RULES_CLASS') ? static::RULES_CLASS : 'Cake\Datasource\RulesChecker';
+        /** @var class-string<\Cake\Datasource\RulesChecker> $class */
+        $class = defined('static::RULES_CLASS') ? static::RULES_CLASS : RulesChecker::class;
+        /**
+         * @phpstan-ignore-next-line
+         */
         $this->_rulesChecker = $this->buildRules(new $class(['repository' => $this]));
         $this->dispatchEvent('Model.buildRules', ['rules' => $this->_rulesChecker]);
 
@@ -110,7 +118,7 @@ public function rulesChecker()
      * @param \Cake\Datasource\RulesChecker $rules The rules object to be modified.
      * @return \Cake\Datasource\RulesChecker
      */
-    public function buildRules(RulesChecker $rules)
+    public function buildRules(RulesChecker $rules): RulesChecker
     {
         return $rules;
     }
diff --git a/src/Datasource/RulesChecker.php b/src/Datasource/RulesChecker.php
index f0bfedef925..83ef8edeabc 100644
--- a/src/Datasource/RulesChecker.php
+++ b/src/Datasource/RulesChecker.php
@@ -1,4 +1,6 @@
 
      */
-    protected $_rules = [];
+    protected array $_rules = [];
 
     /**
      * The list of rules to check during create operations
      *
-     * @var callable[]
+     * @var array<\Cake\Datasource\RuleInvoker>
      */
-    protected $_createRules = [];
+    protected array $_createRules = [];
 
     /**
      * The list of rules to check during update operations
      *
-     * @var callable[]
+     * @var array<\Cake\Datasource\RuleInvoker>
      */
-    protected $_updateRules = [];
+    protected array $_updateRules = [];
 
     /**
      * The list of rules to check during delete operations
      *
-     * @var callable[]
+     * @var array<\Cake\Datasource\RuleInvoker>
      */
-    protected $_deleteRules = [];
+    protected array $_deleteRules = [];
 
     /**
      * List of options to pass to every callable rule
      *
      * @var array
      */
-    protected $_options = [];
+    protected array $_options = [];
 
     /**
-     * Whether or not to use I18n functions for translating default error messages
+     * Whether to use I18n functions for translating default error messages
      *
      * @var bool
      */
-    protected $_useI18n = false;
+    protected bool $_useI18n = false;
 
     /**
      * Constructor. Takes the options to be passed to all rules.
      *
-     * @param array $options The options to pass to every rule
+     * @param array $options The options to pass to every rule
      */
     public function __construct(array $options = [])
     {
         $this->_options = $options;
-        $this->_useI18n = function_exists('__d');
+        $this->_useI18n = function_exists('\Cake\I18n\__d');
     }
 
     /**
-     * Adds a rule that will be applied to the entity both on create and update
+     * Adds a rule that will be applied to the entity on create, update and delete
      * operations.
      *
      * ### Options
@@ -127,14 +130,34 @@ public function __construct(array $options = [])
      *
      * @param callable $rule A callable function or object that will return whether
      * the entity is valid or not.
-     * @param string|null $name The alias for a rule.
-     * @param array $options List of extra options to pass to the rule callable as
+     * @param array|string|null $name The alias for a rule, or an array of options.
+     * @param array $options List of extra options to pass to the rule callable as
      * second argument.
      * @return $this
+     * @throws \Cake\Core\Exception\CakeException If a rule with the same name already exists
      */
-    public function add(callable $rule, $name = null, array $options = [])
+    public function add(callable $rule, array|string|null $name = null, array $options = [])
     {
-        $this->_rules[] = $this->_addError($rule, $name, $options);
+        if (is_string($name)) {
+            $this->checkName($name, $this->_rules);
+            $this->_rules[$name] = $this->_addError($rule, $name, $options);
+        } else {
+            $this->_rules[] = $this->_addError($rule, $name, $options);
+        }
+
+        return $this;
+    }
+
+    /**
+     * Removes a rule from the set.
+     *
+     * @param string $name The name of the rule to remove.
+     * @return $this
+     * @since 5.1.0
+     */
+    public function remove(string $name)
+    {
+        unset($this->_rules[$name]);
 
         return $this;
     }
@@ -152,14 +175,34 @@ public function add(callable $rule, $name = null, array $options = [])
      *
      * @param callable $rule A callable function or object that will return whether
      * the entity is valid or not.
-     * @param string|null $name The alias for a rule.
-     * @param array $options List of extra options to pass to the rule callable as
+     * @param array|string|null $name The alias for a rule or an array of options.
+     * @param array $options List of extra options to pass to the rule callable as
      * second argument.
      * @return $this
+     * @throws \Cake\Core\Exception\CakeException If a rule with the same name already exists
      */
-    public function addCreate(callable $rule, $name = null, array $options = [])
+    public function addCreate(callable $rule, array|string|null $name = null, array $options = [])
     {
-        $this->_createRules[] = $this->_addError($rule, $name, $options);
+        if (is_string($name)) {
+            $this->checkName($name, $this->_createRules);
+            $this->_createRules[$name] = $this->_addError($rule, $name, $options);
+        } else {
+            $this->_createRules[] = $this->_addError($rule, $name, $options);
+        }
+
+        return $this;
+    }
+
+    /**
+     * Removes a rule from the create set.
+     *
+     * @param string $name The name of the rule to remove.
+     * @return $this
+     * @since 5.1.0
+     */
+    public function removeCreate(string $name)
+    {
+        unset($this->_createRules[$name]);
 
         return $this;
     }
@@ -177,14 +220,34 @@ public function addCreate(callable $rule, $name = null, array $options = [])
      *
      * @param callable $rule A callable function or object that will return whether
      * the entity is valid or not.
-     * @param string|null $name The alias for a rule.
-     * @param array $options List of extra options to pass to the rule callable as
+     * @param array|string|null $name The alias for a rule, or an array of options.
+     * @param array $options List of extra options to pass to the rule callable as
      * second argument.
      * @return $this
+     * @throws \Cake\Core\Exception\CakeException If a rule with the same name already exists
+     */
+    public function addUpdate(callable $rule, array|string|null $name = null, array $options = [])
+    {
+        if (is_string($name)) {
+            $this->checkName($name, $this->_updateRules);
+            $this->_updateRules[$name] = $this->_addError($rule, $name, $options);
+        } else {
+            $this->_updateRules[] = $this->_addError($rule, $name, $options);
+        }
+
+        return $this;
+    }
+
+    /**
+     * Removes a rule from the update set.
+     *
+     * @param string $name The name of the rule to remove.
+     * @return $this
+     * @since 5.1.0
      */
-    public function addUpdate(callable $rule, $name = null, array $options = [])
+    public function removeUpdate(string $name)
     {
-        $this->_updateRules[] = $this->_addError($rule, $name, $options);
+        unset($this->_updateRules[$name]);
 
         return $this;
     }
@@ -202,14 +265,34 @@ public function addUpdate(callable $rule, $name = null, array $options = [])
      *
      * @param callable $rule A callable function or object that will return whether
      * the entity is valid or not.
-     * @param string|null $name The alias for a rule.
-     * @param array $options List of extra options to pass to the rule callable as
+     * @param array|string|null $name The alias for a rule, or an array of options.
+     * @param array $options List of extra options to pass to the rule callable as
      * second argument.
      * @return $this
+     * @throws \Cake\Core\Exception\CakeException If a rule with the same name already exists
      */
-    public function addDelete(callable $rule, $name = null, array $options = [])
+    public function addDelete(callable $rule, array|string|null $name = null, array $options = [])
     {
-        $this->_deleteRules[] = $this->_addError($rule, $name, $options);
+        if (is_string($name)) {
+            $this->checkName($name, $this->_deleteRules);
+            $this->_deleteRules[$name] = $this->_addError($rule, $name, $options);
+        } else {
+            $this->_deleteRules[] = $this->_addError($rule, $name, $options);
+        }
+
+        return $this;
+    }
+
+    /**
+     * Removes a rule from the delete set.
+     *
+     * @param string $name The name of the rule to remove.
+     * @return $this
+     * @since 5.1.0
+     */
+    public function removeDelete(string $name)
+    {
+        unset($this->_deleteRules[$name]);
 
         return $this;
     }
@@ -221,25 +304,18 @@ public function addDelete(callable $rule, $name = null, array $options = [])
      *
      * @param \Cake\Datasource\EntityInterface $entity The entity to check for validity.
      * @param string $mode Either 'create, 'update' or 'delete'.
-     * @param array $options Extra options to pass to checker functions.
+     * @param array $options Extra options to pass to checker functions.
      * @return bool
      * @throws \InvalidArgumentException if an invalid mode is passed.
      */
-    public function check(EntityInterface $entity, $mode, array $options = [])
+    public function check(EntityInterface $entity, string $mode, array $options = []): bool
     {
-        if ($mode === self::CREATE) {
-            return $this->checkCreate($entity, $options);
-        }
-
-        if ($mode === self::UPDATE) {
-            return $this->checkUpdate($entity, $options);
-        }
-
-        if ($mode === self::DELETE) {
-            return $this->checkDelete($entity, $options);
-        }
-
-        throw new InvalidArgumentException('Wrong checking mode: ' . $mode);
+        return match ($mode) {
+            self::CREATE => $this->checkCreate($entity, $options),
+            self::UPDATE => $this->checkUpdate($entity, $options),
+            self::DELETE => $this->checkDelete($entity, $options),
+            default => throw new InvalidArgumentException('Wrong checking mode: ' . $mode),
+        };
     }
 
     /**
@@ -247,12 +323,16 @@ public function check(EntityInterface $entity, $mode, array $options = [])
      * of them pass. The rules selected will be only those specified to be run on 'create'
      *
      * @param \Cake\Datasource\EntityInterface $entity The entity to check for validity.
-     * @param array $options Extra options to pass to checker functions.
+     * @param array $options Extra options to pass to checker functions.
      * @return bool
      */
-    public function checkCreate(EntityInterface $entity, array $options = [])
+    public function checkCreate(EntityInterface $entity, array $options = []): bool
     {
-        return $this->_checkRules($entity, $options, array_merge($this->_rules, $this->_createRules));
+        return $this->_checkRules(
+            $entity,
+            $options,
+            array_merge(array_values($this->_rules), array_values($this->_createRules)),
+        );
     }
 
     /**
@@ -260,12 +340,16 @@ public function checkCreate(EntityInterface $entity, array $options = [])
      * of them pass. The rules selected will be only those specified to be run on 'update'
      *
      * @param \Cake\Datasource\EntityInterface $entity The entity to check for validity.
-     * @param array $options Extra options to pass to checker functions.
+     * @param array $options Extra options to pass to checker functions.
      * @return bool
      */
-    public function checkUpdate(EntityInterface $entity, array $options = [])
+    public function checkUpdate(EntityInterface $entity, array $options = []): bool
     {
-        return $this->_checkRules($entity, $options, array_merge($this->_rules, $this->_updateRules));
+        return $this->_checkRules(
+            $entity,
+            $options,
+            array_merge(array_values($this->_rules), array_values($this->_updateRules)),
+        );
     }
 
     /**
@@ -273,10 +357,10 @@ public function checkUpdate(EntityInterface $entity, array $options = [])
      * of them pass. The rules selected will be only those specified to be run on 'delete'
      *
      * @param \Cake\Datasource\EntityInterface $entity The entity to check for validity.
-     * @param array $options Extra options to pass to checker functions.
+     * @param array $options Extra options to pass to checker functions.
      * @return bool
      */
-    public function checkDelete(EntityInterface $entity, array $options = [])
+    public function checkDelete(EntityInterface $entity, array $options = []): bool
     {
         return $this->_checkRules($entity, $options, $this->_deleteRules);
     }
@@ -286,11 +370,11 @@ public function checkDelete(EntityInterface $entity, array $options = [])
      * iterates an array containing the rules to be checked and checks them all.
      *
      * @param \Cake\Datasource\EntityInterface $entity The entity to check for validity.
-     * @param array $options Extra options to pass to checker functions.
-     * @param array $rules The list of rules that must be checked.
+     * @param array $options Extra options to pass to checker functions.
+     * @param array<\Cake\Datasource\RuleInvoker> $rules The list of rules that must be checked.
      * @return bool
      */
-    protected function _checkRules(EntityInterface $entity, array $options = [], array $rules = [])
+    protected function _checkRules(EntityInterface $entity, array $options = [], array $rules = []): bool
     {
         $success = true;
         $options += $this->_options;
@@ -306,11 +390,11 @@ protected function _checkRules(EntityInterface $entity, array $options = [], arr
      * property in the entity is marked as invalid.
      *
      * @param callable $rule The rule to decorate
-     * @param string $name The alias for a rule.
-     * @param array $options The options containing the error message and field.
-     * @return callable
+     * @param array|string|null $name The alias for a rule or an array of options
+     * @param array $options The options containing the error message and field.
+     * @return \Cake\Datasource\RuleInvoker
      */
-    protected function _addError($rule, $name, $options)
+    protected function _addError(callable $rule, array|string|null $name = null, array $options = []): RuleInvoker
     {
         if (is_array($name)) {
             $options = $name;
@@ -325,4 +409,19 @@ protected function _addError($rule, $name, $options)
 
         return $rule;
     }
+
+    /**
+     * Checks that a rule with the same name doesn't already exist
+     *
+     * @param string $name The name to check
+     * @param array<\Cake\Datasource\RuleInvoker> $rules The rules array to check
+     * @return void
+     * @throws \Cake\Core\Exception\CakeException
+     */
+    protected function checkName(string $name, array $rules): void
+    {
+        if (array_key_exists($name, $rules)) {
+            throw new CakeException("A rule with the name `{$name}` already exists");
+        }
+    }
 }
diff --git a/src/Datasource/SchemaInterface.php b/src/Datasource/SchemaInterface.php
index 997ffa1e467..6602750306e 100644
--- a/src/Datasource/SchemaInterface.php
+++ b/src/Datasource/SchemaInterface.php
@@ -1,16 +1,18 @@
 |string $attrs The attributes for the column or the type name.
      * @return $this
      */
-    public function addColumn($name, $attrs);
+    public function addColumn(string $name, array|string $attrs);
 
     /**
      * Get column data in the table.
      *
      * @param string $name The column name.
-     * @return array|null Column data or null.
+     * @return array|null Column data or null.
      */
-    public function getColumn($name);
+    public function getColumn(string $name): ?array;
 
     /**
      * Returns true if a column exists in the schema.
@@ -71,7 +72,7 @@ public function getColumn($name);
      * @param string $name Column name.
      * @return bool
      */
-    public function hasColumn($name);
+    public function hasColumn(string $name): bool;
 
     /**
      * Remove a column from the table schema.
@@ -81,14 +82,14 @@ public function hasColumn($name);
      * @param string $name The name of the column
      * @return $this
      */
-    public function removeColumn($name);
+    public function removeColumn(string $name);
 
     /**
      * Get the column names in the table.
      *
-     * @return array
+     * @return array
      */
-    public function columns();
+    public function columns(): array;
 
     /**
      * Returns column type or null if a column does not exist.
@@ -96,51 +97,51 @@ public function columns();
      * @param string $name The column to get the type of.
      * @return string|null
      */
-    public function getColumnType($name);
+    public function getColumnType(string $name): ?string;
 
     /**
-     * Sets the type of a column.
+     * Sets the type of column.
      *
      * @param string $name The column to set the type of.
      * @param string $type The type to set the column to.
      * @return $this
      */
-    public function setColumnType($name, $type);
+    public function setColumnType(string $name, string $type);
 
     /**
      * Returns the base type name for the provided column.
-     * This represent the database type a more complex class is
+     * This represents the database type a more complex class is
      * based upon.
      *
      * @param string $column The column name to get the base type from
      * @return string|null The base type name
      */
-    public function baseColumnType($column);
+    public function baseColumnType(string $column): ?string;
 
     /**
-     * Check whether or not a field is nullable
+     * Check whether a field is nullable
      *
      * Missing columns are nullable.
      *
      * @param string $name The column to get the type of.
-     * @return bool Whether or not the field is nullable.
+     * @return bool Whether the field is nullable.
      */
-    public function isNullable($name);
+    public function isNullable(string $name): bool;
 
     /**
      * Returns an array where the keys are the column names in the schema
      * and the values the database type they have.
      *
-     * @return array
+     * @return array
      */
-    public function typeMap();
+    public function typeMap(): array;
 
     /**
      * Get a hash of columns and their default values.
      *
-     * @return array
+     * @return array
      */
-    public function defaultValues();
+    public function defaultValues(): array;
 
     /**
      * Sets the options for a table.
@@ -148,10 +149,10 @@ public function defaultValues();
      * Table options allow you to set platform specific table level options.
      * For example the engine type in MySQL.
      *
-     * @param array $options The options to set, or null to read options.
+     * @param array $options The options to set, or null to read options.
      * @return $this
      */
-    public function setOptions($options);
+    public function setOptions(array $options);
 
     /**
      * Gets the options for a table.
@@ -159,7 +160,7 @@ public function setOptions($options);
      * Table options allow you to set platform specific table level options.
      * For example the engine type in MySQL.
      *
-     * @return array An array of options.
+     * @return array An array of options.
      */
-    public function getOptions();
+    public function getOptions(): array;
 }
diff --git a/src/Datasource/TableSchemaInterface.php b/src/Datasource/TableSchemaInterface.php
deleted file mode 100644
index 7ea28994341..00000000000
--- a/src/Datasource/TableSchemaInterface.php
+++ /dev/null
@@ -1,33 +0,0 @@
-=5.6.0",
-        "cakephp/core": "^3.0.0"
+        "php": ">=8.2",
+        "cakephp/core": "^5.4.0",
+        "psr/simple-cache": "^2.0 || ^3.0"
+    },
+    "require-dev": {
+        "cakephp/cache": "^5.4.0",
+        "cakephp/collection": "^5.4.0",
+        "cakephp/utility": "^5.4.0"
+    },
+    "autoload": {
+        "psr-4": {
+            "Cake\\Datasource\\": "."
+        }
     },
     "suggest": {
         "cakephp/utility": "If you decide to use EntityTrait.",
         "cakephp/collection": "If you decide to use ResultSetInterface.",
         "cakephp/cache": "If you decide to use Query caching."
     },
-    "autoload": {
-        "psr-4": {
-            "Cake\\Datasource\\": "."
+    "minimum-stability": "dev",
+    "prefer-stable": true,
+    "extra": {
+        "branch-alias": {
+            "dev-5.next": "5.5.x-dev"
         }
     }
 }
diff --git a/src/Datasource/phpstan.neon.dist b/src/Datasource/phpstan.neon.dist
new file mode 100644
index 00000000000..3feb26869e7
--- /dev/null
+++ b/src/Datasource/phpstan.neon.dist
@@ -0,0 +1,33 @@
+parameters:
+	level: 8
+	treatPhpDocTypesAsCertain: false
+	bootstrapFiles:
+		- tests/phpstan-bootstrap.php
+	paths:
+		- ./
+	excludePaths:
+		- vendor/
+	ignoreErrors:
+		-
+			identifier: trait.unused
+		-
+			identifier: missingType.iterableValue
+		- '#Class Cake\\Database\\Driver\\.+ not found.#'
+		- '#Class Cake\\Database\\Connection not found.#'
+		- '#Method Cake\\Datasource\\QueryInterface::aliasFields\(\) has invalid return type Cake\\Database\\Expression\\IdentifierExpression.#'
+		- '#Parameter \$fields of method Cake\\Datasource\\QueryInterface::aliasFields\(\) has invalid type Cake\\Database\\Expression\\IdentifierExpression.#'
+		-
+			message: '#^Unsafe usage of new static\(\)\.$#'
+			identifier: new.static
+			count: 2
+			path: Paging/SortableFieldsBuilder.php
+		-
+			message: '#^Template type TKey of method Cake\\Datasource\\QueryInterface\:\:all\(\) is not referenced in a parameter\.$#'
+			identifier: method.templateTypeNotInParameter
+			count: 1
+			path: QueryInterface.php
+		-
+			message: '#^Template type TValue of method Cake\\Datasource\\QueryInterface\:\:all\(\) is not referenced in a parameter\.$#'
+			identifier: method.templateTypeNotInParameter
+			count: 1
+			path: QueryInterface.php
diff --git a/src/Datasource/tests/phpstan-bootstrap.php b/src/Datasource/tests/phpstan-bootstrap.php
new file mode 100644
index 00000000000..0e60e7fbe4e
--- /dev/null
+++ b/src/Datasource/tests/phpstan-bootstrap.php
@@ -0,0 +1,60 @@
+ 'App',
+    'encoding' => 'UTF-8',
+]);
+
+ini_set('intl.default_locale', 'en_US');
+ini_set('session.gc_divisor', '1');
+ini_set('assert.exception', '1');
diff --git a/src/Error/BaseErrorHandler.php b/src/Error/BaseErrorHandler.php
deleted file mode 100644
index c4585e78725..00000000000
--- a/src/Error/BaseErrorHandler.php
+++ /dev/null
@@ -1,421 +0,0 @@
-_options['errorLevel'])) {
-            $level = $this->_options['errorLevel'];
-        }
-        error_reporting($level);
-        set_error_handler([$this, 'handleError'], $level);
-        set_exception_handler([$this, 'wrapAndHandleException']);
-        register_shutdown_function(function () {
-            if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
-                return;
-            }
-            $megabytes = Configure::read('Error.extraFatalErrorMemory');
-            if ($megabytes === null) {
-                $megabytes = 4;
-            }
-            if ($megabytes > 0) {
-                $this->increaseMemoryLimit($megabytes * 1024);
-            }
-            $error = error_get_last();
-            if (!is_array($error)) {
-                return;
-            }
-            $fatals = [
-                E_USER_ERROR,
-                E_ERROR,
-                E_PARSE,
-            ];
-            if (!in_array($error['type'], $fatals, true)) {
-                return;
-            }
-            $this->handleFatalError(
-                $error['type'],
-                $error['message'],
-                $error['file'],
-                $error['line']
-            );
-        });
-    }
-
-    /**
-     * Set as the default error handler by CakePHP.
-     *
-     * Use config/error.php to customize or replace this error handler.
-     * This function will use Debugger to display errors when debug > 0. And
-     * will log errors to Log, when debug == 0.
-     *
-     * You can use the 'errorLevel' option to set what type of errors will be handled.
-     * Stack traces for errors can be enabled with the 'trace' option.
-     *
-     * @param int $code Code of error
-     * @param string $description Error description
-     * @param string|null $file File on which error occurred
-     * @param int|null $line Line that triggered the error
-     * @param array|null $context Context
-     * @return bool True if error was handled
-     */
-    public function handleError($code, $description, $file = null, $line = null, $context = null)
-    {
-        if (error_reporting() === 0) {
-            return false;
-        }
-        list($error, $log) = static::mapErrorCode($code);
-        if ($log === LOG_ERR) {
-            return $this->handleFatalError($code, $description, $file, $line);
-        }
-        $data = [
-            'level' => $log,
-            'code' => $code,
-            'error' => $error,
-            'description' => $description,
-            'file' => $file,
-            'line' => $line,
-        ];
-
-        $debug = Configure::read('debug');
-        if ($debug) {
-            $data += [
-                'context' => $context,
-                'start' => 3,
-                'path' => Debugger::trimPath($file)
-            ];
-        }
-        $this->_displayError($data, $debug);
-        $this->_logError($log, $data);
-
-        return true;
-    }
-
-    /**
-     * Checks the passed exception type. If it is an instance of `Error`
-     * then, it wraps the passed object inside another Exception object
-     * for backwards compatibility purposes.
-     *
-     * @param \Exception|\Error $exception The exception to handle
-     * @return void
-     */
-    public function wrapAndHandleException($exception)
-    {
-        if ($exception instanceof Error) {
-            $exception = new PHP7ErrorException($exception);
-        }
-        $this->handleException($exception);
-    }
-
-    /**
-     * Handle uncaught exceptions.
-     *
-     * Uses a template method provided by subclasses to display errors in an
-     * environment appropriate way.
-     *
-     * @param \Exception $exception Exception instance.
-     * @return void
-     * @throws \Exception When renderer class not found
-     * @see https://secure.php.net/manual/en/function.set-exception-handler.php
-     */
-    public function handleException(Exception $exception)
-    {
-        $this->_displayException($exception);
-        $this->_logException($exception);
-        $this->_stop($exception->getCode() ?: 1);
-    }
-
-    /**
-     * Stop the process.
-     *
-     * Implemented in subclasses that need it.
-     *
-     * @param int $code Exit code.
-     * @return void
-     */
-    protected function _stop($code)
-    {
-        // Do nothing.
-    }
-
-    /**
-     * Display/Log a fatal error.
-     *
-     * @param int $code Code of error
-     * @param string $description Error description
-     * @param string $file File on which error occurred
-     * @param int $line Line that triggered the error
-     * @return bool
-     */
-    public function handleFatalError($code, $description, $file, $line)
-    {
-        $data = [
-            'code' => $code,
-            'description' => $description,
-            'file' => $file,
-            'line' => $line,
-            'error' => 'Fatal Error',
-        ];
-        $this->_logError(LOG_ERR, $data);
-
-        $this->handleException(new FatalErrorException($description, 500, $file, $line));
-
-        return true;
-    }
-
-    /**
-     * Increases the PHP "memory_limit" ini setting by the specified amount
-     * in kilobytes
-     *
-     * @param int $additionalKb Number in kilobytes
-     * @return void
-     */
-    public function increaseMemoryLimit($additionalKb)
-    {
-        $limit = ini_get('memory_limit');
-        if (!strlen($limit) || $limit === '-1') {
-            return;
-        }
-        $limit = trim($limit);
-        $units = strtoupper(substr($limit, -1));
-        $current = (int)substr($limit, 0, strlen($limit) - 1);
-        if ($units === 'M') {
-            $current *= 1024;
-            $units = 'K';
-        }
-        if ($units === 'G') {
-            $current = $current * 1024 * 1024;
-            $units = 'K';
-        }
-
-        if ($units === 'K') {
-            ini_set('memory_limit', ceil($current + $additionalKb) . 'K');
-        }
-    }
-
-    /**
-     * Log an error.
-     *
-     * @param string $level The level name of the log.
-     * @param array $data Array of error data.
-     * @return bool
-     */
-    protected function _logError($level, $data)
-    {
-        $message = sprintf(
-            '%s (%s): %s in [%s, line %s]',
-            $data['error'],
-            $data['code'],
-            $data['description'],
-            $data['file'],
-            $data['line']
-        );
-        if (!empty($this->_options['trace'])) {
-            $trace = Debugger::trace([
-                'start' => 1,
-                'format' => 'log'
-            ]);
-
-            $request = Router::getRequest();
-            if ($request) {
-                $message .= $this->_requestContext($request);
-            }
-            $message .= "\nTrace:\n" . $trace . "\n";
-        }
-        $message .= "\n\n";
-
-        return Log::write($level, $message);
-    }
-
-    /**
-     * Handles exception logging
-     *
-     * @param \Exception $exception Exception instance.
-     * @return bool
-     */
-    protected function _logException(Exception $exception)
-    {
-        $config = $this->_options;
-        $unwrapped = $exception instanceof PHP7ErrorException ?
-            $exception->getError() :
-            $exception;
-
-        if (empty($config['log'])) {
-            return false;
-        }
-
-        if (!empty($config['skipLog'])) {
-            foreach ((array)$config['skipLog'] as $class) {
-                if ($unwrapped instanceof $class) {
-                    return false;
-                }
-            }
-        }
-
-        return Log::error($this->_getMessage($exception));
-    }
-
-    /**
-     * Get the request context for an error/exception trace.
-     *
-     * @param \Cake\Http\ServerRequest $request The request to read from.
-     * @return string
-     */
-    protected function _requestContext($request)
-    {
-        $message = "\nRequest URL: " . $request->getRequestTarget();
-
-        $referer = $request->getEnv('HTTP_REFERER');
-        if ($referer) {
-            $message .= "\nReferer URL: " . $referer;
-        }
-        $clientIp = $request->clientIp();
-        if ($clientIp && $clientIp !== '::1') {
-            $message .= "\nClient IP: " . $clientIp;
-        }
-
-        return $message;
-    }
-
-    /**
-     * Generates a formatted error message
-     *
-     * @param \Exception $exception Exception instance
-     * @return string Formatted message
-     */
-    protected function _getMessage(Exception $exception)
-    {
-        $exception = $exception instanceof PHP7ErrorException ?
-            $exception->getError() :
-            $exception;
-        $config = $this->_options;
-        $message = sprintf(
-            '[%s] %s in %s on line %s',
-            get_class($exception),
-            $exception->getMessage(),
-            $exception->getFile(),
-            $exception->getLine()
-        );
-        $debug = Configure::read('debug');
-
-        if ($debug && method_exists($exception, 'getAttributes')) {
-            $attributes = $exception->getAttributes();
-            if ($attributes) {
-                $message .= "\nException Attributes: " . var_export($exception->getAttributes(), true);
-            }
-        }
-
-        $request = Router::getRequest();
-        if ($request) {
-            $message .= $this->_requestContext($request);
-        }
-
-        if (!empty($config['trace'])) {
-            $message .= "\nStack Trace:\n" . $exception->getTraceAsString() . "\n\n";
-        }
-
-        return $message;
-    }
-
-    /**
-     * Map an error code into an Error word, and log location.
-     *
-     * @param int $code Error code to map
-     * @return array Array of error word, and log location.
-     */
-    public static function mapErrorCode($code)
-    {
-        $levelMap = [
-            E_PARSE => 'error',
-            E_ERROR => 'error',
-            E_CORE_ERROR => 'error',
-            E_COMPILE_ERROR => 'error',
-            E_USER_ERROR => 'error',
-            E_WARNING => 'warning',
-            E_USER_WARNING => 'warning',
-            E_COMPILE_WARNING => 'warning',
-            E_RECOVERABLE_ERROR => 'warning',
-            E_NOTICE => 'notice',
-            E_USER_NOTICE => 'notice',
-            E_STRICT => 'strict',
-            E_DEPRECATED => 'deprecated',
-            E_USER_DEPRECATED => 'deprecated',
-        ];
-        $logMap = [
-            'error' => LOG_ERR,
-            'warning' => LOG_WARNING,
-            'notice' => LOG_NOTICE,
-            'strict' => LOG_NOTICE,
-            'deprecated' => LOG_NOTICE,
-        ];
-
-        $error = $levelMap[$code];
-        $log = $logMap[$error];
-
-        return [ucfirst($error), $log];
-    }
-}
diff --git a/src/Error/Debug/ArrayItemNode.php b/src/Error/Debug/ArrayItemNode.php
new file mode 100644
index 00000000000..de74bb12a86
--- /dev/null
+++ b/src/Error/Debug/ArrayItemNode.php
@@ -0,0 +1,73 @@
+key = $key;
+        $this->value = $value;
+    }
+
+    /**
+     * Get the value
+     *
+     * @return \Cake\Error\Debug\NodeInterface
+     */
+    public function getValue(): NodeInterface
+    {
+        return $this->value;
+    }
+
+    /**
+     * Get the key
+     *
+     * @return \Cake\Error\Debug\NodeInterface
+     */
+    public function getKey(): NodeInterface
+    {
+        return $this->key;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function getChildren(): array
+    {
+        return [$this->value];
+    }
+}
diff --git a/src/Error/Debug/ArrayNode.php b/src/Error/Debug/ArrayNode.php
new file mode 100644
index 00000000000..c7f91d2c214
--- /dev/null
+++ b/src/Error/Debug/ArrayNode.php
@@ -0,0 +1,71 @@
+
+     */
+    private array $items = [];
+
+    /**
+     * Constructor
+     *
+     * @param array<\Cake\Error\Debug\ArrayItemNode> $items The items for the array
+     */
+    public function __construct(array $items = [])
+    {
+        foreach ($items as $item) {
+            $this->add($item);
+        }
+    }
+
+    /**
+     * Add an item
+     *
+     * @param \Cake\Error\Debug\ArrayItemNode $node The item to add.
+     * @return void
+     */
+    public function add(ArrayItemNode $node): void
+    {
+        $this->items[] = $node;
+    }
+
+    /**
+     * Get the contained items
+     *
+     * @return array<\Cake\Error\Debug\ArrayItemNode>
+     */
+    public function getValue(): array
+    {
+        return $this->items;
+    }
+
+    /**
+     * Get Item nodes
+     *
+     * @return array<\Cake\Error\Debug\ArrayItemNode>
+     */
+    public function getChildren(): array
+    {
+        return $this->items;
+    }
+}
diff --git a/src/Error/Debug/ClassNode.php b/src/Error/Debug/ClassNode.php
new file mode 100644
index 00000000000..09adf5f2276
--- /dev/null
+++ b/src/Error/Debug/ClassNode.php
@@ -0,0 +1,91 @@
+
+     */
+    private array $properties = [];
+
+    /**
+     * Constructor
+     *
+     * @param string $class The class name
+     * @param int $id The reference id of this object in the DumpContext
+     */
+    public function __construct(string $class, int $id)
+    {
+        $this->class = $class;
+        $this->id = $id;
+    }
+
+    /**
+     * Add a property
+     *
+     * @param \Cake\Error\Debug\PropertyNode $node The property to add.
+     * @return void
+     */
+    public function addProperty(PropertyNode $node): void
+    {
+        $this->properties[] = $node;
+    }
+
+    /**
+     * Get the class name
+     *
+     * @return string
+     */
+    public function getValue(): string
+    {
+        return $this->class;
+    }
+
+    /**
+     * Get the reference id
+     *
+     * @return int
+     */
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    /**
+     * Get property nodes
+     *
+     * @return array<\Cake\Error\Debug\PropertyNode>
+     */
+    public function getChildren(): array
+    {
+        return $this->properties;
+    }
+}
diff --git a/src/Error/Debug/ConsoleFormatter.php b/src/Error/Debug/ConsoleFormatter.php
new file mode 100644
index 00000000000..fffa965d20e
--- /dev/null
+++ b/src/Error/Debug/ConsoleFormatter.php
@@ -0,0 +1,238 @@
+
+     */
+    protected array $styles = [
+        // bold yellow
+        'const' => '1;33',
+        // green
+        'string' => '0;32',
+        // bold blue
+        'number' => '1;34',
+        // cyan
+        'class' => '0;36',
+        // grey
+        'punct' => '0;90',
+        // default foreground
+        'property' => '0;39',
+        // magenta
+        'visibility' => '0;35',
+        // red
+        'special' => '0;31',
+    ];
+
+    /**
+     * Check if the current environment supports ANSI output.
+     *
+     * @return bool
+     */
+    public static function environmentMatches(): bool
+    {
+        if (PHP_SAPI !== 'cli') {
+            return false;
+        }
+        // NO_COLOR in environment means no color.
+        if (env('NO_COLOR')) {
+            return false;
+        }
+        // Windows environment checks
+        if (
+            DIRECTORY_SEPARATOR === '\\' &&
+            !str_contains(strtolower(php_uname('v')), 'windows 10') &&
+            !str_contains(strtolower((string)env('SHELL')), 'bash.exe') &&
+            !env('ANSICON') &&
+            env('ConEmuANSI') !== 'ON'
+        ) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function formatWrapper(string $contents, array $location): string
+    {
+        $lineInfo = '';
+        if (isset($location['file'], $location['line'])) {
+            $lineInfo = sprintf('%s (line %s)', $location['file'], $location['line']);
+        }
+        $parts = [
+            $this->style('const', $lineInfo),
+            $this->style('special', '########## DEBUG ##########'),
+            $contents,
+            $this->style('special', '###########################'),
+            '',
+        ];
+
+        return implode("\n", $parts);
+    }
+
+    /**
+     * Convert a tree of NodeInterface objects into a plain text string.
+     *
+     * @param \Cake\Error\Debug\NodeInterface $node The node tree to dump.
+     * @return string
+     */
+    public function dump(NodeInterface $node): string
+    {
+        $indent = 0;
+
+        return $this->export($node, $indent);
+    }
+
+    /**
+     * Convert a tree of NodeInterface objects into a plain text string.
+     *
+     * @param \Cake\Error\Debug\NodeInterface $var The node tree to dump.
+     * @param int $indent The current indentation level.
+     * @return string
+     */
+    protected function export(NodeInterface $var, int $indent): string
+    {
+        if ($var instanceof ScalarNode) {
+            return match ($var->getType()) {
+                'bool' => $this->style('const', $var->getValue() ? 'true' : 'false'),
+                'null' => $this->style('const', 'null'),
+                'string' => $this->style('string', "'" . $var->getValue() . "'"),
+                'int', 'float' => $this->style('visibility', "({$var->getType()})") .
+                        ' ' . $this->style('number', "{$var->getValue()}"),
+                default => "({$var->getType()}) {$var->getValue()}",
+            };
+        }
+        if ($var instanceof ArrayNode) {
+            return $this->exportArray($var, $indent + 1);
+        }
+        if ($var instanceof ClassNode || $var instanceof ReferenceNode) {
+            return $this->exportObject($var, $indent + 1);
+        }
+        if ($var instanceof SpecialNode) {
+            return $this->style('special', $var->getValue());
+        }
+        throw new InvalidArgumentException('Unknown node received ' . $var::class);
+    }
+
+    /**
+     * Export an array type object
+     *
+     * @param \Cake\Error\Debug\ArrayNode $var The array to export.
+     * @param int $indent The current indentation level.
+     * @return string Exported array.
+     */
+    protected function exportArray(ArrayNode $var, int $indent): string
+    {
+        $out = $this->style('punct', '[');
+        $break = "\n" . str_repeat('  ', $indent);
+        $end = "\n" . str_repeat('  ', $indent - 1);
+        $vars = [];
+
+        $arrow = $this->style('punct', ' => ');
+        foreach ($var->getChildren() as $item) {
+            $val = $item->getValue();
+            $vars[] = $break . $this->export($item->getKey(), $indent) . $arrow . $this->export($val, $indent);
+        }
+
+        $close = $this->style('punct', ']');
+        if ($vars !== []) {
+            return $out . implode($this->style('punct', ','), $vars) . $end . $close;
+        }
+
+        return $out . $close;
+    }
+
+    /**
+     * Handles object to string conversion.
+     *
+     * @param \Cake\Error\Debug\ClassNode|\Cake\Error\Debug\ReferenceNode $var Object to convert.
+     * @param int $indent Current indentation level.
+     * @return string
+     * @see \Cake\Error\Debugger::exportVar()
+     */
+    protected function exportObject(ClassNode|ReferenceNode $var, int $indent): string
+    {
+        $props = [];
+
+        if ($var instanceof ReferenceNode) {
+            return $this->style('punct', 'object(') .
+                $this->style('class', $var->getValue()) .
+                $this->style('punct', ') id:') .
+                $this->style('number', (string)$var->getId()) .
+                $this->style('punct', ' {}');
+        }
+
+        $out = $this->style('punct', 'object(') .
+            $this->style('class', $var->getValue()) .
+            $this->style('punct', ') id:') .
+            $this->style('number', (string)$var->getId()) .
+            $this->style('punct', ' {');
+
+        $break = "\n" . str_repeat('  ', $indent);
+        $end = "\n" . str_repeat('  ', $indent - 1) . $this->style('punct', '}');
+
+        $arrow = $this->style('punct', ' => ');
+        foreach ($var->getChildren() as $property) {
+            $visibility = $property->getVisibility();
+            $name = $property->getName();
+            if ($visibility && $visibility !== 'public') {
+                $props[] = $this->style('visibility', $visibility) .
+                    ' ' .
+                    $this->style('property', $name) .
+                    $arrow .
+                    $this->export($property->getValue(), $indent);
+            } else {
+                $props[] = $this->style('property', $name) .
+                    $arrow .
+                    $this->export($property->getValue(), $indent);
+            }
+        }
+        if ($props !== []) {
+            return $out . $break . implode($break, $props) . $end;
+        }
+
+        return $out . $this->style('punct', '}');
+    }
+
+    /**
+     * Style text with ANSI escape codes.
+     *
+     * @param string $style The style name to use.
+     * @param string $text The text to style.
+     * @return string The styled output.
+     */
+    protected function style(string $style, string $text): string
+    {
+        $code = $this->styles[$style];
+
+        return "\033[{$code}m{$text}\033[0m";
+    }
+}
diff --git a/src/Error/Debug/DebugContext.php b/src/Error/Debug/DebugContext.php
new file mode 100644
index 00000000000..a5deccb33b9
--- /dev/null
+++ b/src/Error/Debug/DebugContext.php
@@ -0,0 +1,110 @@
+
+     */
+    private SplObjectStorage $refs;
+
+    /**
+     * Constructor
+     *
+     * @param int $maxDepth The desired depth of dump output.
+     */
+    public function __construct(int $maxDepth)
+    {
+        $this->maxDepth = $maxDepth;
+        $this->refs = new SplObjectStorage();
+    }
+
+    /**
+     * Return a clone with increased depth.
+     *
+     * @return static
+     */
+    public function withAddedDepth(): static
+    {
+        $new = clone $this;
+        $new->depth += 1;
+
+        return $new;
+    }
+
+    /**
+     * Get the remaining depth levels
+     *
+     * @return int
+     */
+    public function remainingDepth(): int
+    {
+        return $this->maxDepth - $this->depth;
+    }
+
+    /**
+     * Get the reference ID for an object.
+     *
+     * If this object does not exist in the reference storage,
+     * it will be added and the id will be returned.
+     *
+     * @param object $object The object to get a reference for.
+     * @return int
+     */
+    public function getReferenceId(object $object): int
+    {
+        if ($this->refs->offsetExists($object)) {
+            return $this->refs[$object];
+        }
+        $refId = $this->refs->count();
+        $this->refs->offsetSet($object, $refId);
+
+        return $refId;
+    }
+
+    /**
+     * Check whether an object has been seen before.
+     *
+     * @param object $object The object to get a reference for.
+     * @return bool
+     */
+    public function hasReference(object $object): bool
+    {
+        return $this->refs->offsetExists($object);
+    }
+}
diff --git a/src/Error/Debug/FormatterInterface.php b/src/Error/Debug/FormatterInterface.php
new file mode 100644
index 00000000000..bacf883d3f3
--- /dev/null
+++ b/src/Error/Debug/FormatterInterface.php
@@ -0,0 +1,42 @@
+id = uniqid('', true);
+    }
+
+    /**
+     * Check if the current environment is not a CLI context
+     *
+     * @return bool
+     */
+    public static function environmentMatches(): bool
+    {
+        if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function formatWrapper(string $contents, array $location): string
+    {
+        $lineInfo = '';
+        if (isset($location['file'], $location['line'])) {
+            $lineInfo = sprintf(
+                '%s (line %s)',
+                $location['file'],
+                $location['line'],
+            );
+        }
+        $parts = [
+            '
', + $lineInfo, + $contents, + '
', + ]; + + return implode("\n", $parts); + } + + /** + * Generate the CSS and Javascript for dumps + * + * Only output once per process as we don't need it more than once. + * + * @return string + */ + protected function dumpHeader(): string + { + ob_start(); + include __DIR__ . DIRECTORY_SEPARATOR . 'dumpHeader.html'; + + return (string)ob_get_clean(); + } + + /** + * Convert a tree of NodeInterface objects into HTML + * + * @param \Cake\Error\Debug\NodeInterface $node The node tree to dump. + * @return string + */ + public function dump(NodeInterface $node): string + { + $html = $this->export($node, 0); + $head = ''; + if (!static::$outputHeader) { + static::$outputHeader = true; + $head = $this->dumpHeader(); + } + + return $head . '
' . $html . '
'; + } + + /** + * Convert a tree of NodeInterface objects into HTML + * + * @param \Cake\Error\Debug\NodeInterface $var The node tree to dump. + * @param int $indent The current indentation level. + * @return string + */ + protected function export(NodeInterface $var, int $indent): string + { + if ($var instanceof ScalarNode) { + return match ($var->getType()) { + 'bool' => $this->style('const', $var->getValue() ? 'true' : 'false'), + 'null' => $this->style('const', 'null'), + 'string' => $this->style('string', "'" . $var->getValue() . "'"), + 'int', 'float' => $this->style('visibility', "({$var->getType()})") . + ' ' . $this->style('number', "{$var->getValue()}"), + default => "({$var->getType()}) {$var->getValue()}", + }; + } + if ($var instanceof ArrayNode) { + return $this->exportArray($var, $indent + 1); + } + if ($var instanceof ClassNode || $var instanceof ReferenceNode) { + return $this->exportObject($var, $indent + 1); + } + if ($var instanceof SpecialNode) { + return $this->style('special', $var->getValue()); + } + throw new InvalidArgumentException('Unknown node received ' . $var::class); + } + + /** + * Export an array type object + * + * @param \Cake\Error\Debug\ArrayNode $var The array to export. + * @param int $indent The current indentation level. + * @return string Exported array. + */ + protected function exportArray(ArrayNode $var, int $indent): string + { + $open = '' . + $this->style('punct', '[') . + ''; + $vars = []; + $break = "\n" . str_repeat(' ', $indent); + $endBreak = "\n" . str_repeat(' ', $indent - 1); + + $arrow = $this->style('punct', ' => '); + foreach ($var->getChildren() as $item) { + $val = $item->getValue(); + $vars[] = $break . '' . + $this->export($item->getKey(), $indent) . $arrow . $this->export($val, $indent) . + $this->style('punct', ',') . + ''; + } + + $close = '' . + $endBreak . + $this->style('punct', ']') . + ''; + + return $open . implode('', $vars) . $close; + } + + /** + * Handles object to string conversion. + * + * @param \Cake\Error\Debug\ClassNode|\Cake\Error\Debug\ReferenceNode $var Object to convert. + * @param int $indent The current indentation level. + * @return string + * @see \Cake\Error\Debugger::exportVar() + */ + protected function exportObject(ClassNode|ReferenceNode $var, int $indent): string + { + $objectId = "cake-db-object-{$this->id}-{$var->getId()}"; + $out = sprintf( + '', + $objectId, + ); + $break = "\n" . str_repeat(' ', $indent); + $endBreak = "\n" . str_repeat(' ', $indent - 1); + + if ($var instanceof ReferenceNode) { + $link = sprintf( + 'id: %s', + $objectId, + $var->getId(), + ); + + return '' . + $this->style('punct', 'object(') . + $this->style('class', $var->getValue()) . + $this->style('punct', ') ') . + $link . + $this->style('punct', ' {}') . + ''; + } + + $out .= $this->style('punct', 'object(') . + $this->style('class', $var->getValue()) . + $this->style('punct', ') id:') . + $this->style('number', (string)$var->getId()) . + $this->style('punct', ' {') . + ''; + + $props = []; + foreach ($var->getChildren() as $property) { + $arrow = $this->style('punct', ' => '); + $visibility = $property->getVisibility(); + $name = $property->getName(); + if ($visibility && $visibility !== 'public') { + $props[] = $break . + '' . + $this->style('visibility', $visibility) . + ' ' . + $this->style('property', $name) . + $arrow . + $this->export($property->getValue(), $indent) . + ''; + } else { + $props[] = $break . + '' . + $this->style('property', $name) . + $arrow . + $this->export($property->getValue(), $indent) . + ''; + } + } + + $end = '' . + $endBreak . + $this->style('punct', '}') . + ''; + + if ($props !== []) { + return $out . implode('', $props) . $end; + } + + return $out . $end; + } + + /** + * Style text with HTML class names + * + * @param string $style The style name to use. + * @param string $text The text to style. + * @return string The styled output. + */ + protected function style(string $style, string $text): string + { + return sprintf( + '%s', + $style, + h($text), + ); + } +} diff --git a/src/Error/Debug/NodeInterface.php b/src/Error/Debug/NodeInterface.php new file mode 100644 index 00000000000..f5329bd49b5 --- /dev/null +++ b/src/Error/Debug/NodeInterface.php @@ -0,0 +1,39 @@ + + */ + public function getChildren(): array; + + /** + * Get the contained value. + * + * @return mixed + */ + public function getValue(): mixed; +} diff --git a/src/Error/Debug/PropertyNode.php b/src/Error/Debug/PropertyNode.php new file mode 100644 index 00000000000..742e91ab150 --- /dev/null +++ b/src/Error/Debug/PropertyNode.php @@ -0,0 +1,90 @@ +name = $name; + $this->visibility = $visibility; + $this->value = $value; + } + + /** + * Get the value + * + * @return \Cake\Error\Debug\NodeInterface + */ + public function getValue(): NodeInterface + { + return $this->value; + } + + /** + * Get the property visibility + * + * @return string|null + */ + public function getVisibility(): ?string + { + return $this->visibility; + } + + /** + * Get the property name + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @inheritDoc + */ + public function getChildren(): array + { + return [$this->value]; + } +} diff --git a/src/Error/Debug/ReferenceNode.php b/src/Error/Debug/ReferenceNode.php new file mode 100644 index 00000000000..427dda78d28 --- /dev/null +++ b/src/Error/Debug/ReferenceNode.php @@ -0,0 +1,77 @@ +class = $class; + $this->id = $id; + } + + /** + * Get the class name/value + * + * @return string + */ + public function getValue(): string + { + return $this->class; + } + + /** + * Get the reference id for this node. + * + * @return int + */ + public function getId(): int + { + return $this->id; + } + + /** + * @inheritDoc + */ + public function getChildren(): array + { + return []; + } +} diff --git a/src/Error/Debug/ScalarNode.php b/src/Error/Debug/ScalarNode.php new file mode 100644 index 00000000000..1a05a19d652 --- /dev/null +++ b/src/Error/Debug/ScalarNode.php @@ -0,0 +1,73 @@ +type = $type; + $this->value = $value; + } + + /** + * Get the type of value + * + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * Get the value + * + * @return resource|string|float|int|bool|null + */ + public function getValue(): mixed + { + return $this->value; + } + + /** + * @inheritDoc + */ + public function getChildren(): array + { + return []; + } +} diff --git a/src/Error/Debug/SpecialNode.php b/src/Error/Debug/SpecialNode.php new file mode 100644 index 00000000000..bc3bc07aa14 --- /dev/null +++ b/src/Error/Debug/SpecialNode.php @@ -0,0 +1,56 @@ +value = $value; + } + + /** + * Get the message/value + * + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * @inheritDoc + */ + public function getChildren(): array + { + return []; + } +} diff --git a/src/Error/Debug/TextFormatter.php b/src/Error/Debug/TextFormatter.php new file mode 100644 index 00000000000..02d495e0b94 --- /dev/null +++ b/src/Error/Debug/TextFormatter.php @@ -0,0 +1,154 @@ +export($node, $indent); + } + + /** + * Convert a tree of NodeInterface objects into a plain text string. + * + * @param \Cake\Error\Debug\NodeInterface $var The node tree to dump. + * @param int $indent The current indentation level. + * @return string + */ + protected function export(NodeInterface $var, int $indent): string + { + if ($var instanceof ScalarNode) { + return match ($var->getType()) { + 'bool' => $var->getValue() ? 'true' : 'false', + 'null' => 'null', + 'string' => "'" . $var->getValue() . "'", + default => "({$var->getType()}) {$var->getValue()}", + }; + } + if ($var instanceof ArrayNode) { + return $this->exportArray($var, $indent + 1); + } + if ($var instanceof ClassNode || $var instanceof ReferenceNode) { + return $this->exportObject($var, $indent + 1); + } + if ($var instanceof SpecialNode) { + return $var->getValue(); + } + throw new InvalidArgumentException('Unknown node received ' . $var::class); + } + + /** + * Export an array type object + * + * @param \Cake\Error\Debug\ArrayNode $var The array to export. + * @param int $indent The current indentation level. + * @return string Exported array. + */ + protected function exportArray(ArrayNode $var, int $indent): string + { + $out = '['; + $break = "\n" . str_repeat(' ', $indent); + $end = "\n" . str_repeat(' ', $indent - 1); + $vars = []; + + foreach ($var->getChildren() as $item) { + $val = $item->getValue(); + $vars[] = $break . $this->export($item->getKey(), $indent) . ' => ' . $this->export($val, $indent); + } + if ($vars !== []) { + return $out . implode(',', $vars) . $end . ']'; + } + + return $out . ']'; + } + + /** + * Handles object to string conversion. + * + * @param \Cake\Error\Debug\ClassNode|\Cake\Error\Debug\ReferenceNode $var Object to convert. + * @param int $indent Current indentation level. + * @return string + * @see \Cake\Error\Debugger::exportVar() + */ + protected function exportObject(ClassNode|ReferenceNode $var, int $indent): string + { + $out = ''; + $props = []; + + if ($var instanceof ReferenceNode) { + return "object({$var->getValue()}) id:{$var->getId()} {}"; + } + + $out .= "object({$var->getValue()}) id:{$var->getId()} {"; + $break = "\n" . str_repeat(' ', $indent); + $end = "\n" . str_repeat(' ', $indent - 1) . '}'; + + foreach ($var->getChildren() as $property) { + $visibility = $property->getVisibility(); + $name = $property->getName(); + if ($visibility && $visibility !== 'public') { + $props[] = "[{$visibility}] {$name} => " . $this->export($property->getValue(), $indent); + } else { + $props[] = "{$name} => " . $this->export($property->getValue(), $indent); + } + } + if ($props !== []) { + return $out . $break . implode($break, $props) . $end; + } + + return $out . '}'; + } +} diff --git a/src/Error/Debug/dumpHeader.html b/src/Error/Debug/dumpHeader.html new file mode 100644 index 00000000000..82595f16e07 --- /dev/null +++ b/src/Error/Debug/dumpHeader.html @@ -0,0 +1,284 @@ + + diff --git a/src/Error/Debugger.php b/src/Error/Debugger.php index 0ee5c34f3b6..ba73e1df3de 100644 --- a/src/Error/Debugger.php +++ b/src/Error/Debugger.php @@ -1,4 +1,6 @@ */ - protected $_defaultConfig = [ - 'outputMask' => [] + protected array $_defaultConfig = [ + 'outputMask' => [ + 'password' => '********', + 'login' => '********', + 'host' => '********', + 'database' => '********', + 'port' => '********', + 'prefix' => '********', + 'schema' => '********', + ], + 'exportFormatter' => null, + 'editor' => 'phpstorm', + 'editorBasePath' => null, ]; /** - * A list of errors generated by the application. - * - * @var array - */ - public $errors = []; - - /** - * The current output format. + * A map of editors to their link templates. * - * @var string + * @var array */ - protected $_outputFormat = 'js'; - - /** - * Templates used when generating trace or error strings. Can be global or indexed by the format - * value used in $_outputFormat. - * - * @var array - */ - protected $_templates = [ - 'log' => [ - 'trace' => '{:reference} - {:path}, line {:line}', - 'error' => '{:error} ({:code}): {:description} in [{:file}, line {:line}]' - ], - 'js' => [ - 'error' => '', - 'info' => '', - 'trace' => '
{:trace}
', - 'code' => '', - 'context' => '', - 'links' => [], - 'escapeContext' => true, - ], - 'html' => [ - 'trace' => '
Trace 

{:trace}

', - 'context' => '
Context 

{:context}

', - 'escapeContext' => true, - ], - 'txt' => [ - 'error' => "{:error}: {:code} :: {:description} on line {:line} of {:path}\n{:info}", - 'code' => '', - 'info' => '' - ], - 'base' => [ - 'traceLine' => '{:reference} - {:path}, line {:line}', - 'trace' => "Trace:\n{:trace}\n", - 'context' => "Context:\n{:context}\n", - ] + protected array $editors = [ + 'atom' => 'atom://core/open/file?filename={file}&line={line}', + 'emacs' => 'emacs://open?url=file://{file}&line={line}', + 'macvim' => 'mvim://open/?url=file://{file}&line={line}', + 'phpstorm' => 'phpstorm://open?file={file}&line={line}', + 'sublime' => 'subl://open?url=file://{file}&line={line}', + 'textmate' => 'txmt://open?url=file://{file}&line={line}', + 'vscode' => 'vscode://file/{file}:{line}', + 'vscodium' => 'vscodium://file/{file}:{line}', ]; /** @@ -100,97 +97,61 @@ class Debugger * * @var array */ - protected $_data = []; + protected array $_data = []; /** * Constructor. - * */ public function __construct() { $docRef = ini_get('docref_root'); - - if (empty($docRef) && function_exists('ini_set')) { + if (!$docRef && function_exists('ini_set')) { ini_set('docref_root', 'https://secure.php.net/'); } if (!defined('E_RECOVERABLE_ERROR')) { define('E_RECOVERABLE_ERROR', 4096); } - $e = '
';
-        $e .= '{:error} ({:code}): {:description} ';
-        $e .= '[{:path}, line {:line}]';
-
-        $e .= '';
-        $e .= '
'; - $this->_templates['js']['error'] = $e; - - $t = ''; - $this->_templates['js']['info'] = $t; - - $links = []; - $link = 'Code'; - $links['code'] = $link; - - $link = 'Context'; - $links['context'] = $link; - - $this->_templates['js']['links'] = $links; - - $this->_templates['js']['context'] = '
_templates['js']['context'] .= 'style="display: none;">{:context}
'; - - $this->_templates['js']['code'] = '
_templates['js']['code'] .= 'style="display: none;">{:code}
'; - - $e = '
{:error} ({:code}) : {:description} ';
-        $e .= '[{:path}, line {:line}]
'; - $this->_templates['html']['error'] = $e; - - $this->_templates['html']['context'] = '
Context ';
-        $this->_templates['html']['context'] .= '

{:context}

'; + $config = array_intersect_key((array)Configure::read('Debugger'), $this->_defaultConfig); + $this->setConfig($config); } /** * Returns a reference to the Debugger singleton object instance. * - * @param string|null $class Class name. - * @return \Cake\Error\Debugger + * @param class-string<\Cake\Error\Debugger>|null $class Class name. + * @return static */ - public static function getInstance($class = null) + public static function getInstance(?string $class = null): static { + /** @var array $instance */ static $instance = []; - if (!empty($class)) { - if (!$instance || strtolower($class) !== strtolower(get_class($instance[0]))) { - $instance[0] = new $class(); - } + if ($class && (!$instance || strtolower($class) !== strtolower($instance[0]::class))) { + $instance[0] = new $class(); } if (!$instance) { $instance[0] = new Debugger(); } + /** @var static */ return $instance[0]; } /** * Read or write configuration options for the Debugger instance. * - * @param string|array|null $key The key to get/set, or a complete array of configs. + * @param array|string|null $key The key to get/set, or a complete array of configs. * @param mixed|null $value The value to set. * @param bool $merge Whether to recursively merge or overwrite existing config, defaults to true. * @return mixed Config value being read, or the object itself on write operations. - * @throws \Cake\Core\Exception\Exception When trying to set a key that is invalid. + * @throws \Cake\Core\Exception\CakeException When trying to set a key that is invalid. */ - public static function configInstance($key = null, $value = null, $merge = true) + public static function configInstance(array|string|null $key = null, mixed $value = null, bool $merge = true): mixed { + if ($key === null) { + return static::getInstance()->getConfig($key); + } + if (is_array($key) || func_num_args() >= 2) { return static::getInstance()->setConfig($key, $value, $merge); } @@ -201,9 +162,9 @@ public static function configInstance($key = null, $value = null, $merge = true) /** * Reads the current output masking. * - * @return array + * @return array */ - public static function outputMask() + public static function outputMask(): array { return static::configInstance('outputMask'); } @@ -213,44 +174,162 @@ public static function outputMask() * * ### Example * - * Debugger::setOutputMask(['password' => '[*************]'); + * Debugger::setOutputMask(['password' => '[*************]']); * - * @param array $value An array where keys are replaced by their values in output. + * @param array $value An array where keys are replaced by their values in output. * @param bool $merge Whether to recursively merge or overwrite existing config, defaults to true. * @return void */ - public static function setOutputMask(array $value, $merge = true) + public static function setOutputMask(array $value, bool $merge = true): void { static::configInstance('outputMask', $value, $merge); } + /** + * Add an editor link format + * + * Template strings can use the `{file}` and `{line}` placeholders. + * Closures templates must return a string, and accept two parameters: + * The file and line. + * + * @param string $name The name of the editor. + * @param \Closure|string $template The string template or closure + * @return void + */ + public static function addEditor(string $name, Closure|string $template): void + { + $instance = static::getInstance(); + $instance->editors[$name] = $template; + } + + /** + * Choose the editor link style you want to use. + * + * @param string $name The editor name. + * @return void + */ + public static function setEditor(string $name): void + { + $instance = static::getInstance(); + if (!isset($instance->editors[$name])) { + $known = implode(', ', array_keys($instance->editors)); + throw new InvalidArgumentException(sprintf( + 'Unknown editor `%s`. Known editors are `%s`.', + $name, + $known, + )); + } + $instance->setConfig('editor', $name); + } + + /** + * Get a formatted URL for the active editor. + * + * @param string $file The file to create a link for. + * @param int $line The line number to create a link for. + * @return string The formatted URL. + */ + public static function editorUrl(string $file, int $line): string + { + $instance = static::getInstance(); + $editor = $instance->getConfig('editor'); + if (!isset($instance->editors[$editor])) { + throw new InvalidArgumentException(sprintf( + 'Cannot format editor URL `%s` is not a known editor.', + $editor, + )); + } + + $editorBasePath = $instance->getConfig('editorBasePath'); + if ($editorBasePath !== null && is_string($editorBasePath)) { + $file = str_replace(ROOT, $editorBasePath, $file); + } + + $template = $instance->editors[$editor]; + if (is_string($template)) { + return str_replace(['{file}', '{line}'], [$file, (string)$line], $template); + } + + return $template($file, $line); + } + /** * Recursively formats and outputs the contents of the supplied variable. * * @param mixed $var The variable to dump. - * @param int $depth The depth to output to. Defaults to 3. + * @param int $maxDepth The depth to output to. Defaults to 3. * @return void * @see \Cake\Error\Debugger::exportVar() - * @link https://book.cakephp.org/3.0/en/development/debugging.html#outputting-values + * @link https://book.cakephp.org/5/en/development/debugging.html#outputting-values */ - public static function dump($var, $depth = 3) + public static function dump(mixed $var, int $maxDepth = 3): void { - pr(static::exportVar($var, $depth)); + pr(static::exportVar($var, $maxDepth)); } /** * Creates an entry in the log file. The log entry will contain a stack trace from where it was called. - * as well as export the variable using exportVar. By default the log is written to the debug log. + * as well as export the variable using exportVar. By default, the log is written to the debug log. * * @param mixed $var Variable or content to log. - * @param int|string $level Type of log to use. Defaults to 'debug'. - * @param int $depth The depth to output to. Defaults to 3. + * @param string|int $level Type of log to use. Defaults to 'debug'. + * @param int $maxDepth The depth to output to. Defaults to 3. * @return void */ - public static function log($var, $level = 'debug', $depth = 3) + public static function log(mixed $var, string|int $level = 'debug', int $maxDepth = 3): void + { + /** @var string $source */ + $source = static::trace(['start' => 1]); + $source .= "\n"; + + Log::write( + $level, + "\n" . $source . static::exportVarAsPlainText($var, $maxDepth), + ); + } + + /** + * Get the frames from $exception that are not present in $parent + * + * @param \Throwable $exception The exception to get frames from. + * @param \Throwable|null $parent The parent exception to compare frames with. + * @return array An array of frame structures. + */ + public static function getUniqueFrames(Throwable $exception, ?Throwable $parent): array { - $source = static::trace(['start' => 1]) . "\n"; - Log::write($level, "\n" . $source . static::exportVar($var, $depth)); + if ($parent === null) { + return $exception->getTrace(); + } + $parentFrames = $parent->getTrace(); + $frames = $exception->getTrace(); + + $parentCount = count($parentFrames) - 1; + $frameCount = count($frames) - 1; + + // Reverse loop through both traces removing frames that + // are the same. + for ($i = $frameCount, $p = $parentCount; $i >= 0 && $p >= 0; $p--) { + $parentTail = $parentFrames[$p]; + $tail = $frames[$i]; + + // Frames without file/line are never equal to another frame. + $isEqual = ( + ( + isset($tail['file']) && + isset($tail['line']) && + isset($parentTail['file']) && + isset($parentTail['line']) + ) && + ($tail['file'] === $parentTail['file']) && + ($tail['line'] === $parentTail['line']) + ); + if ($isEqual) { + unset($frames[$i]); + $i--; + } + } + + return $frames; } /** @@ -260,18 +339,22 @@ public static function log($var, $level = 'debug', $depth = 3) * * - `depth` - The number of stack frames to return. Defaults to 999 * - `format` - The format you want the return. Defaults to the currently selected format. If - * format is 'array' or 'points' the return will be an array. + * format is 'array', 'points', or 'shortPoints' the return will be an array. * - `args` - Should arguments for functions be shown? If true, the arguments for each method call * will be displayed. * - `start` - The stack frame to start generating a trace from. Defaults to 0 * - * @param array $options Format for outputting stack trace. - * @return mixed Formatted stack trace. - * @link https://book.cakephp.org/3.0/en/development/debugging.html#generating-stack-traces + * @param array $options Format for outputting stack trace. + * @return array|string Formatted stack trace. + * @link https://book.cakephp.org/5/en/development/debugging.html#generating-stack-traces */ - public static function trace(array $options = []) + public static function trace(array $options = []): array|string { - return Debugger::formatTrace(debug_backtrace(), $options); + // Remove the frame for Debugger::trace() + $backtrace = debug_backtrace(); + array_shift($backtrace); + + return Debugger::formatTrace($backtrace, $options); } /** @@ -280,88 +363,90 @@ public static function trace(array $options = []) * ### Options * * - `depth` - The number of stack frames to return. Defaults to 999 - * - `format` - The format you want the return. Defaults to the currently selected format. If - * format is 'array' or 'points' the return will be an array. + * - `format` - The format you want the return. Defaults to 'text'. If + * format is 'array', 'points', or 'shortPoints' the return will be an array. * - `args` - Should arguments for functions be shown? If true, the arguments for each method call * will be displayed. * - `start` - The stack frame to start generating a trace from. Defaults to 0 * - * @param array|\Exception $backtrace Trace as array or an exception object. - * @param array $options Format for outputting stack trace. - * @return mixed Formatted stack trace. - * @link https://book.cakephp.org/3.0/en/development/debugging.html#generating-stack-traces + * @param \Throwable|array $backtrace Trace as array or an exception object. + * @param array $options Format for outputting stack trace. + * @return array|string Formatted stack trace. + * @link https://book.cakephp.org/5/en/development/debugging.html#generating-stack-traces */ - public static function formatTrace($backtrace, $options = []) + public static function formatTrace(Throwable|array $backtrace, array $options = []): array|string { - if ($backtrace instanceof Exception) { + if ($backtrace instanceof Throwable) { $backtrace = $backtrace->getTrace(); } - $self = Debugger::getInstance(); + $defaults = [ 'depth' => 999, - 'format' => $self->_outputFormat, + 'format' => 'text', 'args' => false, 'start' => 0, 'scope' => null, - 'exclude' => ['call_user_func_array', 'trigger_error'] + 'exclude' => ['call_user_func_array', 'trigger_error'], + 'shortPath' => false, ]; $options = Hash::merge($defaults, $options); - $count = count($backtrace); + $count = count($backtrace) + 1; $back = []; - $_trace = [ - 'line' => '??', - 'file' => '[internal]', - 'class' => null, - 'function' => '[main]' - ]; - for ($i = $options['start']; $i < $count && $i < $options['depth']; $i++) { - $trace = $backtrace[$i] + ['file' => '[internal]', 'line' => '??']; - $signature = $reference = '[main]'; - - if (isset($backtrace[$i + 1])) { - $next = $backtrace[$i + 1] + $_trace; - $signature = $reference = $next['function']; - - if (!empty($next['class'])) { - $signature = $next['class'] . '::' . $next['function']; - $reference = $signature . '('; - if ($options['args'] && isset($next['args'])) { - $args = []; - foreach ($next['args'] as $arg) { - $args[] = Debugger::exportVar($arg); - } - $reference .= implode(', ', $args); + $frame = ['file' => '[main]', 'line' => '']; + if (isset($backtrace[$i])) { + $frame = $backtrace[$i] + ['file' => '[internal]', 'line' => '??']; + } + $signature = $frame['file']; + $reference = $frame['file']; + if (!empty($frame['class'])) { + $signature = $frame['class'] . $frame['type'] . $frame['function']; + $reference = $signature . '('; + if ($options['args'] && isset($frame['args'])) { + $args = []; + foreach ($frame['args'] as $arg) { + $args[] = Debugger::exportVar($arg); } - $reference .= ')'; + $reference .= implode(', ', $args); } + $reference .= ')'; } - if (in_array($signature, $options['exclude'])) { + if (in_array($signature, $options['exclude'], true)) { continue; } - if ($options['format'] === 'points' && $trace['file'] !== '[internal]') { - $back[] = ['file' => $trace['file'], 'line' => $trace['line']]; - } elseif ($options['format'] === 'array') { - $back[] = $trace; - } else { - if (isset($self->_templates[$options['format']]['traceLine'])) { - $tpl = $self->_templates[$options['format']]['traceLine']; - } else { - $tpl = $self->_templates['base']['traceLine']; + + $format = $options['format']; + if ($format === 'shortPoints') { + $back[] = [ + 'file' => self::trimPath($frame['file']), + 'line' => $frame['line'], + 'reference' => $reference, + ]; + } elseif ($format === 'points') { + $back[] = ['file' => $frame['file'], 'line' => $frame['line'], 'reference' => $reference]; + } elseif ($format === 'array') { + if (!$options['args']) { + unset($frame['args']); } - $trace['path'] = static::trimPath($trace['file']); - $trace['reference'] = $reference; - unset($trace['object'], $trace['args']); - $back[] = Text::insert($tpl, $trace, ['before' => '{:', 'after' => '}']); + $back[] = $frame; + } elseif ($format === 'text') { + $path = static::trimPath($frame['file']); + $back[] = sprintf('%s - %s, line %d', $reference, $path, $frame['line']); + } else { + throw new InvalidArgumentException( + "Invalid trace format of `{$format}` chosen. Must be one of `array`, `points` or `text`.", + ); } } - - if ($options['format'] === 'array' || $options['format'] === 'points') { + if (in_array($options['format'], ['array', 'points', 'shortPoints'])) { return $back; } + /** + * @phpstan-ignore-next-line + */ return implode("\n", $back); } @@ -372,15 +457,15 @@ public static function formatTrace($backtrace, $options = []) * @param string $path Path to shorten. * @return string Normalized path */ - public static function trimPath($path) + public static function trimPath(string $path): string { - if (defined('APP') && strpos($path, APP) === 0) { + if (defined('APP') && str_starts_with($path, APP)) { return str_replace(APP, 'APP/', $path); } - if (defined('CAKE_CORE_INCLUDE_PATH') && strpos($path, CAKE_CORE_INCLUDE_PATH) === 0) { + if (defined('CAKE_CORE_INCLUDE_PATH') && str_starts_with($path, CAKE_CORE_INCLUDE_PATH)) { return str_replace(CAKE_CORE_INCLUDE_PATH, 'CORE', $path); } - if (defined('ROOT') && strpos($path, ROOT) === 0) { + if (defined('ROOT') && str_starts_with($path, ROOT)) { return str_replace(ROOT, 'ROOT', $path); } @@ -397,28 +482,28 @@ public static function trimPath($path) * ``` * * The above would return an array of 8 items. The 4th item would be the provided line, - * and would be wrapped in ``. All of the lines + * and would be wrapped in ``. All the lines * are processed with highlight_string() as well, so they have basic PHP syntax highlighting * applied. * * @param string $file Absolute path to a PHP file. * @param int $line Line number to highlight. * @param int $context Number of lines of context to extract above and below $line. - * @return array Set of lines highlighted + * @return array Set of lines highlighted * @see https://secure.php.net/highlight_string - * @link https://book.cakephp.org/3.0/en/development/debugging.html#getting-an-excerpt-from-a-file + * @link https://book.cakephp.org/5/en/development/debugging.html#getting-an-excerpt-from-a-file */ - public static function excerpt($file, $line, $context = 2) + public static function excerpt(string $file, int $line, int $context = 2): array { $lines = []; if (!file_exists($file)) { return []; } $data = file_get_contents($file); - if (empty($data)) { + if (!$data) { return $lines; } - if (strpos($data, "\n") !== false) { + if (str_contains($data, "\n")) { $data = explode("\n", $data); } $line--; @@ -430,7 +515,7 @@ public static function excerpt($file, $line, $context = 2) continue; } $string = str_replace(["\r\n", "\n"], '', static::_highlight($data[$i])); - if ($i == $line) { + if ($i === $line) { $lines[] = '' . $string . ''; } else { $lines[] = $string; @@ -447,28 +532,57 @@ public static function excerpt($file, $line, $context = 2) * @param string $str The string to convert. * @return string */ - protected static function _highlight($str) + protected static function _highlight(string $str): string { - if (function_exists('hphp_log') || function_exists('hphp_gettid')) { - return htmlentities($str); - } $added = false; - if (strpos($str, '', '<?php 
'], + return str_replace( + ['<?php 
', '<?php 
', '<?php '], '', - $highlight + $highlight, ); } return $highlight; } + /** + * Get the configured export formatter or infer one based on the environment. + * + * @return \Cake\Error\Debug\FormatterInterface + * @unstable This method is not stable and may change in the future. + * @since 4.1.0 + */ + public function getExportFormatter(): FormatterInterface + { + $instance = static::getInstance(); + $class = $instance->getConfig('exportFormatter'); + if (!$class) { + if (ConsoleFormatter::environmentMatches()) { + $class = ConsoleFormatter::class; + } elseif (HtmlFormatter::environmentMatches()) { + $class = HtmlFormatter::class; + } else { + $class = TextFormatter::class; + } + } + $instance = new $class(); + if (!$instance instanceof FormatterInterface) { + throw new CakeException(sprintf( + 'The `%s` formatter does not implement `%s`.', + $class, + FormatterInterface::class, + )); + } + + return $instance; + } + /** * Converts a variable to a string for debug output. * @@ -486,55 +600,76 @@ protected static function _highlight($str) * This is done to protect database credentials, which could be accidentally * shown in an error message if CakePHP is deployed in development mode. * - * @param string $var Variable to convert. - * @param int $depth The depth to output to. Defaults to 3. + * @param mixed $var Variable to convert. + * @param int $maxDepth The depth to output to. Defaults to 3. * @return string Variable as a formatted string */ - public static function exportVar($var, $depth = 3) + public static function exportVar(mixed $var, int $maxDepth = 3): string + { + $context = new DebugContext($maxDepth); + $node = static::export($var, $context); + + return static::getInstance()->getExportFormatter()->dump($node); + } + + /** + * Converts a variable to a plain text string. + * + * @param mixed $var Variable to convert. + * @param int $maxDepth The depth to output to. Defaults to 3. + * @return string Variable as a string + */ + public static function exportVarAsPlainText(mixed $var, int $maxDepth = 3): string + { + return (new TextFormatter())->dump( + static::export($var, new DebugContext($maxDepth)), + ); + } + + /** + * Convert the variable to the internal node tree. + * + * The node tree can be manipulated and serialized more easily + * than many object graphs can. + * + * @param mixed $var Variable to convert. + * @param int $maxDepth The depth to generate nodes to. Defaults to 3. + * @return \Cake\Error\Debug\NodeInterface The root node of the tree. + */ + public static function exportVarAsNodes(mixed $var, int $maxDepth = 3): NodeInterface { - return static::_export($var, $depth, 0); + return static::export($var, new DebugContext($maxDepth)); } /** * Protected export function used to keep track of indentation and recursion. * * @param mixed $var The variable to dump. - * @param int $depth The remaining depth. - * @param int $indent The current indentation level. - * @return string The dumped variable. + * @param \Cake\Error\Debug\DebugContext $context Dump context + * @return \Cake\Error\Debug\NodeInterface The dumped variable. */ - protected static function _export($var, $depth, $indent) + protected static function export(mixed $var, DebugContext $context): NodeInterface { - switch (static::getType($var)) { - case 'boolean': - return $var ? 'true' : 'false'; - case 'integer': - return '(int) ' . $var; - case 'float': - return '(float) ' . $var; - case 'string': - if (trim($var) === '' && ctype_space($var) === false) { - return "''"; - } + $type = static::getType($var); - return "'" . $var . "'"; - case 'array': - return static::_array($var, $depth - 1, $indent + 1); - case 'resource': - return strtolower(gettype($var)); - case 'null': - return 'null'; - case 'unknown': - return 'unknown'; - default: - return static::_object($var, $depth - 1, $indent + 1); + if (str_starts_with($type, 'resource ')) { + return new ScalarNode($type, $var); } + + return match ($type) { + 'float', 'string', 'null' => new ScalarNode($type, $var), + 'bool' => new ScalarNode('bool', $var), + 'int' => new ScalarNode('int', $var), + 'array' => static::exportArray($var, $context->withAddedDepth()), + 'unknown' => new SpecialNode('(unknown)'), + default => static::exportObject($var, $context->withAddedDepth()), + }; } /** * Export an array type object. Filters out keys used in datasource configuration. * - * The following keys are replaced with ***'s + * By default the following keys are replaced with ***'s * * - password * - login @@ -545,79 +680,80 @@ protected static function _export($var, $depth, $indent) * - schema * * @param array $var The array to export. - * @param int $depth The current depth, used for recursion tracking. - * @param int $indent The current indentation level. - * @return string Exported array. + * @param \Cake\Error\Debug\DebugContext $context The current dump context. + * @return \Cake\Error\Debug\ArrayNode Exported array. */ - protected static function _array(array $var, $depth, $indent) + protected static function exportArray(array $var, DebugContext $context): ArrayNode { - $out = '['; - $break = $end = null; - if (!empty($var)) { - $break = "\n" . str_repeat("\t", $indent); - $end = "\n" . str_repeat("\t", $indent - 1); - } - $vars = []; + $items = []; - if ($depth >= 0) { - $outputMask = (array)static::outputMask(); + $remaining = $context->remainingDepth(); + if ($remaining >= 0) { + $outputMask = static::outputMask(); foreach ($var as $key => $val) { - // Sniff for globals as !== explodes in < 5.4 - if ($key === 'GLOBALS' && is_array($val) && isset($val['GLOBALS'])) { - $val = '[recursion]'; - } elseif (array_key_exists($key, $outputMask)) { - $val = (string)$outputMask[$key]; + if (array_key_exists($key, $outputMask)) { + $node = new ScalarNode('string', $outputMask[$key]); } elseif ($val !== $var) { - $val = static::_export($val, $depth, $indent); + // Dump all the items without increasing depth. + $node = static::export($val, $context); + } else { + // Likely recursion, so we increase depth. + $node = static::export($val, $context->withAddedDepth()); } - $vars[] = $break . static::exportVar($key) . - ' => ' . - $val; + $items[] = new ArrayItemNode(static::export($key, $context), $node); } } else { - $vars[] = $break . '[maximum depth reached]'; + $items[] = new ArrayItemNode( + new ScalarNode('string', ''), + new SpecialNode('[maximum depth reached]'), + ); } - return $out . implode(',', $vars) . $end . ']'; + return new ArrayNode($items); } /** - * Handles object to string conversion. + * Handles object to node conversion. * * @param object $var Object to convert. - * @param int $depth The current depth, used for tracking recursion. - * @param int $indent The current indentation level. - * @return string + * @param \Cake\Error\Debug\DebugContext $context The dump context. + * @return \Cake\Error\Debug\NodeInterface * @see \Cake\Error\Debugger::exportVar() */ - protected static function _object($var, $depth, $indent) + protected static function exportObject(object $var, DebugContext $context): NodeInterface { - $out = ''; - $props = []; - - $className = get_class($var); - $out .= 'object(' . $className . ') {'; - $break = "\n" . str_repeat("\t", $indent); - $end = "\n" . str_repeat("\t", $indent - 1); - - if ($depth > 0 && method_exists($var, '__debugInfo')) { - try { - return $out . "\n" . - substr(static::_array($var->__debugInfo(), $depth - 1, $indent), 1, -1) . - $end . '}'; - } catch (Exception $e) { - $message = $e->getMessage(); - - return $out . "\n(unable to export object: $message)\n }"; - } + $isRef = $context->hasReference($var); + $refNum = $context->getReferenceId($var); + + $className = $var::class; + if ($isRef) { + return new ReferenceNode($className, $refNum); } + $node = new ClassNode($className, $refNum); + + $remaining = $context->remainingDepth(); + if ($remaining > 0) { + if (method_exists($var, '__debugInfo')) { + try { + foreach ((array)$var->__debugInfo() as $key => $val) { + $node->addProperty(new PropertyNode("'{$key}'", null, static::export($val, $context))); + } + + return $node; + } catch (Exception $e) { + return new SpecialNode("(unable to export object: {$e->getMessage()})"); + } + } - if ($depth > 0) { - $outputMask = (array)static::outputMask(); + $outputMask = static::outputMask(); $objectVars = get_object_vars($var); foreach ($objectVars as $key => $value) { - $value = array_key_exists($key, $outputMask) ? $outputMask[$key] : static::_export($value, $depth - 1, $indent); - $props[] = "$key => " . $value; + if (array_key_exists($key, $outputMask)) { + $value = $outputMask[$key]; + } + $node->addProperty( + new PropertyNode((string)$key, 'public', static::export($value, $context->withAddedDepth())), + ); } $ref = new ReflectionObject($var); @@ -629,225 +765,26 @@ protected static function _object($var, $depth, $indent) foreach ($filters as $filter => $visibility) { $reflectionProperties = $ref->getProperties($filter); foreach ($reflectionProperties as $reflectionProperty) { - $reflectionProperty->setAccessible(true); - $property = $reflectionProperty->getValue($var); - - $value = static::_export($property, $depth - 1, $indent); - $key = $reflectionProperty->name; - $props[] = sprintf( - '[%s] %s => %s', - $visibility, - $key, - array_key_exists($key, $outputMask) ? $outputMask[$key] : $value + if ( + method_exists($reflectionProperty, 'isInitialized') && + !$reflectionProperty->isInitialized($var) + ) { + $value = new SpecialNode('[uninitialized]'); + } else { + $value = static::export($reflectionProperty->getValue($var), $context->withAddedDepth()); + } + $node->addProperty( + new PropertyNode( + $reflectionProperty->getName(), + $visibility, + $value, + ), ); } } - - $out .= $break . implode($break, $props) . $end; - } - $out .= '}'; - - return $out; - } - - /** - * Get the output format for Debugger error rendering. - * - * @return string Returns the current format when getting. - */ - public static function getOutputFormat() - { - return Debugger::getInstance()->_outputFormat; - } - - /** - * Set the output format for Debugger error rendering. - * - * @param string $format The format you want errors to be output as. - * @return void - * @throws \InvalidArgumentException When choosing a format that doesn't exist. - */ - public static function setOutputFormat($format) - { - $self = Debugger::getInstance(); - - if (!isset($self->_templates[$format])) { - throw new InvalidArgumentException('Invalid Debugger output format.'); - } - $self->_outputFormat = $format; - } - - /** - * Get/Set the output format for Debugger error rendering. - * - * @deprecated 3.5.0 Use getOutputFormat()/setOutputFormat() instead. - * @param string|null $format The format you want errors to be output as. - * Leave null to get the current format. - * @return string|null Returns null when setting. Returns the current format when getting. - * @throws \InvalidArgumentException When choosing a format that doesn't exist. - */ - public static function outputAs($format = null) - { - $self = Debugger::getInstance(); - if ($format === null) { - return $self->_outputFormat; - } - - if (!isset($self->_templates[$format])) { - throw new InvalidArgumentException('Invalid Debugger output format.'); - } - $self->_outputFormat = $format; - - return null; - } - - /** - * Add an output format or update a format in Debugger. - * - * ``` - * Debugger::addFormat('custom', $data); - * ``` - * - * Where $data is an array of strings that use Text::insert() variable - * replacement. The template vars should be in a `{:id}` style. - * An error formatter can have the following keys: - * - * - 'error' - Used for the container for the error message. Gets the following template - * variables: `id`, `error`, `code`, `description`, `path`, `line`, `links`, `info` - * - 'info' - A combination of `code`, `context` and `trace`. Will be set with - * the contents of the other template keys. - * - 'trace' - The container for a stack trace. Gets the following template - * variables: `trace` - * - 'context' - The container element for the context variables. - * Gets the following templates: `id`, `context` - * - 'links' - An array of HTML links that are used for creating links to other resources. - * Typically this is used to create javascript links to open other sections. - * Link keys, are: `code`, `context`, `help`. See the js output format for an - * example. - * - 'traceLine' - Used for creating lines in the stacktrace. Gets the following - * template variables: `reference`, `path`, `line` - * - * Alternatively if you want to use a custom callback to do all the formatting, you can use - * the callback key, and provide a callable: - * - * ``` - * Debugger::addFormat('custom', ['callback' => [$foo, 'outputError']]; - * ``` - * - * The callback can expect two parameters. The first is an array of all - * the error data. The second contains the formatted strings generated using - * the other template strings. Keys like `info`, `links`, `code`, `context` and `trace` - * will be present depending on the other templates in the format type. - * - * @param string $format Format to use, including 'js' for JavaScript-enhanced HTML, 'html' for - * straight HTML output, or 'txt' for unformatted text. - * @param array $strings Template strings, or a callback to be used for the output format. - * @return array The resulting format string set. - */ - public static function addFormat($format, array $strings) - { - $self = Debugger::getInstance(); - if (isset($self->_templates[$format])) { - if (isset($strings['links'])) { - $self->_templates[$format]['links'] = array_merge( - $self->_templates[$format]['links'], - $strings['links'] - ); - unset($strings['links']); - } - $self->_templates[$format] = $strings + $self->_templates[$format]; - } else { - $self->_templates[$format] = $strings; } - return $self->_templates[$format]; - } - - /** - * Takes a processed array of data from an error and displays it in the chosen format. - * - * @param array $data Data to output. - * @return void - */ - public function outputError($data) - { - $defaults = [ - 'level' => 0, - 'error' => 0, - 'code' => 0, - 'description' => '', - 'file' => '', - 'line' => 0, - 'context' => [], - 'start' => 2, - ]; - $data += $defaults; - - $files = static::trace(['start' => $data['start'], 'format' => 'points']); - $code = ''; - $file = null; - if (isset($files[0]['file'])) { - $file = $files[0]; - } elseif (isset($files[1]['file'])) { - $file = $files[1]; - } - if ($file) { - $code = static::excerpt($file['file'], $file['line'], 1); - } - $trace = static::trace(['start' => $data['start'], 'depth' => '20']); - $insertOpts = ['before' => '{:', 'after' => '}']; - $context = []; - $links = []; - $info = ''; - - foreach ((array)$data['context'] as $var => $value) { - $context[] = "\${$var} = " . static::exportVar($value, 3); - } - - switch ($this->_outputFormat) { - case false: - $this->_data[] = compact('context', 'trace') + $data; - - return; - case 'log': - static::log(compact('context', 'trace') + $data); - - return; - } - - $data['trace'] = $trace; - $data['id'] = 'cakeErr' . uniqid(); - $tpl = $this->_templates[$this->_outputFormat] + $this->_templates['base']; - - if (isset($tpl['links'])) { - foreach ($tpl['links'] as $key => $val) { - $links[$key] = Text::insert($val, $data, $insertOpts); - } - } - - if (!empty($tpl['escapeContext'])) { - $context = h($context); - $data['description'] = h($data['description']); - } - - $infoData = compact('code', 'context', 'trace'); - foreach ($infoData as $key => $value) { - if (empty($value) || !isset($tpl[$key])) { - continue; - } - if (is_array($value)) { - $value = implode("\n", $value); - } - $info .= Text::insert($tpl[$key], [$key => $value] + $data, $insertOpts); - } - $links = implode(' ', $links); - - if (isset($tpl['callback']) && is_callable($tpl['callback'])) { - call_user_func($tpl['callback'], $data, compact('links', 'info')); - - return; - } - echo Text::insert($tpl['error'], compact('links', 'info') + $data, $insertOpts); + return $node; } /** @@ -857,34 +794,19 @@ public function outputError($data) * @param mixed $var The variable to get the type of. * @return string The type of variable. */ - public static function getType($var) + public static function getType(mixed $var): string { - if (is_object($var)) { - return get_class($var); - } - if ($var === null) { - return 'null'; - } - if (is_string($var)) { - return 'string'; - } - if (is_array($var)) { - return 'array'; - } - if (is_int($var)) { - return 'integer'; - } - if (is_bool($var)) { - return 'boolean'; - } - if (is_float($var)) { + $type = get_debug_type($var); + + if ($type === 'double') { return 'float'; } - if (is_resource($var)) { - return 'resource'; + + if ($type === 'unknown type') { + return 'unknown'; } - return 'unknown'; + return $type; } /** @@ -894,59 +816,51 @@ public static function getType($var) * @param array $location If contains keys "file" and "line" their values will * be used to show location info. * @param bool|null $showHtml If set to true, the method prints the debug - * data in a browser-friendly way. + * data encoded as HTML. If false, plain text formatting will be used. + * If null, the format will be chosen based on the configured exportFormatter, or + * environment conditions. * @return void */ - public static function printVar($var, $location = [], $showHtml = null) + public static function printVar(mixed $var, array $location = [], ?bool $showHtml = null): void { $location += ['file' => null, 'line' => null]; - $file = $location['file']; - $line = $location['line']; - $lineInfo = ''; - if ($file) { - $search = []; - if (defined('ROOT')) { - $search = [ROOT]; - } - if (defined('CAKE_CORE_INCLUDE_PATH')) { - array_unshift($search, CAKE_CORE_INCLUDE_PATH); - } - $file = str_replace($search, '', $file); - } - $html = << -%s -
-%s
-
- -HTML; - $text = <<getConfig('exportFormatter'); + $debugger->setConfig('exportFormatter', $showHtml ? HtmlFormatter::class : TextFormatter::class); } - $var = Debugger::exportVar($var, 25); - if ($showHtml) { - $template = $html; - $var = h($var); - if ($file && $line) { - $lineInfo = sprintf('%s (line %s)', $file, $line); - } + $contents = static::exportVar($var, 25); + $formatter = $debugger->getExportFormatter(); + + if ($restore) { + $debugger->setConfig('exportFormatter', $restore); } - printf($template, $lineInfo, $var); + echo $formatter->formatWrapper($contents, $location); + } + + /** + * Format an exception message to be HTML formatted. + * + * Does the following formatting operations: + * + * - HTML escape the message. + * - Convert `bool` into `bool` + * - Convert newlines into `
` + * + * @param string $message The string message to format. + * @return string Formatted message. + */ + public static function formatHtmlMessage(string $message): string + { + $message = h($message); + $message = (string)preg_replace('/`([^`]+)`/', '$0', $message); + + return nl2br($message); } /** @@ -954,10 +868,15 @@ public static function printVar($var, $location = [], $showHtml = null) * * @return void */ - public static function checkSecurityKeys() + public static function checkSecurityKeys(): void { - if (Security::getSalt() === '__SALT__') { - trigger_error(sprintf('Please change the value of %s in %s to a salt value specific to your application.', '\'Security.salt\'', 'ROOT/config/app.php'), E_USER_NOTICE); + $salt = Security::getSalt(); + if ($salt === '__SALT__' || strlen($salt) < 32) { + trigger_error( + 'Please change the value of `Security.salt` in `ROOT/config/app_local.php` ' . + 'to a random value of at least 32 characters.', + E_USER_NOTICE, + ); } } } diff --git a/src/Error/ErrorHandler.php b/src/Error/ErrorHandler.php deleted file mode 100644 index 82b6c53179c..00000000000 --- a/src/Error/ErrorHandler.php +++ /dev/null @@ -1,199 +0,0 @@ - 1. - * - * ### Uncaught exceptions - * - * When debug < 1 a CakeException will render 404 or 500 errors. If an uncaught exception is thrown - * and it is a type that ErrorHandler does not know about it will be treated as a 500 error. - * - * ### Implementing application specific exception handling - * - * You can implement application specific exception handling in one of a few ways. Each approach - * gives you different amounts of control over the exception handling process. - * - * - Modify config/error.php and setup custom exception handling. - * - Use the `exceptionRenderer` option to inject an Exception renderer. This will - * let you keep the existing handling logic but override the rendering logic. - * - * #### Create your own Exception handler - * - * This gives you full control over the exception handling process. The class you choose should be - * loaded in your config/error.php and registered as the default exception handler. - * - * #### Using a custom renderer with `exceptionRenderer` - * - * If you don't want to take control of the exception handling, but want to change how exceptions are - * rendered you can use `exceptionRenderer` option to choose a class to render exception pages. By default - * `Cake\Error\ExceptionRenderer` is used. Your custom exception renderer class should be placed in src/Error. - * - * Your custom renderer should expect an exception in its constructor, and implement a render method. - * Failing to do so will cause additional errors. - * - * #### Logging exceptions - * - * Using the built-in exception handling, you can log all the exceptions - * that are dealt with by ErrorHandler by setting `log` option to true in your config/error.php. - * Enabling this will log every exception to Log and the configured loggers. - * - * ### PHP errors - * - * Error handler also provides the built in features for handling php errors (trigger_error). - * While in debug mode, errors will be output to the screen using debugger. While in production mode, - * errors will be logged to Log. You can control which errors are logged by setting - * `errorLevel` option in config/error.php. - * - * #### Logging errors - * - * When ErrorHandler is used for handling errors, you can enable error logging by setting the `log` - * option to true. This will log all errors to the configured log handlers. - * - * #### Controlling what errors are logged/displayed - * - * You can control which errors are logged / displayed by ErrorHandler by setting `errorLevel`. Setting this - * to one or a combination of a few of the E_* constants will only enable the specified errors: - * - * ``` - * $options['errorLevel'] = E_ALL & ~E_NOTICE; - * ``` - * - * Would enable handling for all non Notice errors. - * - * @see \Cake\Error\ExceptionRenderer for more information on how to customize exception rendering. - */ -class ErrorHandler extends BaseErrorHandler -{ - /** - * Constructor - * - * @param array $options The options for error handling. - */ - public function __construct($options = []) - { - $defaults = [ - 'log' => true, - 'trace' => false, - 'exceptionRenderer' => ExceptionRenderer::class, - ]; - $this->_options = $options + $defaults; - } - - /** - * Display an error. - * - * Template method of BaseErrorHandler. - * - * Only when debug > 2 will a formatted error be displayed. - * - * @param array $error An array of error data. - * @param bool $debug Whether or not the app is in debug mode. - * @return void - */ - protected function _displayError($error, $debug) - { - if (!$debug) { - return; - } - Debugger::getInstance()->outputError($error); - } - - /** - * Displays an exception response body. - * - * @param \Exception $exception The exception to display. - * @return void - * @throws \Exception When the chosen exception renderer is invalid. - */ - protected function _displayException($exception) - { - $rendererClassName = App::className($this->_options['exceptionRenderer'], 'Error'); - try { - if (!$rendererClassName) { - throw new Exception("$rendererClassName is an invalid class."); - } - /** @var \Cake\Error\ExceptionRendererInterface $renderer */ - $renderer = new $rendererClassName($exception); - $response = $renderer->render(); - $this->_clearOutput(); - $this->_sendResponse($response); - } catch (Throwable $exception) { - $this->_logInternalError($exception); - } catch (Exception $exception) { - $this->_logInternalError($exception); - } - } - - /** - * Clear output buffers so error pages display properly. - * - * Easily stubbed in testing. - * - * @return void - */ - protected function _clearOutput() - { - while (ob_get_level()) { - ob_end_clean(); - } - } - - /** - * Logs both PHP5 and PHP7 errors. - * - * The PHP5 part will be removed with 4.0. - * - * @param \Throwable|\Exception $exception Exception. - * - * @return void - */ - protected function _logInternalError($exception) - { - // Disable trace for internal errors. - $this->_options['trace'] = false; - $message = sprintf( - "[%s] %s\n%s", // Keeping same message format - get_class($exception), - $exception->getMessage(), - $exception->getTraceAsString() - ); - trigger_error($message, E_USER_ERROR); - } - - /** - * Method that can be easily stubbed in testing. - * - * @param string|\Cake\Http\Response $response Either the message or response object. - * @return void - */ - protected function _sendResponse($response) - { - if (is_string($response)) { - echo $response; - - return; - } - $response->send(); - } -} diff --git a/src/Error/ErrorLogger.php b/src/Error/ErrorLogger.php new file mode 100644 index 00000000000..1848dc0b175 --- /dev/null +++ b/src/Error/ErrorLogger.php @@ -0,0 +1,229 @@ + + */ + protected array $_defaultConfig = [ + 'trace' => false, + ]; + + /** + * Constructor + * + * @param array $config Config array. + */ + public function __construct(array $config = []) + { + $this->setConfig($config); + } + + /** + * @inheritDoc + */ + public function log($level, Stringable|string $message, array $context = []): void + { + Log::write($level, $message, $context); + } + + /** + * @inheritDoc + */ + public function logError(PhpError $error, ?ServerRequestInterface $request = null, bool $includeTrace = false): void + { + $message = $this->getErrorMessage($error, $includeTrace); + + if ($request instanceof ServerRequestInterface) { + $message .= $this->getRequestContext($request); + } + + $label = $error->getLabel(); + $level = match ($label) { + 'strict' => LOG_NOTICE, + 'deprecated' => LOG_DEBUG, + default => $label, + }; + + $this->log($level, $message); + } + + /** + * Generate the message for the error + * + * @param \Cake\Error\PhpError $error The exception to log a message for. + * @param bool $includeTrace Whether to include a stack trace. + * @return string Error message + */ + protected function getErrorMessage(PhpError $error, bool $includeTrace = false): string + { + $message = sprintf( + '%s in %s on line %s', + $error->getMessage(), + $error->getFile(), + $error->getLine(), + ); + + if (!$includeTrace) { + return $message; + } + + $message .= "\nTrace:\n" . $error->getTraceAsString() . "\n"; + + return $message; + } + + /** + * @inheritDoc + */ + public function logException( + Throwable $exception, + ?ServerRequestInterface $request = null, + bool $includeTrace = false, + ): void { + $message = $this->getMessage($exception, false, $includeTrace); + + if ($request !== null) { + $message .= $this->getRequestContext($request); + } + + $context = $this->getExceptionContext($exception); + $this->error($message, $context); + } + + /** + * Extract additional context from an exception. + * + * For database exceptions, this includes the connection name + * to help identify which database connection caused the error. + * + * @param \Throwable $exception The exception to extract context from. + * @return array Additional context data. + */ + protected function getExceptionContext(Throwable $exception): array + { + $context = []; + + if ($exception instanceof QueryException) { + $connectionName = $exception->getConnectionName(); + if ($connectionName !== '') { + $context['connection'] = $connectionName; + } + } + + return $context; + } + + /** + * Generate the message for the exception + * + * @param \Throwable $exception The exception to log a message for. + * @param bool $isPrevious False for original exception, true for previous + * @param bool $includeTrace Whether to include a stack trace. + * @return string Error message + */ + protected function getMessage(Throwable $exception, bool $isPrevious = false, bool $includeTrace = false): string + { + $message = sprintf( + '%s[%s] %s in %s on line %s', + $isPrevious ? "\nCaused by: " : '', + $exception::class, + $exception->getMessage(), + $exception->getFile(), + $exception->getLine(), + ); + $debug = Configure::read('debug'); + + if ($debug && $exception instanceof CakeException) { + $attributes = $exception->getAttributes(); + if ($attributes) { + $message .= "\nException Attributes: " . var_export($exception->getAttributes(), true); + } + } + + if ($includeTrace) { + $trace = Debugger::formatTrace( + $exception, + ['format' => Configure::read('Error.traceFormat', 'shortPoints')], + ); + assert(is_array($trace)); + $message .= "\nStack Trace:\n"; + foreach ($trace as $line) { + if (is_string($line)) { + $message .= '- ' . $line; + } else { + $message .= "- {$line['file']}:{$line['line']}\n"; + } + } + } + + $previous = $exception->getPrevious(); + if ($previous) { + $message .= $this->getMessage($previous, true, $includeTrace); + } + + return $message; + } + + /** + * Get the request context for an error/exception trace. + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request to read from. + * @return string + */ + public function getRequestContext(ServerRequestInterface $request): string + { + $message = "\nRequest URL: " . $request->getRequestTarget(); + + $referer = $request->getHeaderLine('Referer'); + if ($referer) { + $message .= "\nReferer URL: " . $referer; + } + + if ($request instanceof ServerRequest) { + $clientIp = $request->clientIp(); + if ($clientIp && $clientIp !== '::1') { + $message .= "\nClient IP: " . $clientIp; + } + } + + return $message; + } +} diff --git a/src/Error/ErrorLoggerInterface.php b/src/Error/ErrorLoggerInterface.php new file mode 100644 index 00000000000..bba6907f491 --- /dev/null +++ b/src/Error/ErrorLoggerInterface.php @@ -0,0 +1,56 @@ + + */ + protected array $_defaultConfig = [ + 'errorLevel' => E_ALL, + 'errorRenderer' => null, + 'log' => true, + 'logger' => ErrorLogger::class, + 'trace' => false, + ]; + + /** + * Constructor + * + * @param array $options An options array. See $_defaultConfig. + */ + public function __construct(array $options = []) + { + $this->setConfig($options); + } + + /** + * Choose an error renderer based on config or the SAPI + * + * @return class-string<\Cake\Error\ErrorRendererInterface> + */ + protected function chooseErrorRenderer(): string + { + $config = $this->getConfig('errorRenderer'); + if ($config !== null) { + return $config; + } + + /** @var class-string<\Cake\Error\ErrorRendererInterface> */ + return PHP_SAPI === 'cli' ? ConsoleErrorRenderer::class : HtmlErrorRenderer::class; + } + + /** + * Attach this ErrorTrap to PHP's default error handler. + * + * This will replace the existing error handler, and the + * previous error handler will be discarded. + * + * This method will also set the global error level + * via error_reporting(). + * + * @return void + */ + public function register(): void + { + $level = $this->_config['errorLevel'] ?? -1; + error_reporting($level); + set_error_handler($this->handleError(...), $level); + } + + /** + * Handle an error from PHP set_error_handler + * + * Will use the configured renderer to generate output + * and output it. + * + * This method will dispatch the `Error.beforeRender` event which can be listened + * to on the global event manager. + * + * @param int $code Code of error + * @param string $description Error description + * @param string|null $file File on which error occurred + * @param int|null $line Line that triggered the error + * @return bool True if error was handled + */ + public function handleError( + int $code, + string $description, + ?string $file = null, + ?int $line = null, + ): bool { + if (!(error_reporting() & $code)) { + return false; + } + if (in_array($code, [E_USER_ERROR, E_ERROR, E_PARSE], true)) { + throw new FatalErrorException($description, $code, $file, $line); + } + + $trace = (array)Debugger::trace(['start' => 0, 'format' => 'points']); + $error = new PhpError($code, $description, $file, $line, $trace); + + $ignoredPaths = (array)Configure::read('Error.ignoredDeprecationPaths'); + if ($code === E_USER_DEPRECATED && $ignoredPaths) { + $relativePath = str_replace(DIRECTORY_SEPARATOR, '/', substr((string)$file, strlen(ROOT) + 1)); + foreach ($ignoredPaths as $pattern) { + $pattern = str_replace(DIRECTORY_SEPARATOR, '/', $pattern); + if (fnmatch($pattern, $relativePath)) { + return true; + } + } + } + + $debug = Configure::read('debug'); + $renderer = $this->renderer(); + + try { + // Log first in case rendering or event listeners fail + $this->logError($error); + $event = $this->dispatchEvent('Error.beforeRender', ['error' => $error]); + if ($event->isStopped()) { + return true; + } + $renderer->write($event->getResult() ?: $renderer->render($error, $debug)); + } catch (Exception $e) { + // Fatal errors always log. + $this->logger()->logException($e); + + return false; + } + + return true; + } + + /** + * Logging helper method. + * + * @param \Cake\Error\PhpError $error The error object to log. + * @return void + */ + protected function logError(PhpError $error): void + { + if (!$this->_config['log']) { + return; + } + $this->logger()->logError($error, Router::getRequest(), $this->_config['trace']); + } + + /** + * Get an instance of the renderer. + * + * @return \Cake\Error\ErrorRendererInterface + */ + public function renderer(): ErrorRendererInterface + { + /** @var class-string<\Cake\Error\ErrorRendererInterface> $class */ + $class = $this->getConfig('errorRenderer') ?: $this->chooseErrorRenderer(); + + return new $class($this->_config); + } + + /** + * Get an instance of the logger. + * + * @return \Cake\Error\ErrorLoggerInterface + */ + public function logger(): ErrorLoggerInterface + { + /** @var class-string<\Cake\Error\ErrorLoggerInterface> $class */ + $class = $this->getConfig('logger', $this->_defaultConfig['logger']); + + return new $class($this->_config); + } +} diff --git a/src/Error/ExceptionRenderer.php b/src/Error/ExceptionRenderer.php deleted file mode 100644 index 08596d09df6..00000000000 --- a/src/Error/ExceptionRenderer.php +++ /dev/null @@ -1,393 +0,0 @@ -error = $exception; - $this->controller = $this->_getController(); - } - - /** - * Returns the unwrapped exception object in case we are dealing with - * a PHP 7 Error object - * - * @param \Exception $exception The object to unwrap - * @return \Exception|\Error - */ - protected function _unwrap($exception) - { - return $exception instanceof PHP7ErrorException ? $exception->getError() : $exception; - } - - /** - * Get the controller instance to handle the exception. - * Override this method in subclasses to customize the controller used. - * This method returns the built in `ErrorController` normally, or if an error is repeated - * a bare controller will be used. - * - * @return \Cake\Controller\Controller - * @triggers Controller.startup $controller - */ - protected function _getController() - { - if (!$request = Router::getRequest(true)) { - $request = ServerRequest::createFromGlobals(); - } - $response = new Response(); - $controller = null; - - try { - $class = App::className('Error', 'Controller', 'Controller'); - /* @var \Cake\Controller\Controller $controller */ - $controller = new $class($request, $response); - $controller->startupProcess(); - $startup = true; - } catch (Exception $e) { - $startup = false; - } - - // Retry RequestHandler, as another aspect of startupProcess() - // could have failed. Ignore any exceptions out of startup, as - // there could be userland input data parsers. - if ($startup === false && !empty($controller) && isset($controller->RequestHandler)) { - try { - $event = new Event('Controller.startup', $controller); - $controller->RequestHandler->startup($event); - } catch (Exception $e) { - } - } - if (empty($controller)) { - $controller = new Controller($request, $response); - } - - return $controller; - } - - /** - * Renders the response for the exception. - * - * @return \Cake\Http\Response The response to be sent. - */ - public function render() - { - $exception = $this->error; - $code = $this->_code($exception); - $method = $this->_method($exception); - $template = $this->_template($exception, $method, $code); - $unwrapped = $this->_unwrap($exception); - - $isDebug = Configure::read('debug'); - if (($isDebug || $exception instanceof HttpException) && - method_exists($this, $method) - ) { - return $this->_customMethod($method, $unwrapped); - } - - $message = $this->_message($exception, $code); - $url = $this->controller->request->getRequestTarget(); - - if ($exception instanceof CakeException) { - $this->controller->response->header($exception->responseHeader()); - } - $this->controller->response->statusCode($code); - $viewVars = [ - 'message' => $message, - 'url' => h($url), - 'error' => $unwrapped, - 'code' => $code, - '_serialize' => ['message', 'url', 'code'] - ]; - if ($isDebug) { - $viewVars['trace'] = Debugger::formatTrace($unwrapped->getTrace(), [ - 'format' => 'array', - 'args' => false - ]); - $viewVars['file'] = $exception->getFile() ?: 'null'; - $viewVars['line'] = $exception->getLine() ?: 'null'; - $viewVars['_serialize'][] = 'file'; - $viewVars['_serialize'][] = 'line'; - } - $this->controller->set($viewVars); - - if ($unwrapped instanceof CakeException && $isDebug) { - $this->controller->set($unwrapped->getAttributes()); - } - - return $this->_outputMessage($template); - } - - /** - * Render a custom error method/template. - * - * @param string $method The method name to invoke. - * @param \Exception $exception The exception to render. - * @return \Cake\Http\Response The response to send. - */ - protected function _customMethod($method, $exception) - { - $result = call_user_func([$this, $method], $exception); - $this->_shutdown(); - if (is_string($result)) { - $this->controller->response->body($result); - $result = $this->controller->response; - } - - return $result; - } - - /** - * Get method name - * - * @param \Exception $exception Exception instance. - * @return string - */ - protected function _method(Exception $exception) - { - $exception = $this->_unwrap($exception); - list(, $baseClass) = namespaceSplit(get_class($exception)); - - if (substr($baseClass, -9) === 'Exception') { - $baseClass = substr($baseClass, 0, -9); - } - - $method = Inflector::variable($baseClass) ?: 'error500'; - - return $this->method = $method; - } - - /** - * Get error message. - * - * @param \Exception $exception Exception. - * @param int $code Error code. - * @return string Error message - */ - protected function _message(Exception $exception, $code) - { - $exception = $this->_unwrap($exception); - $message = $exception->getMessage(); - - if (!Configure::read('debug') && - !($exception instanceof HttpException) - ) { - if ($code < 500) { - $message = __d('cake', 'Not Found'); - } else { - $message = __d('cake', 'An Internal Error Has Occurred.'); - } - } - - return $message; - } - - /** - * Get template for rendering exception info. - * - * @param \Exception $exception Exception instance. - * @param string $method Method name. - * @param int $code Error code. - * @return string Template name - */ - protected function _template(Exception $exception, $method, $code) - { - $exception = $this->_unwrap($exception); - $isHttpException = $exception instanceof HttpException; - - if (!Configure::read('debug') && !$isHttpException || $isHttpException) { - $template = 'error500'; - if ($code < 500) { - $template = 'error400'; - } - - return $this->template = $template; - } - - $template = $method ?: 'error500'; - - if ($exception instanceof PDOException) { - $template = 'pdo_error'; - } - - return $this->template = $template; - } - - /** - * Get an error code value within range 400 to 506 - * - * @param \Exception $exception Exception. - * @return int Error code value within range 400 to 506 - */ - protected function _code(Exception $exception) - { - $code = 500; - $exception = $this->_unwrap($exception); - $errorCode = $exception->getCode(); - if ($errorCode >= 400 && $errorCode < 506) { - $code = $errorCode; - } - - return $code; - } - - /** - * Generate the response using the controller object. - * - * @param string $template The template to render. - * @return \Cake\Http\Response A response object that can be sent. - */ - protected function _outputMessage($template) - { - try { - $this->controller->render($template); - - return $this->_shutdown(); - } catch (MissingTemplateException $e) { - $attributes = $e->getAttributes(); - if (isset($attributes['file']) && strpos($attributes['file'], 'error500') !== false) { - return $this->_outputMessageSafe('error500'); - } - - return $this->_outputMessage('error500'); - } catch (MissingPluginException $e) { - $attributes = $e->getAttributes(); - if (isset($attributes['plugin']) && $attributes['plugin'] === $this->controller->plugin) { - $this->controller->plugin = null; - } - - return $this->_outputMessageSafe('error500'); - } catch (Exception $e) { - return $this->_outputMessageSafe('error500'); - } - } - - /** - * A safer way to render error messages, replaces all helpers, with basics - * and doesn't call component methods. - * - * @param string $template The template to render. - * @return \Cake\Http\Response A response object that can be sent. - */ - protected function _outputMessageSafe($template) - { - $helpers = ['Form', 'Html']; - $this->controller->helpers = $helpers; - $builder = $this->controller->viewBuilder(); - $builder->setHelpers($helpers, false) - ->setLayoutPath('') - ->setTemplatePath('Error'); - $view = $this->controller->createView('View'); - - $this->controller->response->body($view->render($template, 'error')); - $this->controller->response->type('html'); - - return $this->controller->response; - } - - /** - * Run the shutdown events. - * - * Triggers the afterFilter and afterDispatch events. - * - * @return \Cake\Http\Response The response to serve. - */ - protected function _shutdown() - { - $this->controller->dispatchEvent('Controller.shutdown'); - $dispatcher = DispatcherFactory::create(); - $eventManager = $dispatcher->getEventManager(); - foreach ($dispatcher->filters() as $filter) { - $eventManager->on($filter); - } - $args = [ - 'request' => $this->controller->request, - 'response' => $this->controller->response - ]; - $result = $dispatcher->dispatchEvent('Dispatcher.afterDispatch', $args); - - return $result->getData('response'); - } -} diff --git a/src/Error/ExceptionRendererInterface.php b/src/Error/ExceptionRendererInterface.php index 9e28438830c..2f7a248952b 100644 --- a/src/Error/ExceptionRendererInterface.php +++ b/src/Error/ExceptionRendererInterface.php @@ -1,4 +1,6 @@ ['Cake\Http\Exception\NotFoundException', 'Cake\Http\Exception\UnauthorizedException'] + * ``` + * This option is forwarded to the configured `logger` + * - `extraFatalErrorMemory` - int - The number of megabytes to increase the memory limit by when a fatal error is + * encountered. This allows breathing room to complete logging or error handling. + * - `stderr` Used in console environments so that renderers have access to the current console output stream. + * + * @var array + */ + protected array $_defaultConfig = [ + 'exceptionRenderer' => null, + 'logger' => ErrorLogger::class, + 'stderr' => null, + 'log' => true, + 'skipLog' => [], + 'trace' => false, + 'extraFatalErrorMemory' => 4, + ]; + + /** + * A list of handling callbacks. + * + * Callbacks are invoked for each error that is handled. + * Callbacks are invoked in the order they are attached. + * + * @var array<\Closure> + */ + protected array $callbacks = []; + + /** + * The currently registered global exception handler + * + * This is best effort as we can't know if/when another + * exception handler is registered. + * + * @var \Cake\Error\ExceptionTrap|null + */ + protected static ?ExceptionTrap $registeredTrap = null; + + /** + * Track if this trap was removed from the global handler. + * + * @var bool + */ + protected bool $disabled = false; + + /** + * Constructor + * + * @param array $options An options array. See $_defaultConfig. + */ + public function __construct(array $options = []) + { + $this->setConfig($options); + } + + /** + * Get an instance of the renderer. + * + * @param \Throwable $exception Exception to render + * @param \Psr\Http\Message\ServerRequestInterface|null $request The request if possible. + * @return \Cake\Error\ExceptionRendererInterface + */ + public function renderer(Throwable $exception, ?ServerRequestInterface $request = null): ExceptionRendererInterface + { + $request ??= Router::getRequest(); + + /** @var callable|class-string $class */ + $class = $this->getConfig('exceptionRenderer') ?: $this->chooseRenderer(); + + if (is_string($class)) { + if (!is_subclass_of($class, ExceptionRendererInterface::class)) { + throw new InvalidArgumentException( + "Cannot use `{$class}` as an `exceptionRenderer`. " . + 'It must be an instance of `Cake\Error\ExceptionRendererInterface`.', + ); + } + + /** @var class-string<\Cake\Error\ExceptionRendererInterface> $class */ + return new $class($exception, $request, $this->_config); + } + + return $class($exception, $request); + } + + /** + * Choose an exception renderer based on config or the SAPI + * + * @return class-string<\Cake\Error\ExceptionRendererInterface> + */ + protected function chooseRenderer(): string + { + /** @var class-string<\Cake\Error\ExceptionRendererInterface> */ + return PHP_SAPI === 'cli' ? ConsoleExceptionRenderer::class : WebExceptionRenderer::class; + } + + /** + * Get an instance of the logger. + * + * @return \Cake\Error\ErrorLoggerInterface + */ + public function logger(): ErrorLoggerInterface + { + /** @var class-string<\Cake\Error\ErrorLoggerInterface> $class */ + $class = $this->getConfig('logger', $this->_defaultConfig['logger']); + + return new $class($this->_config); + } + + /** + * Attach this ExceptionTrap to PHP's default exception handler. + * + * This will replace the existing exception handler, and the + * previous exception handler will be discarded. + * + * @return void + */ + public function register(): void + { + set_exception_handler($this->handleException(...)); + register_shutdown_function($this->handleShutdown(...)); + static::$registeredTrap = $this; + + ini_set('assert.exception', '1'); + } + + /** + * Remove this instance from the singleton + * + * If this instance is not currently the registered singleton + * nothing happens. + * + * @return void + */ + public function unregister(): void + { + if (static::$registeredTrap === $this) { + $this->disabled = true; + static::$registeredTrap = null; + restore_exception_handler(); + } + } + + /** + * Get the registered global instance if set. + * + * Keep in mind that the global state contained here + * is mutable and the object returned by this method + * could be a stale value. + * + * @return \Cake\Error\ExceptionTrap|null The global instance or null. + */ + public static function instance(): ?self + { + return static::$registeredTrap; + } + + /** + * Handle uncaught exceptions. + * + * Uses a template method provided by subclasses to display errors in an + * environment appropriate way. + * + * @param \Throwable $exception Exception instance. + * @return void + * @throws \Exception When renderer class not found + * @see https://secure.php.net/manual/en/function.set-exception-handler.php + */ + public function handleException(Throwable $exception): void + { + if ($this->disabled) { + return; + } + $request = Router::getRequest(); + + $this->logException($exception, $request); + + try { + $event = $this->dispatchEvent('Exception.beforeRender', ['exception' => $exception, 'request' => $request]); + if ($event->isStopped()) { + return; + } + $exception = $event->getData('exception'); + assert($exception instanceof Throwable); + + $renderer = $this->renderer($exception, $request); + $renderer->write($event->getResult() ?: $renderer->render()); + } catch (Throwable $exception) { + $this->logInternalError($exception); + } + // Use this constant as a proxy for cakephp tests. + if (PHP_SAPI === 'cli' && !env('FIXTURE_SCHEMA_METADATA')) { + exit(1); + } + } + + /** + * Shutdown handler + * + * Convert fatal errors into exceptions that we can render. + * + * @return void + */ + public function handleShutdown(): void + { + if ($this->disabled) { + return; + } + $megabytes = $this->_config['extraFatalErrorMemory'] ?? 4; + if ($megabytes > 0) { + $this->increaseMemoryLimit($megabytes * 1024); + } + $this->handleLastError(error_get_last()); + } + + /** + * Handle the last PHP error captured during shutdown. + * + * @param array|null $error The last PHP error. + * @return void + */ + protected function handleLastError(?array $error): void + { + if (!is_array($error)) { + return; + } + $fatals = [ + E_USER_ERROR, + E_ERROR, + E_PARSE, + E_COMPILE_ERROR, + ]; + if (!in_array($error['type'], $fatals, true)) { + return; + } + $description = $error['message']; + $trace = $error['trace'] ?? []; + if (is_array($trace) && $trace) { + $formattedTrace = Debugger::formatTrace($trace, ['format' => 'text', 'args' => false]); + assert(is_string($formattedTrace)); + $description .= "\nStack Trace:\n" . $formattedTrace; + } + $this->handleFatalError( + $error['type'], + $description, + $error['file'], + $error['line'], + ); + } + + /** + * Increases the PHP "memory_limit" ini setting by the specified amount + * in kilobytes + * + * @param int $additionalKb Number in kilobytes + * @return void + */ + public function increaseMemoryLimit(int $additionalKb): void + { + $limit = ini_get('memory_limit'); + if (in_array($limit, [false, '', '-1'], true)) { + return; + } + $limit = trim($limit); + $units = strtoupper(substr($limit, -1)); + $current = (int)substr($limit, 0, -1); + if ($units === 'M') { + $current *= 1024; + $units = 'K'; + } + if ($units === 'G') { + $current = $current * 1024 * 1024; + $units = 'K'; + } + + if ($units === 'K') { + ini_set('memory_limit', ceil($current + $additionalKb) . 'K'); + } + } + + /** + * Display/Log a fatal error. + * + * @param int $code Code of error + * @param string $description Error description + * @param string $file File on which error occurred + * @param int $line Line that triggered the error + * @return void + */ + public function handleFatalError(int $code, string $description, string $file, int $line): void + { + $this->handleException(new FatalErrorException('Fatal Error: ' . $description, 500, $file, $line)); + } + + /** + * Log an exception. + * + * Primarily a public function to ensure consistency between global exception handling + * and the ErrorHandlerMiddleware. This method will apply the `skipLog` filter + * skipping logging if the exception should not be logged. + * + * After logging is attempted the `Exception.beforeRender` event is triggered. + * + * @param \Throwable $exception The exception to log + * @param \Psr\Http\Message\ServerRequestInterface|null $request The optional request + * @return void + */ + public function logException(Throwable $exception, ?ServerRequestInterface $request = null): void + { + $shouldLog = $this->_config['log']; + if ($shouldLog) { + foreach ($this->getConfig('skipLog') as $class) { + if ($exception instanceof $class) { + $shouldLog = false; + break; + } + } + } + if ($shouldLog) { + $this->logger()->logException($exception, $request, $this->_config['trace']); + } + } + + /** + * Trigger an error that occurred during rendering an exception. + * + * By triggering an E_USER_WARNING we can end up in the default + * exception handling which will log the rendering failure, + * and hopefully render an error page. + * + * @param \Throwable $exception Exception to log + * @return void + */ + public function logInternalError(Throwable $exception): void + { + $message = sprintf( + '[%s] %s (%s:%s)', // Keeping same message format + $exception::class, + $exception->getMessage(), + $exception->getFile(), + $exception->getLine(), + ); + trigger_error($message, E_USER_WARNING); + } +} diff --git a/src/Error/FatalErrorException.php b/src/Error/FatalErrorException.php index 4613e8e4725..fa4c754b6e7 100644 --- a/src/Error/FatalErrorException.php +++ b/src/Error/FatalErrorException.php @@ -1,4 +1,6 @@ file = $file; diff --git a/src/Error/Middleware/ErrorHandlerMiddleware.php b/src/Error/Middleware/ErrorHandlerMiddleware.php index e433a6fb3de..f90682a3a31 100644 --- a/src/Error/Middleware/ErrorHandlerMiddleware.php +++ b/src/Error/Middleware/ErrorHandlerMiddleware.php @@ -1,4 +1,6 @@ ['Cake\Error\NotFoundException', 'Cake\Error\UnauthorizedException'] - * ``` + * Ignored if constructor is passed an ExceptionTrap instance. * - * - `trace` Should error logs include stack traces? + * Configuration keys and values are shared with `ExceptionTrap`. + * This class will pass its configuration onto the ExceptionTrap + * class if you are using the array style constructor. * - * @var array + * @var array + * @see \Cake\Error\ExceptionTrap */ - protected $_defaultConfig = [ - 'skipLog' => [], - 'log' => true, - 'trace' => false, + protected array $_defaultConfig = [ + 'exceptionRenderer' => WebExceptionRenderer::class, ]; /** - * Exception render. + * ExceptionTrap instance * - * @var \Cake\Error\ExceptionRendererInterface|callable|string|null + * @var \Cake\Error\ExceptionTrap|null + */ + protected ?ExceptionTrap $exceptionTrap = null; + + /** + * @var \Cake\Routing\RoutingApplicationInterface|null */ - protected $exceptionRenderer; + protected ?RoutingApplicationInterface $app = null; /** * Constructor * - * @param string|callable|null $exceptionRenderer The renderer or class name - * to use or a callable factory. If null, Configure::read('Error.exceptionRenderer') - * will be used. - * @param array $config Configuration options to use. If empty, `Configure::read('Error')` - * will be used. + * @param \Cake\Error\ExceptionTrap|array $config The error handler instance + * or config array. + * @param \Cake\Routing\RoutingApplicationInterface|null $app Application instance. */ - public function __construct($exceptionRenderer = null, array $config = []) + public function __construct(ExceptionTrap|array $config = [], ?RoutingApplicationInterface $app = null) { - if ($exceptionRenderer) { - $this->exceptionRenderer = $exceptionRenderer; + $this->app = $app; + + if (Configure::read('debug')) { + ini_set('zend.exception_ignore_args', '0'); + } + + if (is_array($config)) { + $this->setConfig($config); + + return; } - $config = $config ?: Configure::read('Error'); - $this->setConfig($config); + $this->exceptionTrap = $config; } /** * Wrap the remaining middleware with error handling. * * @param \Psr\Http\Message\ServerRequestInterface $request The request. - * @param \Psr\Http\Message\ResponseInterface $response The response. - * @param callable $next Callback to invoke the next middleware. + * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler. * @return \Psr\Http\Message\ResponseInterface A response */ - public function __invoke($request, $response, $next) + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { try { - return $next($request, $response); + return $handler->handle($request); + } catch (RedirectException $exception) { + return $this->handleRedirect($exception); } catch (Throwable $exception) { - return $this->handleException($exception, $request, $response); - } catch (Exception $exception) { - return $this->handleException($exception, $request, $response); + return $this->handleException($exception, Router::getRequest() ?? $request); } } /** * Handle an exception and generate an error response * - * @param \Exception $exception The exception to handle. + * @param \Throwable $exception The exception to handle. * @param \Psr\Http\Message\ServerRequestInterface $request The request. - * @param \Psr\Http\Message\ResponseInterface $response The response. - * @return \Psr\Http\Message\ResponseInterface A response + * @return \Psr\Http\Message\ResponseInterface A response. */ - public function handleException($exception, $request, $response) + public function handleException(Throwable $exception, ServerRequestInterface $request): ResponseInterface { - $renderer = $this->getRenderer($exception); - try { - $res = $renderer->render(); - $this->logException($request, $exception); + $this->loadRoutes(); - return $res; - } catch (Throwable $exception) { - $this->logException($request, $exception); - $response = $this->handleInternalError($response); - } catch (Exception $exception) { - $this->logException($request, $exception); - $response = $this->handleInternalError($response); + $trap = $this->getExceptionTrap(); + $trap->logException($exception, $request); + + $event = $this->dispatchEvent( + 'Exception.beforeRender', + ['exception' => $exception, 'request' => $request], + $trap, + ); + + $response = $event->getResult(); + if ($response === null) { + $renderer = $trap->renderer($event->getData('exception'), $request); } - return $response; + try { + $response ??= $renderer->render(); + if (is_string($response)) { + return new Response(['body' => $response, 'status' => 500]); + } + + return $response; + } catch (Throwable $internalException) { + $trap->logException($internalException, $request); + + return $this->handleInternalError(); + } } /** - * @param \Psr\Http\Message\ResponseInterface $response The response + * Convert a redirect exception into a response. * - * @return \Psr\Http\Message\ResponseInterface A response + * @param \Cake\Http\Exception\RedirectException $exception The exception to handle + * @return \Psr\Http\Message\ResponseInterface Response created from the redirect. */ - protected function handleInternalError($response) + public function handleRedirect(RedirectException $exception): ResponseInterface { - $body = $response->getBody(); - $body->write('An Internal Server Error Occurred'); - - return $response->withStatus(500) - ->withBody($body); + return new RedirectResponse( + $exception->getMessage(), + $exception->getCode(), + $exception->getHeaders(), + ); } /** - * Get a renderer instance + * Handle internal errors. * - * @param \Exception $exception The exception being rendered. - * @return \Cake\Error\ExceptionRendererInterface The exception renderer. - * @throws \Exception When the renderer class cannot be found. + * @return \Psr\Http\Message\ResponseInterface A response */ - protected function getRenderer($exception) + protected function handleInternalError(): ResponseInterface { - if (!$this->exceptionRenderer) { - $this->exceptionRenderer = $this->getConfig('exceptionRenderer') ?: ExceptionRenderer::class; - } - - // For PHP5 backwards compatibility - if ($exception instanceof Error) { - $exception = new PHP7ErrorException($exception); - } - - if (is_string($this->exceptionRenderer)) { - $class = App::className($this->exceptionRenderer, 'Error'); - if (!$class) { - throw new Exception(sprintf( - "The '%s' renderer class could not be found.", - $this->exceptionRenderer - )); - } - - return new $class($exception); - } - $factory = $this->exceptionRenderer; - - return $factory($exception); + return new Response([ + 'body' => 'An Internal Server Error Occurred', + 'status' => 500, + ]); } /** - * Log an error for the exception if applicable. + * Get a exception trap instance * - * @param \Psr\Http\Message\ServerRequestInterface $request The current request. - * @param \Exception $exception The exception to log a message for. - * @return void + * @return \Cake\Error\ExceptionTrap The exception trap. */ - protected function logException($request, $exception) + protected function getExceptionTrap(): ExceptionTrap { - if (!$this->getConfig('log')) { - return; + if ($this->exceptionTrap === null) { + /** @var class-string<\Cake\Error\ExceptionTrap> $className */ + $className = App::className('ExceptionTrap', 'Error'); + $this->exceptionTrap = new $className($this->getConfig()); } - foreach ((array)$this->getConfig('skipLog') as $class) { - if ($exception instanceof $class) { - return; - } - } - - Log::error($this->getMessage($request, $exception)); + return $this->exceptionTrap; } /** - * Generate the error log message. + * Ensure that the application's routes are loaded. * - * @param \Psr\Http\Message\ServerRequestInterface $request The current request. - * @param \Exception $exception The exception to log a message for. - * @return string Error message + * @return void */ - protected function getMessage($request, $exception) + protected function loadRoutes(): void { - $message = sprintf( - '[%s] %s', - get_class($exception), - $exception->getMessage() - ); - $debug = Configure::read('debug'); + if ( + !($this->app instanceof RoutingApplicationInterface) + || Router::routes() + ) { + return; + } + + try { + $builder = Router::createRouteBuilder('/'); - if ($debug && $exception instanceof CakeException) { - $attributes = $exception->getAttributes(); - if ($attributes) { - $message .= "\nException Attributes: " . var_export($exception->getAttributes(), true); + $this->app->routes($builder); + if ($this->app instanceof PluginApplicationInterface) { + $this->app->pluginRoutes($builder); } + } catch (Throwable $e) { + triggerWarning(sprintf( + "Exception loading routes when rendering an error page: \n %s - %s", + $e::class, + $e->getMessage(), + )); } - $message .= "\nRequest URL: " . $request->getRequestTarget(); - $referer = $request->getHeaderLine('Referer'); - if ($referer) { - $message .= "\nReferer URL: " . $referer; - } - if ($this->getConfig('trace')) { - $message .= "\nStack Trace:\n" . $exception->getTraceAsString() . "\n\n"; - } - - return $message; } } diff --git a/src/Error/PHP7ErrorException.php b/src/Error/PHP7ErrorException.php deleted file mode 100644 index 165b32bc639..00000000000 --- a/src/Error/PHP7ErrorException.php +++ /dev/null @@ -1,63 +0,0 @@ -_error = $error; - $this->message = $error->getMessage(); - $this->code = $error->getCode(); - $this->file = $error->getFile(); - $this->line = $error->getLine(); - $msg = sprintf( - '(%s) - %s in %s on %s', - get_class($error), - $this->message, - $this->file ?: 'null', - $this->line ?: 'null' - ); - parent::__construct($msg, $this->code, $error->getPrevious()); - } - - /** - * Returns the wrapped error object - * - * @return \Error - */ - public function getError() - { - return $this->_error; - } -} diff --git a/src/Error/PhpError.php b/src/Error/PhpError.php new file mode 100644 index 00000000000..ff91fae12d0 --- /dev/null +++ b/src/Error/PhpError.php @@ -0,0 +1,198 @@ +> + */ + private array $trace; + + /** + * @var array + */ + private array $levelMap = [ + E_PARSE => 'error', + E_ERROR => 'error', + E_CORE_ERROR => 'error', + E_COMPILE_ERROR => 'error', + E_USER_ERROR => 'error', + E_WARNING => 'warning', + E_USER_WARNING => 'warning', + E_COMPILE_WARNING => 'warning', + E_RECOVERABLE_ERROR => 'warning', + E_NOTICE => 'notice', + E_USER_NOTICE => 'notice', + E_DEPRECATED => 'deprecated', + E_USER_DEPRECATED => 'deprecated', + ]; + + /** + * @var array + */ + private array $logMap = [ + 'error' => LOG_ERR, + 'warning' => LOG_WARNING, + 'notice' => LOG_NOTICE, + 'strict' => LOG_NOTICE, + 'deprecated' => LOG_NOTICE, + ]; + + /** + * Constructor + * + * @param int $code The PHP error code constant + * @param string $message The error message. + * @param string|null $file The filename of the error. + * @param int|null $line The line number for the error. + * @param array $trace The backtrace for the error. + */ + public function __construct( + int $code, + string $message, + ?string $file = null, + ?int $line = null, + array $trace = [], + ) { + if (version_compare(PHP_VERSION, '8.4.0-dev', '<')) { + $this->levelMap[E_STRICT] = 'strict'; + } + + $this->code = $code; + $this->message = $message; + $this->file = $file; + $this->line = $line; + $this->trace = $trace; + } + + /** + * Get the PHP error constant. + * + * @return int + */ + public function getCode(): int + { + return $this->code; + } + + /** + * Get the mapped LOG_ constant. + * + * @return int + */ + public function getLogLevel(): int + { + $label = $this->getLabel(); + + return $this->logMap[$label] ?? LOG_ERR; + } + + /** + * Get the error code label + * + * @return string + */ + public function getLabel(): string + { + return $this->levelMap[$this->code] ?? 'error'; + } + + /** + * Get the error message. + * + * @return string + */ + public function getMessage(): string + { + return $this->message; + } + + /** + * Get the error file + * + * @return string|null + */ + public function getFile(): ?string + { + return $this->file; + } + + /** + * Get the error line number. + * + * @return int|null + */ + public function getLine(): ?int + { + return $this->line; + } + + /** + * Get the stacktrace as an array. + * + * @return array + */ + public function getTrace(): array + { + return $this->trace; + } + + /** + * Get the stacktrace as a string. + * + * @return string + */ + public function getTraceAsString(): string + { + $out = []; + foreach ($this->trace as $frame) { + if (!empty($frame['line'])) { + $out[] = "{$frame['reference']} {$frame['file']}, line {$frame['line']}"; + } else { + $out[] = $frame['reference']; + } + } + + return implode("\n", $out); + } +} diff --git a/src/Error/Renderer/ConsoleErrorRenderer.php b/src/Error/Renderer/ConsoleErrorRenderer.php new file mode 100644 index 00000000000..44f97794310 --- /dev/null +++ b/src/Error/Renderer/ConsoleErrorRenderer.php @@ -0,0 +1,84 @@ +output = $config['stderr'] ?? new ConsoleOutput('php://stderr'); + $this->trace = (bool)($config['trace'] ?? false); + } + + /** + * @inheritDoc + */ + public function write(string $out): void + { + $this->output->write($out); + } + + /** + * @inheritDoc + */ + public function render(PhpError $error, bool $debug): string + { + $trace = ''; + if ($this->trace) { + $trace = "\nStack Trace:\n\n" . $error->getTraceAsString(); + } + + return sprintf( + '%s: %s :: %s on line %s of %s%s', + $error->getLabel(), + $error->getCode(), + $error->getMessage(), + $error->getLine() ?? '', + $error->getFile() ?? '', + $trace, + ); + } +} diff --git a/src/Error/Renderer/ConsoleExceptionRenderer.php b/src/Error/Renderer/ConsoleExceptionRenderer.php new file mode 100644 index 00000000000..1119888d2df --- /dev/null +++ b/src/Error/Renderer/ConsoleExceptionRenderer.php @@ -0,0 +1,141 @@ +error = $error; + $this->output = $config['stderr'] ?? new ConsoleOutput('php://stderr'); + $this->trace = $config['trace'] ?? true; + } + + /** + * Render an exception into a plain text message. + * + * @return \Psr\Http\Message\ResponseInterface|string + */ + public function render(): ResponseInterface|string + { + $exceptions = [$this->error]; + $previous = $this->error->getPrevious(); + while ($previous !== null) { + $exceptions[] = $previous; + $previous = $previous->getPrevious(); + } + $out = []; + foreach ($exceptions as $i => $error) { + $parent = $i > 0 ? $exceptions[$i - 1] : null; + $out = array_merge($out, $this->renderException($error, $parent)); + } + + return implode("\n", $out); + } + + /** + * Render an individual exception + * + * @param \Throwable $exception The exception to render. + * @param \Throwable|null $parent The Exception index in the chain + * @return array + */ + protected function renderException(Throwable $exception, ?Throwable $parent): array + { + $out = [ + sprintf( + '%s[%s] %s in %s on line %s', + $parent ? 'Caused by ' : '', + $exception::class, + $exception->getMessage(), + $exception->getFile(), + $exception->getLine(), + ), + ]; + + $debug = Configure::read('debug'); + if ($debug && $exception instanceof CakeException) { + $attributes = $exception->getAttributes(); + if ($attributes) { + $out[] = ''; + $out[] = 'Exception Attributes'; + $out[] = ''; + $out[] = var_export($exception->getAttributes(), true); + } + } + + if ($this->trace) { + $stacktrace = Debugger::getUniqueFrames($exception, $parent); + $out[] = ''; + $out[] = 'Stack Trace:'; + $out[] = ''; + $out[] = Debugger::formatTrace($stacktrace, ['format' => 'text']); + $out[] = ''; + } + + return $out; + } + + /** + * Write output to the output stream + * + * @param \Psr\Http\Message\ResponseInterface|string $output The output to print. + * @return void + */ + public function write(ResponseInterface|string $output): void + { + if (is_string($output)) { + $this->output->write($output); + } + } +} diff --git a/src/Error/Renderer/HtmlErrorRenderer.php b/src/Error/Renderer/HtmlErrorRenderer.php new file mode 100644 index 00000000000..8e52b18aef4 --- /dev/null +++ b/src/Error/Renderer/HtmlErrorRenderer.php @@ -0,0 +1,105 @@ +getFile(); + + // Some of the error data is not HTML safe so we escape everything. + $description = h($error->getMessage()); + $path = h($file); + $trace = h($error->getTraceAsString()); + $line = $error->getLine(); + + $errorMessage = sprintf( + '%s (%s)', + h(ucfirst($error->getLabel())), + h($error->getCode()), + ); + $toggle = $this->renderToggle($errorMessage, $id, 'trace'); + $codeToggle = $this->renderToggle('Code', $id, 'code'); + + $excerpt = []; + if ($file && $line) { + $excerpt = Debugger::excerpt($file, $line, 1); + } + $code = implode("\n", $excerpt); + + return << + {$toggle}: {$description} [in {$path}, line {$line}] + + +HTML; + } + + /** + * Render a toggle link in the error content. + * + * @param string $text The text to insert. Assumed to be HTML safe. + * @param string $id The error id scope. + * @param string $suffix The element selector. + * @return string + */ + private function renderToggle(string $text, string $id, string $suffix): string + { + $selector = $id . '-' . $suffix; + + // phpcs:disable + return << + {$text} + +HTML; + // phpcs:enable + } +} diff --git a/src/Error/Renderer/TextErrorRenderer.php b/src/Error/Renderer/TextErrorRenderer.php new file mode 100644 index 00000000000..ed98b15f0c8 --- /dev/null +++ b/src/Error/Renderer/TextErrorRenderer.php @@ -0,0 +1,56 @@ +getLabel(), + $error->getCode(), + $error->getMessage(), + $error->getLine() ?? '', + $error->getFile() ?? '', + $error->getTraceAsString(), + ); + } +} diff --git a/src/Error/Renderer/TextExceptionRenderer.php b/src/Error/Renderer/TextExceptionRenderer.php new file mode 100644 index 00000000000..ee916de319f --- /dev/null +++ b/src/Error/Renderer/TextExceptionRenderer.php @@ -0,0 +1,73 @@ +error = $error; + } + + /** + * Render an exception into a plain text message. + * + * @return \Psr\Http\Message\ResponseInterface|string + */ + public function render(): ResponseInterface|string + { + return sprintf( + "%s : %s on line %s of %s\nTrace:\n%s", + $this->error->getCode(), + $this->error->getMessage(), + $this->error->getLine(), + $this->error->getFile(), + $this->error->getTraceAsString(), + ); + } + + /** + * Write output to stdout. + * + * @param \Psr\Http\Message\ResponseInterface|string $output The output to print. + * @return void + */ + public function write(ResponseInterface|string $output): void + { + assert(is_string($output)); + echo $output; + } +} diff --git a/src/Error/Renderer/WebExceptionRenderer.php b/src/Error/Renderer/WebExceptionRenderer.php new file mode 100644 index 00000000000..0c10e1c3fd9 --- /dev/null +++ b/src/Error/Renderer/WebExceptionRenderer.php @@ -0,0 +1,541 @@ +, int> + * @deprecated 5.2.0 Exceptions returning HTTP error codes should extend + * HttpErrorCodeInterface instead of using this array. + */ + protected array $exceptionHttpCodes = []; + + /** + * Creates the controller to perform rendering on the error response. + * + * @param \Throwable $exception Exception. + * @param \Cake\Http\ServerRequest|null $request The request if this is set it will be used + * instead of creating a new one. + */ + public function __construct(Throwable $exception, ?ServerRequest $request = null) + { + $this->error = $exception; + $this->request = $request; + $this->controller = $this->_getController(); + } + + /** + * Get the controller instance to handle the exception. + * Override this method in subclasses to customize the controller used. + * This method returns the built in `ErrorController` normally, or if an error is repeated + * a bare controller will be used. + * + * @return \Cake\Controller\Controller + * @triggers Controller.startup $controller + */ + protected function _getController(): Controller + { + $request = $this->request; + $routerRequest = Router::getRequest(); + // Fallback to the request in the router or make a new one from + // $_SERVER + $request ??= $routerRequest ?: ServerRequestFactory::fromGlobals(); + + // If the current request doesn't have routing data, but we + // found a request in the router context copy the params over + if ($request->getParam('controller') === null && $routerRequest !== null) { + $request = $request->withAttribute('params', $routerRequest->getAttribute('params')); + } + + $class = ''; + try { + /** @var array $params */ + $params = $request->getAttribute('params'); + $params['controller'] = 'Error'; + + $factory = new ControllerFactory(new Container()); + // Check including plugin + prefix + $class = $factory->getControllerClass($request->withAttribute('params', $params)); + + if (!$class && !empty($params['prefix']) && !empty($params['plugin'])) { + unset($params['prefix']); + // Fallback to only plugin + $class = $factory->getControllerClass($request->withAttribute('params', $params)); + } + + if (!$class) { + // Fallback to app/core provided controller. + /** @var string $class */ + $class = App::className('Error', 'Controller', 'Controller'); + } + + assert(is_subclass_of($class, Controller::class)); + $controller = new $class($request); + $controller->startupProcess(); + } catch (Throwable $e) { + Log::warning( + "Failed to construct or call startup() on the resolved controller class of `{$class}`. " . + "Using Fallback Controller instead. Error {$e->getMessage()}" . + "\nStack Trace\n: {$e->getTraceAsString()}", + 'cake.error', + ); + $controller = null; + } + + if ($controller === null) { + return new Controller($request); + } + + return $controller; + } + + /** + * Clear output buffers so error pages display properly. + * + * @return void + */ + protected function clearOutput(): void + { + if (in_array(PHP_SAPI, ['cli', 'phpdbg'], true)) { + return; + } + while (ob_get_level()) { + ob_end_clean(); + } + } + + /** + * Renders the response for the exception. + * + * @return \Psr\Http\Message\ResponseInterface The response to be sent. + */ + public function render(): ResponseInterface + { + $exception = $this->error; + $code = $this->getHttpCode($exception); + $method = $this->_method($exception); + $template = $this->_template($exception, $method, $code); + $this->clearOutput(); + + if (method_exists($this, $method)) { + return $this->_customMethod($method, $exception); + } + + $message = $this->_message($exception, $code); + $url = $this->controller->getRequest()->getRequestTarget(); + $response = $this->controller->getResponse(); + + if ($exception instanceof HttpException) { + foreach ($exception->getHeaders() as $name => $value) { + $response = $response->withHeader($name, $value); + } + } + $response = $response->withStatus($code); + + $exceptions = [$exception]; + $previous = $exception->getPrevious(); + while ($previous !== null) { + $exceptions[] = $previous; + $previous = $previous->getPrevious(); + } + + $viewVars = [ + 'message' => $message, + 'url' => h($url), + 'error' => $exception, + 'exceptions' => $exceptions, + 'code' => $code, + ]; + $serialize = ['message', 'url', 'code']; + + $isDebug = Configure::read('debug'); + if ($isDebug) { + $trace = (array)Debugger::formatTrace($exception->getTrace(), [ + 'format' => 'array', + 'args' => true, + ]); + $origin = [ + 'file' => $exception->getFile() ?: 'null', + 'line' => $exception->getLine() ?: 'null', + ]; + // Traces don't include the origin file/line. + array_unshift($trace, $origin); + $viewVars['trace'] = $trace; + $viewVars += $origin; + $serialize[] = 'file'; + $serialize[] = 'line'; + } + $this->controller->set($viewVars); + $this->controller->viewBuilder()->setOption('serialize', $serialize); + + if ($exception instanceof CakeException && $isDebug) { + $this->controller->set($exception->getAttributes()); + } + $this->controller->setResponse($response); + + return $this->_outputMessage($template); + } + + /** + * Emit the response content + * + * @param \Psr\Http\Message\ResponseInterface|string $output The response to output. + * @return void + */ + public function write(ResponseInterface|string $output): void + { + if (is_string($output)) { + echo $output; + + return; + } + + $emitter = new ResponseEmitter(); + $emitter->emit($output); + } + + /** + * Render a custom error method/template. + * + * @param string $method The method name to invoke. + * @param \Throwable $exception The exception to render. + * @return \Cake\Http\Response The response to send. + */ + protected function _customMethod(string $method, Throwable $exception): Response + { + $result = $this->{$method}($exception); + $this->_shutdown(); + if (is_string($result)) { + return $this->controller->getResponse()->withStringBody($result); + } + + return $result; + } + + /** + * Get method name + * + * @param \Throwable $exception Exception instance. + * @return string + */ + protected function _method(Throwable $exception): string + { + [, $baseClass] = namespaceSplit($exception::class); + + if (str_ends_with($baseClass, 'Exception')) { + $baseClass = substr($baseClass, 0, -9); + } + + // $baseClass would be an empty string if the exception class is \Exception. + $method = $baseClass === '' ? 'error500' : Inflector::variable($baseClass); + + return $this->method = $method; + } + + /** + * Get error message. + * + * @param \Throwable $exception Exception. + * @param int $code Error code. + * @return string Error message + */ + protected function _message(Throwable $exception, int $code): string + { + $message = $exception->getMessage(); + + if ( + !Configure::read('debug') && + !($exception instanceof HttpException) + ) { + if ($code < 500) { + $message = __d('cake', 'Not Found'); + } else { + $message = __d('cake', 'An Internal Error Has Occurred.'); + } + } + + return $message; + } + + /** + * Get template for rendering exception info. + * + * @param \Throwable $exception Exception instance. + * @param string $method Method name. + * @param int $code Error code. + * @return string Template name + */ + protected function _template(Throwable $exception, string $method, int $code): string + { + if ($exception instanceof HttpException || !Configure::read('debug')) { + return $this->template = $code < 500 ? 'error400' : 'error500'; + } + + if ($exception instanceof PDOException) { + return $this->template = 'pdo_error'; + } + + return $this->template = $method; + } + + /** + * Gets the appropriate http status code for exception. + * + * @param \Throwable $exception Exception. + * @return int A valid HTTP status code. + */ + protected function getHttpCode(Throwable $exception): int + { + if ($exception instanceof HttpErrorCodeInterface) { + return $exception->getCode(); + } + + if (isset($this->exceptionHttpCodes[$exception::class])) { + deprecationWarning( + '5.2.0', + 'Exceptions returning a HTTP error code should implement HttpErrorCodeInterface,' + . ' instead of using the WebExceptionRenderer::$exceptionHttpCodes property.', + ); + + return $this->exceptionHttpCodes[$exception::class]; + } + + return 500; + } + + /** + * Generate the response using the controller object. + * + * @param string $template The template to render. + * @param bool $skipControllerCheck Skip checking controller for existence of + * method matching the exception name. + * @return \Cake\Http\Response A response object that can be sent. + */ + protected function _outputMessage(string $template, bool $skipControllerCheck = false): Response + { + try { + $method = $this->method ?: $this->_method($this->error); + + if (!$skipControllerCheck && method_exists($this->controller, $method)) { + $this->controller->viewBuilder()->setTemplate($method); + + $reflectionMethod = new ReflectionMethod($this->controller, $method); + $result = $reflectionMethod->invoke($this->controller, $this->error); + + if ($result instanceof Response) { + $this->controller->setResponse($result); + } else { + $this->controller->render(); + } + } else { + $this->controller->render($template); + } + + return $this->_shutdown(); + } catch (MissingTemplateException $e) { + Log::warning( + "MissingTemplateException - Failed to render error template `{$template}` . Error: {$e->getMessage()}" . + "\nStack Trace\n: {$e->getTraceAsString()}", + 'cake.error', + ); + $attributes = $e->getAttributes(); + if ( + $e instanceof MissingLayoutException || + str_contains($attributes['file'], 'error500') + ) { + return $this->_outputMessageSafe('error500'); + } + + // If we have a prefix/plugin and the template is error400 or error500, + // try to render from the base Error directory before falling back to error500 + if ( + ($template === 'error400' || $template === 'error500') && + ($this->controller->getRequest()->getParam('prefix') || $this->controller->getPlugin()) + ) { + return $this->_outputMessageSafe($template); + } + + return $this->_outputMessage('error500', true); + } catch (MissingPluginException $e) { + Log::warning( + "MissingPluginException - Failed to render error template `{$template}`. Error: {$e->getMessage()}" . + "\nStack Trace\n: {$e->getTraceAsString()}", + 'cake.error', + ); + $attributes = $e->getAttributes(); + if (isset($attributes['plugin']) && $attributes['plugin'] === $this->controller->getPlugin()) { + $this->controller->setPlugin(null); + } + + return $this->_outputMessageSafe('error500'); + } catch (Throwable $outer) { + Log::warning( + "Throwable - Failed to render error template `{$template}`. Error: {$outer->getMessage()}" . + "\nStack Trace\n: {$outer->getTraceAsString()}", + 'cake.error', + ); + try { + return $this->_outputMessageSafe('error500'); + } catch (Throwable) { + throw $outer; + } + } + } + + /** + * A safer way to render error messages, replaces all helpers, with basics + * and doesn't call component methods. + * + * @param string $template The template to render. + * @return \Cake\Http\Response A response object that can be sent. + */ + protected function _outputMessageSafe(string $template): Response + { + $builder = $this->controller->viewBuilder(); + $builder + ->setHelpers([]) + ->setLayoutPath('') + ->setTemplatePath('Error'); + $view = $this->controller->createView('View'); + + $response = $this->controller->getResponse() + ->withType('html') + ->withStringBody($view->render($template, 'error')); + $this->controller->setResponse($response); + + return $response; + } + + /** + * Run the shutdown events. + * + * Triggers the afterFilter and afterDispatch events. + * + * @return \Cake\Http\Response The response to serve. + */ + protected function _shutdown(): Response + { + $this->controller->dispatchEvent('Controller.shutdown'); + + return $this->controller->getResponse(); + } + + /** + * Returns an array that can be used to describe the internal state of this + * object. + * + * @return array + */ + public function __debugInfo(): array + { + return [ + 'error' => $this->error, + 'request' => $this->request, + 'controller' => $this->controller, + 'template' => $this->template, + 'method' => $this->method, + ]; + } +} diff --git a/src/Error/functions.php b/src/Error/functions.php new file mode 100644 index 00000000000..56bb966ecde --- /dev/null +++ b/src/Error/functions.php @@ -0,0 +1,116 @@ + 0, 'depth' => 1, 'format' => 'array']); + if (isset($trace[0]['line']) && isset($trace[0]['file'])) { + $location = [ + 'line' => $trace[0]['line'], + 'file' => $trace[0]['file'], + ]; + } + } + + Debugger::printVar($var, $location, $showHtml); + + return $var; +} + +/** + * Outputs a stack trace based on the supplied options. + * + * ### Options + * + * - `depth` - The number of stack frames to return. Defaults to 999 + * - `args` - Should arguments for functions be shown? If true, the arguments for each method call + * will be displayed. + * - `start` - The stack frame to start generating a trace from. Defaults to 1 + * + * @param array $options Format for outputting stack trace + * @return void + */ +function stackTrace(array $options = []): void +{ + if (!Configure::read('debug')) { + return; + } + + $options += ['start' => 0]; + $options['start']++; + + /** @var string $trace */ + $trace = Debugger::trace($options); + echo $trace; +} + +/** + * Prints out debug information about given variable and dies. + * + * Only runs if debug mode is enabled. + * It will otherwise just continue code execution and ignore this function. + * + * @param mixed $var Variable to show debug information for. + * @param bool|null $showHtml If set to true, the method prints the debug data in a browser-friendly way. + * @return void + * @link https://book.cakephp.org/5/en/development/debugging.html#basic-debugging + */ +function dd(mixed $var, ?bool $showHtml = null): void +{ + if (!Configure::read('debug')) { + return; + } + + $trace = Debugger::trace(['start' => 0, 'depth' => 2, 'format' => 'array']); + $location = [ + 'line' => $trace[0]['line'], + 'file' => $trace[0]['file'], + ]; + + Debugger::printVar($var, $location, $showHtml); + die(1); +} + +/** + * Include global functions. + */ +if (!getenv('CAKE_DISABLE_GLOBAL_FUNCS')) { + include 'functions_global.php'; +} diff --git a/src/Error/functions_global.php b/src/Error/functions_global.php new file mode 100644 index 00000000000..9cb473074f6 --- /dev/null +++ b/src/Error/functions_global.php @@ -0,0 +1,141 @@ + 0, 'depth' => 1, 'format' => 'array']); + if (isset($trace[0]['line']) && isset($trace[0]['file'])) { + $location = [ + 'line' => $trace[0]['line'], + 'file' => $trace[0]['file'], + ]; + } + } + + Debugger::printVar($var, $location, $showHtml); + + return $var; + } +} + +if (!function_exists('stackTrace')) { + /** + * Outputs a stack trace based on the supplied options. + * + * ### Options + * + * - `depth` - The number of stack frames to return. Defaults to 999 + * - `args` - Should arguments for functions be shown? If true, the arguments for each method call + * will be displayed. + * - `start` - The stack frame to start generating a trace from. Defaults to 1 + * + * @param array{depth?: int, args?: bool, start?: int} $options Format for outputting stack trace + * @return void + */ + function stackTrace(array $options = []): void + { + if (!Configure::read('debug')) { + return; + } + + $options += ['start' => 0]; + $options['start']++; + + /** @var string $trace */ + $trace = Debugger::trace($options); + echo $trace; + } +} + +if (!function_exists('dd')) { + /** + * Prints out debug information about given variable and dies. + * + * Only runs if debug mode is enabled. + * It will otherwise just continue code execution and ignore this function. + * + * @param mixed $var Variable to show debug information for. + * @param bool|null $showHtml If set to true, the method prints the debug data in a browser-friendly way. + * @return void + * @link https://book.cakephp.org/5/en/development/debugging.html#basic-debugging + */ + function dd(mixed $var, ?bool $showHtml = null): void + { + if (!Configure::read('debug')) { + return; + } + + $trace = Debugger::trace(['start' => 0, 'depth' => 2, 'format' => 'array']); + $location = [ + 'line' => $trace[0]['line'], + 'file' => $trace[0]['file'], + ]; + + Debugger::printVar($var, $location, $showHtml); + die(1); + } +} + +if (!function_exists('breakpoint')) { + /** + * Command to return the eval-able code to startup PsySH in interactive debugger + * Works the same way as eval(\Psy\sh()); + * psy/psysh must be loaded in your project + * + * ``` + * eval(breakpoint()); + * ``` + * + * @return string|null + * @link https://psysh.org/ + */ + function breakpoint(): ?string + { + // phpcs:ignore SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly + if ((PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') && class_exists(\Psy\Shell::class)) { + return 'extract(\Psy\Shell::debug(get_defined_vars(), isset($this) ? $this : null));'; + } + trigger_error( + 'psy/psysh must be installed and you must be in a CLI environment to use the breakpoint function', + E_USER_WARNING, + ); + + return null; + } +} diff --git a/src/Event/Decorator/AbstractDecorator.php b/src/Event/Decorator/AbstractDecorator.php index e0cb24108e7..f34d7a9731e 100644 --- a/src/Event/Decorator/AbstractDecorator.php +++ b/src/Event/Decorator/AbstractDecorator.php @@ -1,4 +1,6 @@ $options Decorator options. */ public function __construct(callable $callable, array $options = []) { @@ -50,11 +51,12 @@ public function __construct(callable $callable, array $options = []) * Invoke * * @link https://secure.php.net/manual/en/language.oop5.magic.php#object.invoke + * @param mixed ...$args Arguments for the callable. * @return mixed */ - public function __invoke() + public function __invoke(mixed ...$args): mixed { - return $this->_call(func_get_args()); + return $this->_call($args); } /** @@ -63,7 +65,7 @@ public function __invoke() * @param array $args Arguments for the callable. * @return mixed */ - protected function _call($args) + protected function _call(array $args): mixed { $callable = $this->_callable; diff --git a/src/Event/Decorator/ConditionDecorator.php b/src/Event/Decorator/ConditionDecorator.php index 91274a3eaf0..297ca18099f 100644 --- a/src/Event/Decorator/ConditionDecorator.php +++ b/src/Event/Decorator/ConditionDecorator.php @@ -1,4 +1,6 @@ canTrigger($args[0])) { - return; + return null; } return $this->_call($args); @@ -42,10 +42,11 @@ public function __invoke() /** * Checks if the event is triggered for this listener. * - * @param \Cake\Event\Event $event Event object. + * @template TSubject of object + * @param \Cake\Event\EventInterface $event Event object. * @return bool */ - public function canTrigger(Event $event) + public function canTrigger(EventInterface $event): bool { $if = $this->_evaluateCondition('if', $event); $unless = $this->_evaluateCondition('unless', $event); @@ -56,23 +57,20 @@ public function canTrigger(Event $event) /** * Evaluates the filter conditions * + * @template TSubject of object * @param string $condition Condition type - * @param \Cake\Event\Event $event Event object + * @param \Cake\Event\EventInterface $event Event object * @return bool */ - protected function _evaluateCondition($condition, Event $event) + protected function _evaluateCondition(string $condition, EventInterface $event): bool { if (!isset($this->_options[$condition])) { - if ($condition === 'unless') { - return false; - } - - return true; + return $condition !== 'unless'; } if (!is_callable($this->_options[$condition])) { - throw new RuntimeException(self::class . ' the `' . $condition . '` condition is not a callable!'); + throw new InvalidArgumentException(self::class . ' the `' . $condition . '` condition is not a callable!'); } - return $this->_options[$condition]($event); + return (bool)$this->_options[$condition]($event); } } diff --git a/src/Event/Decorator/SubjectFilterDecorator.php b/src/Event/Decorator/SubjectFilterDecorator.php index eb32f826c9b..f380d9469eb 100644 --- a/src/Event/Decorator/SubjectFilterDecorator.php +++ b/src/Event/Decorator/SubjectFilterDecorator.php @@ -1,4 +1,6 @@ canTrigger($args[0])) { - return false; + return null; } return $this->_call($args); @@ -45,19 +45,25 @@ public function __invoke() /** * Checks if the event is triggered for this listener. * - * @param \Cake\Event\Event $event Event object. + * @template TSubject of object + * @param \Cake\Event\EventInterface $event Event object. * @return bool */ - public function canTrigger(Event $event) + public function canTrigger(EventInterface $event): bool { - $class = get_class($event->getSubject()); if (!isset($this->_options['allowedSubject'])) { - throw new RuntimeException(self::class . ' Missing subject filter options!'); + throw new CakeException(self::class . ' Missing subject filter options!'); } if (is_string($this->_options['allowedSubject'])) { $this->_options['allowedSubject'] = [$this->_options['allowedSubject']]; } - return in_array($class, $this->_options['allowedSubject']); + try { + $subject = $event->getSubject(); + } catch (CakeException) { + return false; + } + + return in_array($subject::class, $this->_options['allowedSubject'], true); } } diff --git a/src/Event/Event.php b/src/Event/Event.php index dd8977e4dfe..b7ed901e419 100644 --- a/src/Event/Event.php +++ b/src/Event/Event.php @@ -1,4 +1,6 @@ */ -class Event +class Event implements EventInterface { - /** * Name of the event * * @var string */ - protected $_name; + protected string $_name; /** * The object this event applies to (usually the same object that generates the event) * - * @var object + * @var TSubject|null */ - protected $_subject; + protected ?object $_subject = null; /** * Custom data for the method that receives the event * * @var array */ - protected $_data; + protected array $_data; /** * Property used to retain the result value of the event listeners * + * Use setResult() and getResult() to set and get the result. + * * @var mixed */ - public $result; + protected mixed $result = null; /** * Flags an event as stopped or not, default is false * * @var bool */ - protected $_stopped = false; + protected bool $_stopped = false; /** * Constructor @@ -69,67 +70,20 @@ class Event * * ``` * $event = new Event('Order.afterBuy', $this, ['buyer' => $userData]); - * $event = new Event('User.afterRegister', $UserModel); + * $event = new Event('User.afterRegister', $userModel); * ``` * * @param string $name Name of the event - * @param object|null $subject the object that this event applies to (usually the object that is generating the event) - * @param array|\ArrayAccess|null $data any value you wish to be transported with this event to it can be read by listeners + * @param TSubject|null $subject the object that this event applies to + * (usually the object that is generating the event). + * @param array $data any value you wish to be transported + * with this event to it can be read by listeners. */ - public function __construct($name, $subject = null, $data = null) + public function __construct(string $name, ?object $subject = null, array $data = []) { $this->_name = $name; - $this->_data = (array)$data; $this->_subject = $subject; - } - - /** - * Provides read-only access for the name and subject properties. - * - * @param string $attribute Attribute name. - * @return mixed - * @deprecated 3.4.0 Public properties will be removed. - */ - public function __get($attribute) - { - if ($attribute === 'name' || $attribute === 'subject') { - return $this->{$attribute}(); - } - if ($attribute === 'data') { - return $this->_data; - } - if ($attribute === 'result') { - return $this->result; - } - } - - /** - * Provides backward compatibility for write access to data and result properties. - * - * @param string $attribute Attribute name. - * @param mixed $value The value to set. - * @return void - * @deprecated 3.4.0 Public properties will be removed. - */ - public function __set($attribute, $value) - { - if ($attribute === 'data') { - $this->_data = (array)$value; - } - if ($attribute === 'result') { - $this->result = $value; - } - } - - /** - * Returns the name of this event. This is usually used as the event identifier - * - * @return string - * @deprecated 3.4.0 use getName() instead. - */ - public function name() - { - return $this->_name; + $this->_data = $data; } /** @@ -137,7 +91,7 @@ public function name() * * @return string */ - public function getName() + public function getName(): string { return $this->_name; } @@ -145,21 +99,17 @@ public function getName() /** * Returns the subject of this event * - * @return object - * @deprecated 3.4.0 use getSubject() instead. - */ - public function subject() - { - return $this->_subject; - } - - /** - * Returns the subject of this event + * If the event has no subject an exception will be raised. * - * @return object + * @return TSubject + * @throws \Cake\Core\Exception\CakeException */ - public function getSubject() + public function getSubject(): object { + if ($this->_subject === null) { + throw new CakeException('No subject set for this event'); + } + return $this->_subject; } @@ -168,7 +118,7 @@ public function getSubject() * * @return void */ - public function stopPropagation() + public function stopPropagation(): void { $this->_stopped = true; } @@ -178,7 +128,7 @@ public function stopPropagation() * * @return bool True if the event is stopped */ - public function isStopped() + public function isStopped(): bool { return $this->_stopped; } @@ -187,19 +137,8 @@ public function isStopped() * The result value of the event listeners * * @return mixed - * @deprecated 3.4.0 use getResult() instead. */ - public function result() - { - return $this->result; - } - - /** - * The result value of the event listeners - * - * @return mixed - */ - public function getResult() + public function getResult(): mixed { return $this->result; } @@ -207,10 +146,12 @@ public function getResult() /** * Listeners can attach a result value to the event. * + * Setting the result to `false` will also stop event propagation. + * * @param mixed $value The value to set. * @return $this */ - public function setResult($value = null) + public function setResult(mixed $value = null) { $this->result = $value; @@ -218,42 +159,21 @@ public function setResult($value = null) } /** - * Access the event data/payload. - * - * @param string|null $key The data payload element to return, or null to return all data. - * @return array|mixed|null The data payload if $key is null, or the data value for the given $key. If the $key does not - * exist a null value is returned. - * @deprecated 3.4.0 use getData() instead. + * @inheritDoc */ - public function data($key = null) - { - return $this->getData($key); - } - - /** - * Access the event data/payload. - * - * @param string|null $key The data payload element to return, or null to return all data. - * @return array|mixed|null The data payload if $key is null, or the data value for the given $key. If the $key does not - * exist a null value is returned. - */ - public function getData($key = null) + public function getData(?string $key = null): mixed { if ($key !== null) { - return isset($this->_data[$key]) ? $this->_data[$key] : null; + return $this->_data[$key] ?? null; } - return (array)$this->_data; + return $this->_data; } /** - * Assigns a value to the data/payload of this event. - * - * @param array|string $key An array will replace all payload data, and a key will set just that array item. - * @param mixed $value The value to set. - * @return $this + * @inheritDoc */ - public function setData($key, $value = null) + public function setData(array|string $key, $value = null) { if (is_array($key)) { $this->_data = $key; diff --git a/src/Event/EventDispatcherInterface.php b/src/Event/EventDispatcherInterface.php index 9119ad1e1e8..5868c1d0804 100644 --- a/src/Event/EventDispatcherInterface.php +++ b/src/Event/EventDispatcherInterface.php @@ -1,4 +1,6 @@ */ - public function dispatchEvent($name, $data = null, $subject = null); + public function dispatchEvent(string $name, array $data = [], ?object $subject = null): EventInterface; /** - * Returns the Cake\Event\EventManager manager instance for this object. + * Sets the Cake\Event\EventManager manager instance for this object. * * You can use this instance to register any new listeners or callbacks to the * object events, or create your own events and trigger them at will. * - * @deprecated 3.5.0 Use getEventManager()/setEventManager() instead. - * @param \Cake\Event\EventManager|null $eventManager the eventManager to set - * @return \Cake\Event\EventManager + * @param \Cake\Event\EventManagerInterface $eventManager the eventManager to set + * @return $this + */ + public function setEventManager(EventManagerInterface $eventManager); + + /** + * Returns the Cake\Event\EventManager manager instance for this object. + * + * @return \Cake\Event\EventManagerInterface */ - public function eventManager(EventManager $eventManager = null); + public function getEventManager(): EventManagerInterface; } diff --git a/src/Event/EventDispatcherTrait.php b/src/Event/EventDispatcherTrait.php index 08a206e00f8..756e5ad38a7 100644 --- a/src/Event/EventDispatcherTrait.php +++ b/src/Event/EventDispatcherTrait.php @@ -1,4 +1,6 @@ setEventManager($eventManager); - } - - return $this->getEventManager(); - } + protected string $_eventClass = Event::class; /** * Returns the Cake\Event\EventManager manager instance for this object. @@ -60,27 +42,23 @@ public function eventManager(EventManager $eventManager = null) * You can use this instance to register any new listeners or callbacks to the * object events, or create your own events and trigger them at will. * - * @return \Cake\Event\EventManager + * @return \Cake\Event\EventManagerInterface */ - public function getEventManager() + public function getEventManager(): EventManagerInterface { - if ($this->_eventManager === null) { - $this->_eventManager = new EventManager(); - } - - return $this->_eventManager; + return $this->_eventManager ??= new EventManager(); } /** - * Returns the Cake\Event\EventManager manager instance for this object. + * Returns the Cake\Event\EventManagerInterface instance for this object. * * You can use this instance to register any new listeners or callbacks to the * object events, or create your own events and trigger them at will. * - * @param \Cake\Event\EventManager $eventManager the eventManager to set + * @param \Cake\Event\EventManagerInterface $eventManager the eventManager to set * @return $this */ - public function setEventManager(EventManager $eventManager) + public function setEventManager(EventManagerInterface $eventManager) { $this->_eventManager = $eventManager; @@ -92,20 +70,22 @@ public function setEventManager(EventManager $eventManager) * * Returns a dispatched event. * + * @template TSubject of object = $this * @param string $name Name of the event. - * @param array|null $data Any value you wish to be transported with this event to + * @param array $data Any value you wish to be transported with this event to * it can be read by listeners. - * @param object|null $subject The object that this event applies to + * @param TSubject|null $subject The object that this event applies to * ($this by default). - * - * @return \Cake\Event\Event + * @return \Cake\Event\EventInterface */ - public function dispatchEvent($name, $data = null, $subject = null) + public function dispatchEvent(string $name, array $data = [], ?object $subject = null): EventInterface { - if ($subject === null) { - $subject = $this; - } + $subject ??= $this; + /** + * phpcs:ignore SlevomatCodingStandard.Namespaces.FullyQualifiedClassNameInAnnotation.NonFullyQualifiedClassName + * @var \Cake\Event\EventInterface $event Coerce for psalm/phpstan + */ $event = new $this->_eventClass($name, $subject, $data); $this->getEventManager()->dispatch($event); diff --git a/src/Event/EventInterface.php b/src/Event/EventInterface.php new file mode 100644 index 00000000000..81dfd59e9fd --- /dev/null +++ b/src/Event/EventInterface.php @@ -0,0 +1,88 @@ +> + * @implements \IteratorAggregate<\Cake\Event\EventInterface> */ -class EventList implements ArrayAccess, Countable +class EventList implements ArrayAccess, Countable, IteratorAggregate { - /** * Events list * - * @var \Cake\Event\Event[] + * @var array<\Cake\Event\EventInterface> */ - protected $_events = []; + protected array $_events = []; /** * Empties the list of dispatched events. * * @return void */ - public function flush() + public function flush(): void { $this->_events = []; } @@ -43,10 +52,10 @@ public function flush() /** * Adds an event to the list when event listing is enabled. * - * @param \Cake\Event\Event $event An event to the list of dispatched events. + * @param \Cake\Event\EventInterface $event An event to the list of dispatched events. * @return void */ - public function add(Event $event) + public function add(EventInterface $event): void { $this->_events[] = $event; } @@ -54,63 +63,94 @@ public function add(Event $event) /** * Whether a offset exists * + * @deprecated 5.3.0 Array access for `EventList` is deprecated, use `EventList::hasEvent()` instead. * @link https://secure.php.net/manual/en/arrayaccess.offsetexists.php * @param mixed $offset An offset to check for. * @return bool True on success or false on failure. */ - public function offsetExists($offset) + public function offsetExists(mixed $offset): bool { + deprecationWarning( + '5.3.0', + 'Array access for `EventList` is deprecated, use `EventList::hasEvent()` instead.', + ); + return isset($this->_events[$offset]); } /** * Offset to retrieve * + * @deprecated 5.3.0 Array access for `EventList` is deprecated, you can iterate the instance instead. * @link https://secure.php.net/manual/en/arrayaccess.offsetget.php * @param mixed $offset The offset to retrieve. - * @return mixed Can return all value types. + * @return \Cake\Event\EventInterface|null */ - public function offsetGet($offset) + public function offsetGet(mixed $offset): ?EventInterface { - if ($this->offsetExists($offset)) { - return $this->_events[$offset]; - } + deprecationWarning( + '5.3.0', + 'Array access for `EventList` is deprecated, you can iterate the instance instead.', + ); - return null; + return $this->_events[$offset] ?? null; } /** * Offset to set * + * @deprecated 5.3.0 Array access for `EventList` is deprecated, use `EventList::add() instead.`. * @link https://secure.php.net/manual/en/arrayaccess.offsetset.php * @param mixed $offset The offset to assign the value to. * @param mixed $value The value to set. * @return void */ - public function offsetSet($offset, $value) + public function offsetSet(mixed $offset, mixed $value): void { + deprecationWarning( + '5.3.0', + 'Array access for `EventList` is deprecated, use `EventList::add() instead.', + ); + $this->_events[$offset] = $value; } /** * Offset to unset * + * @deprecated 5.3.0 Array access for `EventList` is deprecated. + * Individual events cannot be unset anymore, use `EventList::flush()` to clear the list. * @link https://secure.php.net/manual/en/arrayaccess.offsetunset.php * @param mixed $offset The offset to unset. * @return void */ - public function offsetUnset($offset) + public function offsetUnset(mixed $offset): void { + deprecationWarning( + '5.3.0', + 'Array access for `EventList` is deprecated.' + . ' Individual events cannot be unset anymore, use `EventList::flush()` to clear the list.', + ); unset($this->_events[$offset]); } + /** + * Retrieve an external iterator + * + * @return \Traversable<\Cake\Event\EventInterface> + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->_events); + } + /** * Count elements of an object * * @link https://secure.php.net/manual/en/countable.count.php * @return int The custom count as an integer. */ - public function count() + public function count(): int { return count($this->_events); } @@ -121,7 +161,7 @@ public function count() * @param string $name Event name. * @return bool */ - public function hasEvent($name) + public function hasEvent(string $name): bool { foreach ($this->_events as $event) { if ($event->getName() === $name) { diff --git a/src/Event/EventListenerInterface.php b/src/Event/EventListenerInterface.php index 5f7af679ab6..8439f6f366b 100644 --- a/src/Event/EventListenerInterface.php +++ b/src/Event/EventListenerInterface.php @@ -1,4 +1,6 @@ Associative array or event key names pointing to the function * that should be called in the object when the respective event is fired */ - public function implementedEvents(); + public function implementedEvents(): array; } diff --git a/src/Event/EventListenerRegistrationTrait.php b/src/Event/EventListenerRegistrationTrait.php new file mode 100644 index 00000000000..1c70d9236d2 --- /dev/null +++ b/src/Event/EventListenerRegistrationTrait.php @@ -0,0 +1,59 @@ +> $listeners FQCNs to register. + * @param \Cake\Event\EventManagerInterface $eventManager Manager the listeners are attached to. + * @param \Cake\Core\ContainerInterface $container Container used to resolve each listener. + * @return void + * @throws \InvalidArgumentException When an entry is not a class string implementing + * `EventListenerInterface`. + */ + protected function registerEventListeners( + array $listeners, + EventManagerInterface $eventManager, + ContainerInterface $container, + ): void { + foreach ($listeners as $listener) { + if (!is_a($listener, EventListenerInterface::class, true)) { + throw new InvalidArgumentException(sprintf( + 'Event listener `%s` must be a class name that implements %s', + $listener, + EventListenerInterface::class, + )); + } + + $eventManager->on($container->get($listener)); + } + } +} diff --git a/src/Event/EventManager.php b/src/Event/EventManager.php index b04c955f00c..1c2c652cd02 100644 --- a/src/Event/EventManager.php +++ b/src/Event/EventManager.php @@ -1,4 +1,6 @@ |null */ - protected $_eventList; + protected ?EventList $_eventList = null; /** * Enables automatic adding of events to the event list object if it is present. * * @var bool */ - protected $_trackEvents = false; + protected bool $_trackEvents = false; /** * Returns the globally available instance of a Cake\Event\EventManager @@ -76,119 +81,59 @@ class EventManager * If called with the first parameter, it will be set as the globally available instance * * @param \Cake\Event\EventManager|null $manager Event manager instance. - * @return static The global event manager + * @return \Cake\Event\EventManager The global event manager */ - public static function instance($manager = null) + public static function instance(?EventManager $manager = null): EventManager { + if ($manager === null && static::$_generalManager) { + return static::$_generalManager; + } + if ($manager instanceof EventManager) { static::$_generalManager = $manager; } - if (empty(static::$_generalManager)) { - static::$_generalManager = new static(); - } - + static::$_generalManager ??= new static(); static::$_generalManager->_isGlobal = true; return static::$_generalManager; } /** - * Adds a new listener to an event. - * - * @param callable|\Cake\Event\EventListenerInterface $callable PHP valid callback type or instance of Cake\Event\EventListenerInterface to be called - * when the event named with $eventKey is triggered. If a Cake\Event\EventListenerInterface instance is passed, then the `implementedEvents` - * method will be called on the object to register the declared events individually as methods to be managed by this class. - * It is possible to define multiple event handlers per event name. - * - * @param string|null $eventKey The event unique identifier name with which the callback will be associated. If $callable - * is an instance of Cake\Event\EventListenerInterface this argument will be ignored - * - * @param array $options used to set the `priority` flag to the listener. In the future more options may be added. - * Priorities are treated as queues. Lower values are called before higher ones, and multiple attachments - * added to the same priority queue will be treated in the order of insertion. - * - * @return void - * @throws \InvalidArgumentException When event key is missing or callable is not an - * instance of Cake\Event\EventListenerInterface. - * @deprecated 3.0.0 Use on() instead. - */ - public function attach($callable, $eventKey = null, array $options = []) - { - if ($eventKey === null) { - $this->on($callable); - - return; - } - if ($options) { - $this->on($eventKey, $options, $callable); - - return; - } - $this->on($eventKey, $callable); - } - - /** - * Adds a new listener to an event. - * - * A variadic interface to add listeners that emulates jQuery.on(). - * - * Binding an EventListenerInterface: - * - * ``` - * $eventManager->on($listener); - * ``` - * - * Binding with no options: - * - * ``` - * $eventManager->on('Model.beforeSave', $callable); - * ``` - * - * Binding with options: - * - * ``` - * $eventManager->on('Model.beforeSave', ['priority' => 90], $callable); - * ``` - * - * @param string|\Cake\Event\EventListenerInterface|null $eventKey The event unique identifier name - * with which the callback will be associated. If $eventKey is an instance of - * Cake\Event\EventListenerInterface its events will be bound using the `implementedEvents` methods. - * - * @param array|callable $options Either an array of options or the callable you wish to - * bind to $eventKey. If an array of options, the `priority` key can be used to define the order. - * Priorities are treated as queues. Lower values are called before higher ones, and multiple attachments - * added to the same priority queue will be treated in the order of insertion. - * - * @param callable|null $callable The callable function you want invoked. - * - * @return $this - * @throws \InvalidArgumentException When event key is missing or callable is not an - * instance of Cake\Event\EventListenerInterface. + * @inheritDoc */ - public function on($eventKey = null, $options = [], $callable = null) - { + public function on( + EventListenerInterface|string $eventKey, + callable|array $options = [], + ?callable $callable = null, + ) { if ($eventKey instanceof EventListenerInterface) { $this->_attachSubscriber($eventKey); return $this; } - $argCount = func_num_args(); - if ($argCount === 2) { - $this->_listeners[$eventKey][static::$defaultPriority][] = [ - 'callable' => $options - ]; - return $this; + if ($callable === null && !is_callable($options)) { + throw new InvalidArgumentException( + 'Second argument of `EventManager::on()` must be a callable if `$callable` is null.', + ); } - if ($argCount === 3) { - $priority = isset($options['priority']) ? $options['priority'] : static::$defaultPriority; - $this->_listeners[$eventKey][$priority][] = [ - 'callable' => $callable + + if ($callable === null) { + /** @var callable $options */ + $this->_listeners[$eventKey][static::$defaultPriority][] = [ + 'callable' => $options(...), ]; return $this; } - throw new InvalidArgumentException('Invalid arguments for EventManager::on().'); + + /** @var array $options */ + $priority = $options['priority'] ?? static::$defaultPriority; + $this->_listeners[$eventKey][$priority][] = [ + 'callable' => $callable(...), + ]; + + return $this; } /** @@ -198,127 +143,56 @@ public function on($eventKey = null, $options = [], $callable = null) * @param \Cake\Event\EventListenerInterface $subscriber Event listener. * @return void */ - protected function _attachSubscriber(EventListenerInterface $subscriber) + protected function _attachSubscriber(EventListenerInterface $subscriber): void { - foreach ((array)$subscriber->implementedEvents() as $eventKey => $function) { - $options = []; - $method = $function; - if (is_array($function) && isset($function['callable'])) { - list($method, $options) = $this->_extractCallable($function, $subscriber); - } elseif (is_array($function) && is_numeric(key($function))) { - foreach ($function as $f) { - list($method, $options) = $this->_extractCallable($f, $subscriber); - $this->on($eventKey, $options, $method); - } - continue; + foreach ($subscriber->implementedEvents() as $eventKey => $handlers) { + foreach ($this->normalizeHandlers($subscriber, $handlers) as $handler) { + $this->on($eventKey, $handler['settings'], $handler['callable']); } - if (is_string($method)) { - $method = [$subscriber, $function]; - } - $this->on($eventKey, $options, $method); } } /** - * Auxiliary function to extract and return a PHP callback type out of the callable definition - * from the return value of the `implementedEvents` method on a Cake\Event\EventListenerInterface - * - * @param array $function the array taken from a handler definition for an event - * @param \Cake\Event\EventListenerInterface $object The handler object - * @return callable + * @inheritDoc */ - protected function _extractCallable($function, $object) - { - $method = $function['callable']; - $options = $function; - unset($options['callable']); - if (is_string($method)) { - $method = [$object, $method]; - } - - return [$method, $options]; - } - - /** - * Removes a listener from the active listeners. - * - * @param callable|\Cake\Event\EventListenerInterface $callable any valid PHP callback type or an instance of EventListenerInterface - * @param string|null $eventKey The event unique identifier name with which the callback has been associated - * @return void - * @deprecated 3.0.0 Use off() instead. - */ - public function detach($callable, $eventKey = null) - { - if ($eventKey === null) { - $this->off($callable); + public function off( + EventListenerInterface|callable|string $eventKey, + EventListenerInterface|callable|null $callable = null, + ) { + if ($eventKey instanceof EventListenerInterface) { + $this->_detachSubscriber($eventKey); - return; + return $this; } - $this->off($eventKey, $callable); - } - /** - * Remove a listener from the active listeners. - * - * Remove a EventListenerInterface entirely: - * - * ``` - * $manager->off($listener); - * ``` - * - * Remove all listeners for a given event: - * - * ``` - * $manager->off('My.event'); - * ``` - * - * Remove a specific listener: - * - * ``` - * $manager->off('My.event', $callback); - * ``` - * - * Remove a callback from all events: - * - * ``` - * $manager->off($callback); - * ``` - * - * @param string|\Cake\Event\EventListenerInterface $eventKey The event unique identifier name - * with which the callback has been associated, or the $listener you want to remove. - * @param callable|null $callable The callback you want to detach. - * @return $this - */ - public function off($eventKey, $callable = null) - { - if ($eventKey instanceof EventListenerInterface) { - $this->_detachSubscriber($eventKey); + if (!is_string($eventKey)) { + foreach (array_keys($this->_listeners) as $name) { + $this->off($name, $eventKey); + } return $this; } + if ($callable instanceof EventListenerInterface) { $this->_detachSubscriber($callable, $eventKey); return $this; } - if ($callable === null && is_string($eventKey)) { - unset($this->_listeners[$eventKey]); - return $this; - } if ($callable === null) { - foreach (array_keys($this->_listeners) as $name) { - $this->off($name, $eventKey); - } + unset($this->_listeners[$eventKey]); return $this; } + if (empty($this->_listeners[$eventKey])) { return $this; } + + $callable = $callable(...); foreach ($this->_listeners[$eventKey] as $priority => $callables) { foreach ($callables as $k => $callback) { - if ($callback['callable'] === $callable) { + if ($callback['callable'] == $callable) { unset($this->_listeners[$eventKey][$priority][$k]); break; } @@ -335,38 +209,86 @@ public function off($eventKey, $callable = null) * @param string|null $eventKey optional event key name to unsubscribe the listener from * @return void */ - protected function _detachSubscriber(EventListenerInterface $subscriber, $eventKey = null) + protected function _detachSubscriber(EventListenerInterface $subscriber, ?string $eventKey = null): void { - $events = (array)$subscriber->implementedEvents(); - if (!empty($eventKey) && empty($events[$eventKey])) { + $events = $subscriber->implementedEvents(); + if ($eventKey && empty($events[$eventKey])) { return; } - if (!empty($eventKey)) { + if ($eventKey) { $events = [$eventKey => $events[$eventKey]]; } - foreach ($events as $key => $function) { - if (is_array($function)) { - if (is_numeric(key($function))) { - foreach ($function as $handler) { - $handler = isset($handler['callable']) ? $handler['callable'] : $handler; - $this->off($key, [$subscriber, $handler]); - } - continue; - } - $function = $function['callable']; + foreach ($events as $key => $handlers) { + foreach ($this->normalizeHandlers($subscriber, $handlers) as $handler) { + $this->off($key, $handler['callable']); } - $this->off($key, [$subscriber, $function]); } } /** - * Dispatches a new event to all configured listeners + * Builds an array of normalized handlers. + * + * A normalized handler is an array with these keys: + * + * - `callable` - The event handler closure + * - `settings` - The event handler settings * - * @param string|\Cake\Event\Event $event the event key name or instance of Event - * @return \Cake\Event\Event - * @triggers $event + * @param \Cake\Event\EventListenerInterface $subscriber Event subscriber + * @param callable|array|string $handlers Event handlers + * @return array */ - public function dispatch($event) + protected function normalizeHandlers( + EventListenerInterface $subscriber, + callable|array|string $handlers, + ): array { + // Check if an array of handlers not single handler config array + if (is_array($handlers) && !isset($handlers['callable'])) { + foreach ($handlers as &$handler) { + $handler = $this->normalizeHandler($subscriber, $handler); + } + + return $handlers; + } + + return [$this->normalizeHandler($subscriber, $handlers)]; + } + + /** + * Builds a single normalized handler. + * + * A normalized handler is an array with these keys: + * + * - `callable` - The event handler closure + * - `settings` - The event handler settings + * + * @param \Cake\Event\EventListenerInterface $subscriber Event subscriber + * @param callable|array|string $handler Event handler + * @return array + */ + protected function normalizeHandler( + EventListenerInterface $subscriber, + callable|array|string $handler, + ): array { + $callable = $handler; + $settings = []; + + if (is_array($handler)) { + $callable = $handler['callable']; + $settings = $handler; + unset($settings['callable']); + } + + if (is_string($callable)) { + $callable = $subscriber->$callable(...); + } + + return ['callable' => $callable, 'settings' => $settings]; + } + + /** + * @inheritDoc + */ + public function dispatch(EventInterface|string $event): EventInterface { if (is_string($event)) { $event = new Event($event); @@ -382,7 +304,7 @@ public function dispatch($event) static::instance()->addEventToList($event); } - if (empty($listeners)) { + if (!$listeners) { return $event; } @@ -390,13 +312,8 @@ public function dispatch($event) if ($event->isStopped()) { break; } - $result = $this->_callListener($listener['callable'], $event); - if ($result === false) { - $event->stopPropagation(); - } - if ($result !== null) { - $event->setResult($result); - } + + $this->_callListener($listener['callable'], $event); } return $event; @@ -405,32 +322,54 @@ public function dispatch($event) /** * Calls a listener. * + * @template TSubject of object * @param callable $listener The listener to trigger. - * @param \Cake\Event\Event $event Event instance. - * @return mixed The result of the $listener function. + * @param \Cake\Event\EventInterface $event Event instance. + * @return void */ - protected function _callListener(callable $listener, Event $event) + protected function _callListener(callable $listener, EventInterface $event): void { - $data = $event->getData(); + $result = $listener($event, ...array_values($event->getData())); - return $listener($event, ...array_values($data)); + if ($result !== null) { + try { + $class = get_class($event->getSubject()); + } catch (CakeException) { + $class = 'unknown subject'; + } + + if ($listener instanceof Closure) { + $ref = new ReflectionFunction($listener); + $closureClass = $ref->getClosureScopeClass(); + $closureMethod = $ref->getName(); + if ($closureClass && $closureClass->name && $closureMethod) { + $class = $closureClass->name . '::' . $closureMethod . '()'; + } + } + + deprecationWarning( + '5.2.0', + 'Returning a value from event listeners is deprecated. ' . + 'Use `$event->setResult()` instead in `' . $event->getName() . '` of `' . $class . '`', + ); + $event->setResult($result); + } + + if ($event->getResult() === false) { + $event->stopPropagation(); + } } /** - * Returns a list of all listeners for an eventKey in the order they should be called - * - * @param string $eventKey Event key. - * @return array + * @inheritDoc */ - public function listeners($eventKey) + public function listeners(string $eventKey): array { $localListeners = []; if (!$this->_isGlobal) { $localListeners = $this->prioritisedListeners($eventKey); - $localListeners = empty($localListeners) ? [] : $localListeners; } $globalListeners = static::instance()->prioritisedListeners($eventKey); - $globalListeners = empty($globalListeners) ? [] : $globalListeners; $priorities = array_merge(array_keys($globalListeners), array_keys($localListeners)); $priorities = array_unique($priorities); @@ -455,7 +394,7 @@ public function listeners($eventKey) * @param string $eventKey Event key. * @return array */ - public function prioritisedListeners($eventKey) + public function prioritisedListeners(string $eventKey): array { if (empty($this->_listeners[$eventKey])) { return []; @@ -470,25 +409,24 @@ public function prioritisedListeners($eventKey) * @param string $eventKeyPattern Pattern to match. * @return array */ - public function matchingListeners($eventKeyPattern) + public function matchingListeners(string $eventKeyPattern): array { $matchPattern = '/' . preg_quote($eventKeyPattern, '/') . '/'; - $matches = array_intersect_key( + + return array_intersect_key( $this->_listeners, array_flip( - preg_grep($matchPattern, array_keys($this->_listeners), 0) - ) + preg_grep($matchPattern, array_keys($this->_listeners), 0) ?: [], + ), ); - - return $matches; } /** * Returns the event list. * - * @return \Cake\Event\EventList + * @return \Cake\Event\EventList|null */ - public function getEventList() + public function getEventList(): ?EventList { return $this->_eventList; } @@ -496,14 +434,13 @@ public function getEventList() /** * Adds an event to the list if the event list object is present. * - * @param \Cake\Event\Event $event An event to add to the list. + * @template TSubject of object + * @param \Cake\Event\EventInterface $event An event to add to the list. * @return $this */ - public function addEventToList(Event $event) + public function addEventToList(EventInterface $event) { - if ($this->_eventList) { - $this->_eventList->add($event); - } + $this->_eventList?->add($event); return $this; } @@ -514,9 +451,9 @@ public function addEventToList(Event $event) * @param bool $enabled True or false to enable / disable it. * @return $this */ - public function trackEvents($enabled) + public function trackEvents(bool $enabled) { - $this->_trackEvents = (bool)$enabled; + $this->_trackEvents = $enabled; return $this; } @@ -526,7 +463,7 @@ public function trackEvents($enabled) * * @return bool */ - public function isTrackingEvents() + public function isTrackingEvents(): bool { return $this->_trackEvents && $this->_eventList; } @@ -534,7 +471,7 @@ public function isTrackingEvents() /** * Enables the listing of dispatched events. * - * @param \Cake\Event\EventList $eventList The event list object to use. + * @param \Cake\Event\EventList $eventList The event list object to use. * @return $this */ public function setEventList(EventList $eventList) @@ -561,9 +498,9 @@ public function unsetEventList() /** * Debug friendly object properties. * - * @return array + * @return array */ - public function __debugInfo() + public function __debugInfo(): array { $properties = get_object_vars($this); $properties['_generalManager'] = '(object) EventManager'; @@ -575,11 +512,14 @@ public function __debugInfo() } $properties['_listeners'][$key] = $listenerCount . ' listener(s)'; } - if ($this->_eventList) { - $count = count($this->_eventList); - for ($i = 0; $i < $count; $i++) { - $event = $this->_eventList[$i]; - $properties['_dispatchedEvents'][] = $event->getName() . ' with subject ' . get_class($event->getSubject()); + if ($this->_eventList !== null) { + foreach ($this->_eventList as $event) { + try { + $subject = $event->getSubject(); + $properties['_dispatchedEvents'][] = $event->getName() . ' with subject ' . $subject::class; + } catch (CakeException) { + $properties['_dispatchedEvents'][] = $event->getName() . ' with no subject'; + } } } else { $properties['_dispatchedEvents'] = null; diff --git a/src/Event/EventManagerInterface.php b/src/Event/EventManagerInterface.php new file mode 100644 index 00000000000..96db2703d04 --- /dev/null +++ b/src/Event/EventManagerInterface.php @@ -0,0 +1,120 @@ +on($listener); + * ``` + * + * Binding with no options: + * + * ``` + * $eventManager->on('Model.beforeSave', $callable); + * ``` + * + * Binding with options: + * + * ``` + * $eventManager->on('Model.beforeSave', ['priority' => 90], $callable); + * ``` + * + * @param \Cake\Event\EventListenerInterface|string $eventKey The event unique identifier name + * with which the callback will be associated. If $eventKey is an instance of + * Cake\Event\EventListenerInterface its events will be bound using the `implementedEvents()` methods. + * + * @param callable|array $options Either an array of options or the callable you wish to + * bind to $eventKey. If an array of options, the `priority` key can be used to define the order. + * Priorities are treated as queues. Lower values are called before higher ones, and multiple attachments + * added to the same priority queue will be treated in the order of insertion. + * + * @param callable|null $callable The callable function you want invoked. + * @return $this + * @throws \InvalidArgumentException When event key is missing or callable is not an + * instance of Cake\Event\EventListenerInterface. + */ + public function on( + EventListenerInterface|string $eventKey, + callable|array $options = [], + ?callable $callable = null, + ); + + /** + * Remove a listener from the active listeners. + * + * Remove a EventListenerInterface entirely: + * + * ``` + * $manager->off($listener); + * ``` + * + * Remove all listeners for a given event: + * + * ``` + * $manager->off('My.event'); + * ``` + * + * Remove a specific listener: + * + * ``` + * $manager->off('My.event', $callback); + * ``` + * + * Remove a callback from all events: + * + * ``` + * $manager->off($callback); + * ``` + * + * @param \Cake\Event\EventListenerInterface|callable|string $eventKey The event unique identifier name + * with which the callback has been associated, or the $listener you want to remove. + * @param \Cake\Event\EventListenerInterface|callable|null $callable The callback you want to detach. + * @return $this + */ + public function off( + EventListenerInterface|callable|string $eventKey, + EventListenerInterface|callable|null $callable = null, + ); + + /** + * Dispatches a new event to all configured listeners + * + * @template TSubject of object + * @param \Cake\Event\EventInterface|string $event The event key name or instance of EventInterface. + * @return \Cake\Event\EventInterface + * @triggers $event + */ + public function dispatch(EventInterface|string $event): EventInterface; + + /** + * Returns a list of all listeners for an eventKey in the order they should be called + * + * @param string $eventKey Event key. + * @return array + */ + public function listeners(string $eventKey): array; +} diff --git a/src/Event/EventManagerTrait.php b/src/Event/EventManagerTrait.php deleted file mode 100644 index efc0a5abefa..00000000000 --- a/src/Event/EventManagerTrait.php +++ /dev/null @@ -1,26 +0,0 @@ -=5.6.0" + "php": ">=8.2", + "cakephp/core": "^5.4.0" }, "autoload": { "psr-4": { "Cake\\Event\\": "." } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" + } } } diff --git a/src/Filesystem/File.php b/src/Filesystem/File.php deleted file mode 100644 index a0da4240c70..00000000000 --- a/src/Filesystem/File.php +++ /dev/null @@ -1,633 +0,0 @@ -Folder = new Folder(dirname($path), $create, $mode); - if (!is_dir($path)) { - $this->name = basename($path); - } - $this->pwd(); - $create && !$this->exists() && $this->safe($path) && $this->create(); - } - - /** - * Closes the current file if it is opened - */ - public function __destruct() - { - $this->close(); - } - - /** - * Creates the file. - * - * @return bool Success - */ - public function create() - { - $dir = $this->Folder->pwd(); - - if (is_dir($dir) && is_writable($dir) && !$this->exists()) { - if (touch($this->path)) { - return true; - } - } - - return false; - } - - /** - * Opens the current file with a given $mode - * - * @param string $mode A valid 'fopen' mode string (r|w|a ...) - * @param bool $force If true then the file will be re-opened even if its already opened, otherwise it won't - * @return bool True on success, false on failure - */ - public function open($mode = 'r', $force = false) - { - if (!$force && is_resource($this->handle)) { - return true; - } - if ($this->exists() === false && $this->create() === false) { - return false; - } - - $this->handle = fopen($this->path, $mode); - - return is_resource($this->handle); - } - - /** - * Return the contents of this file as a string. - * - * @param string|bool $bytes where to start - * @param string $mode A `fread` compatible mode. - * @param bool $force If true then the file will be re-opened even if its already opened, otherwise it won't - * @return string|false string on success, false on failure - */ - public function read($bytes = false, $mode = 'rb', $force = false) - { - if ($bytes === false && $this->lock === null) { - return file_get_contents($this->path); - } - if ($this->open($mode, $force) === false) { - return false; - } - if ($this->lock !== null && flock($this->handle, LOCK_SH) === false) { - return false; - } - if (is_int($bytes)) { - return fread($this->handle, $bytes); - } - - $data = ''; - while (!feof($this->handle)) { - $data .= fgets($this->handle, 4096); - } - - if ($this->lock !== null) { - flock($this->handle, LOCK_UN); - } - if ($bytes === false) { - $this->close(); - } - - return trim($data); - } - - /** - * Sets or gets the offset for the currently opened file. - * - * @param int|bool $offset The $offset in bytes to seek. If set to false then the current offset is returned. - * @param int $seek PHP Constant SEEK_SET | SEEK_CUR | SEEK_END determining what the $offset is relative to - * @return int|bool True on success, false on failure (set mode), false on failure or integer offset on success (get mode) - */ - public function offset($offset = false, $seek = SEEK_SET) - { - if ($offset === false) { - if (is_resource($this->handle)) { - return ftell($this->handle); - } - } elseif ($this->open() === true) { - return fseek($this->handle, $offset, $seek) === 0; - } - - return false; - } - - /** - * Prepares an ASCII string for writing. Converts line endings to the - * correct terminator for the current platform. If Windows, "\r\n" will be used, - * all other platforms will use "\n" - * - * @param string $data Data to prepare for writing. - * @param bool $forceWindows If true forces Windows new line string. - * @return string The with converted line endings. - */ - public static function prepare($data, $forceWindows = false) - { - $lineBreak = "\n"; - if (DIRECTORY_SEPARATOR === '\\' || $forceWindows === true) { - $lineBreak = "\r\n"; - } - - return strtr($data, ["\r\n" => $lineBreak, "\n" => $lineBreak, "\r" => $lineBreak]); - } - - /** - * Write given data to this file. - * - * @param string $data Data to write to this File. - * @param string $mode Mode of writing. {@link https://secure.php.net/fwrite See fwrite()}. - * @param bool $force Force the file to open - * @return bool Success - */ - public function write($data, $mode = 'w', $force = false) - { - $success = false; - if ($this->open($mode, $force) === true) { - if ($this->lock !== null && flock($this->handle, LOCK_EX) === false) { - return false; - } - - if (fwrite($this->handle, $data) !== false) { - $success = true; - } - if ($this->lock !== null) { - flock($this->handle, LOCK_UN); - } - } - - return $success; - } - - /** - * Append given data string to this file. - * - * @param string $data Data to write - * @param bool $force Force the file to open - * @return bool Success - */ - public function append($data, $force = false) - { - return $this->write($data, 'a', $force); - } - - /** - * Closes the current file if it is opened. - * - * @return bool True if closing was successful or file was already closed, otherwise false - */ - public function close() - { - if (!is_resource($this->handle)) { - return true; - } - - return fclose($this->handle); - } - - /** - * Deletes the file. - * - * @return bool Success - */ - public function delete() - { - if (is_resource($this->handle)) { - fclose($this->handle); - $this->handle = null; - } - if ($this->exists()) { - return unlink($this->path); - } - - return false; - } - - /** - * Returns the file info as an array with the following keys: - * - * - dirname - * - basename - * - extension - * - filename - * - filesize - * - mime - * - * @return array File information. - */ - public function info() - { - if (!$this->info) { - $this->info = pathinfo($this->path); - } - if (!isset($this->info['filename'])) { - $this->info['filename'] = $this->name(); - } - if (!isset($this->info['filesize'])) { - $this->info['filesize'] = $this->size(); - } - if (!isset($this->info['mime'])) { - $this->info['mime'] = $this->mime(); - } - - return $this->info; - } - - /** - * Returns the file extension. - * - * @return string|false The file extension, false if extension cannot be extracted. - */ - public function ext() - { - if (!$this->info) { - $this->info(); - } - if (isset($this->info['extension'])) { - return $this->info['extension']; - } - - return false; - } - - /** - * Returns the file name without extension. - * - * @return string|false The file name without extension, false if name cannot be extracted. - */ - public function name() - { - if (!$this->info) { - $this->info(); - } - if (isset($this->info['extension'])) { - return basename($this->name, '.' . $this->info['extension']); - } - if ($this->name) { - return $this->name; - } - - return false; - } - - /** - * Makes file name safe for saving - * - * @param string|null $name The name of the file to make safe if different from $this->name - * @param string|null $ext The name of the extension to make safe if different from $this->ext - * @return string The extension of the file - */ - public function safe($name = null, $ext = null) - { - if (!$name) { - $name = $this->name; - } - if (!$ext) { - $ext = $this->ext(); - } - - return preg_replace("/(?:[^\w\.-]+)/", '_', basename($name, $ext)); - } - - /** - * Get md5 Checksum of file with previous check of Filesize - * - * @param int|bool $maxsize in MB or true to force - * @return string|false md5 Checksum {@link https://secure.php.net/md5_file See md5_file()}, or false in case of an error - */ - public function md5($maxsize = 5) - { - if ($maxsize === true) { - return md5_file($this->path); - } - - $size = $this->size(); - if ($size && $size < ($maxsize * 1024) * 1024) { - return md5_file($this->path); - } - - return false; - } - - /** - * Returns the full path of the file. - * - * @return string Full path to the file - */ - public function pwd() - { - if ($this->path === null) { - $dir = $this->Folder->pwd(); - if (is_dir($dir)) { - $this->path = $this->Folder->slashTerm($dir) . $this->name; - } - } - - return $this->path; - } - - /** - * Returns true if the file exists. - * - * @return bool True if it exists, false otherwise - */ - public function exists() - { - $this->clearStatCache(); - - return (file_exists($this->path) && is_file($this->path)); - } - - /** - * Returns the "chmod" (permissions) of the file. - * - * @return string|false Permissions for the file, or false in case of an error - */ - public function perms() - { - if ($this->exists()) { - return substr(sprintf('%o', fileperms($this->path)), -4); - } - - return false; - } - - /** - * Returns the file size - * - * @return int|false Size of the file in bytes, or false in case of an error - */ - public function size() - { - if ($this->exists()) { - return filesize($this->path); - } - - return false; - } - - /** - * Returns true if the file is writable. - * - * @return bool True if it's writable, false otherwise - */ - public function writable() - { - return is_writable($this->path); - } - - /** - * Returns true if the File is executable. - * - * @return bool True if it's executable, false otherwise - */ - public function executable() - { - return is_executable($this->path); - } - - /** - * Returns true if the file is readable. - * - * @return bool True if file is readable, false otherwise - */ - public function readable() - { - return is_readable($this->path); - } - - /** - * Returns the file's owner. - * - * @return int|false The file owner, or false in case of an error - */ - public function owner() - { - if ($this->exists()) { - return fileowner($this->path); - } - - return false; - } - - /** - * Returns the file's group. - * - * @return int|false The file group, or false in case of an error - */ - public function group() - { - if ($this->exists()) { - return filegroup($this->path); - } - - return false; - } - - /** - * Returns last access time. - * - * @return int|false Timestamp of last access time, or false in case of an error - */ - public function lastAccess() - { - if ($this->exists()) { - return fileatime($this->path); - } - - return false; - } - - /** - * Returns last modified time. - * - * @return int|false Timestamp of last modification, or false in case of an error - */ - public function lastChange() - { - if ($this->exists()) { - return filemtime($this->path); - } - - return false; - } - - /** - * Returns the current folder. - * - * @return \Cake\Filesystem\Folder Current folder - */ - public function folder() - { - return $this->Folder; - } - - /** - * Copy the File to $dest - * - * @param string $dest Destination for the copy - * @param bool $overwrite Overwrite $dest if exists - * @return bool Success - */ - public function copy($dest, $overwrite = true) - { - if (!$this->exists() || is_file($dest) && !$overwrite) { - return false; - } - - return copy($this->path, $dest); - } - - /** - * Gets the mime type of the file. Uses the finfo extension if - * it's available, otherwise falls back to mime_content_type(). - * - * @return false|string The mimetype of the file, or false if reading fails. - */ - public function mime() - { - if (!$this->exists()) { - return false; - } - if (class_exists('finfo')) { - $finfo = new finfo(FILEINFO_MIME); - $type = $finfo->file($this->pwd()); - if (!$type) { - return false; - } - list($type) = explode(';', $type); - - return $type; - } - if (function_exists('mime_content_type')) { - return mime_content_type($this->pwd()); - } - - return false; - } - - /** - * Clear PHP's internal stat cache - * - * @param bool $all Clear all cache or not. Passing false will clear - * the stat cache for the current path only. - * @return void - */ - public function clearStatCache($all = false) - { - if ($all === false) { - clearstatcache(true, $this->path); - } - - clearstatcache(); - } - - /** - * Searches for a given text and replaces the text if found. - * - * @param string|array $search Text(s) to search for. - * @param string|array $replace Text(s) to replace with. - * @return bool Success - */ - public function replaceText($search, $replace) - { - if (!$this->open('r+')) { - return false; - } - - if ($this->lock !== null && flock($this->handle, LOCK_EX) === false) { - return false; - } - - $replaced = $this->write(str_replace($search, $replace, $this->read()), 'w', true); - - if ($this->lock !== null) { - flock($this->handle, LOCK_UN); - } - $this->close(); - - return $replaced; - } -} diff --git a/src/Filesystem/Folder.php b/src/Filesystem/Folder.php deleted file mode 100644 index 859c895037c..00000000000 --- a/src/Filesystem/Folder.php +++ /dev/null @@ -1,975 +0,0 @@ - 'getPathname', - self::SORT_TIME => 'getCTime' - ]; - - /** - * Holds messages from last method. - * - * @var array - */ - protected $_messages = []; - - /** - * Holds errors from last method. - * - * @var array - */ - protected $_errors = []; - - /** - * Holds array of complete directory paths. - * - * @var array - */ - protected $_directories; - - /** - * Holds array of complete file paths. - * - * @var array - */ - protected $_files; - - /** - * Constructor. - * - * @param string|null $path Path to folder - * @param bool $create Create folder if not found - * @param int|false $mode Mode (CHMOD) to apply to created folder, false to ignore - */ - public function __construct($path = null, $create = false, $mode = false) - { - if (empty($path)) { - $path = TMP; - } - if ($mode) { - $this->mode = $mode; - } - - if (!file_exists($path) && $create === true) { - $this->create($path, $this->mode); - } - if (!Folder::isAbsolute($path)) { - $path = realpath($path); - } - if (!empty($path)) { - $this->cd($path); - } - } - - /** - * Return current path. - * - * @return string Current path - */ - public function pwd() - { - return $this->path; - } - - /** - * Change directory to $path. - * - * @param string $path Path to the directory to change to - * @return string|bool The new path. Returns false on failure - */ - public function cd($path) - { - $path = $this->realpath($path); - if (is_dir($path)) { - return $this->path = $path; - } - - return false; - } - - /** - * Returns an array of the contents of the current directory. - * The returned array holds two arrays: One of directories and one of files. - * - * @param string|bool $sort Whether you want the results sorted, set this and the sort property - * to false to get unsorted results. - * @param array|bool $exceptions Either an array or boolean true will not grab dot files - * @param bool $fullPath True returns the full path - * @return array Contents of current directory as an array, an empty array on failure - */ - public function read($sort = self::SORT_NAME, $exceptions = false, $fullPath = false) - { - $dirs = $files = []; - - if (!$this->pwd()) { - return [$dirs, $files]; - } - if (is_array($exceptions)) { - $exceptions = array_flip($exceptions); - } - $skipHidden = isset($exceptions['.']) || $exceptions === true; - - try { - $iterator = new DirectoryIterator($this->path); - } catch (Exception $e) { - return [$dirs, $files]; - } - - if (!is_bool($sort) && isset($this->_fsorts[$sort])) { - $methodName = $this->_fsorts[$sort]; - } else { - $methodName = $this->_fsorts[self::SORT_NAME]; - } - - foreach ($iterator as $item) { - if ($item->isDot()) { - continue; - } - $name = $item->getFilename(); - if ($skipHidden && $name[0] === '.' || isset($exceptions[$name])) { - continue; - } - if ($fullPath) { - $name = $item->getPathname(); - } - - if ($item->isDir()) { - $dirs[$item->{$methodName}()][] = $name; - } else { - $files[$item->{$methodName}()][] = $name; - } - } - - if ($sort || $this->sort) { - ksort($dirs); - ksort($files); - } - - if ($dirs) { - $dirs = array_merge(...array_values($dirs)); - } - - if ($files) { - $files = array_merge(...array_values($files)); - } - - return [$dirs, $files]; - } - - /** - * Returns an array of all matching files in current directory. - * - * @param string $regexpPattern Preg_match pattern (Defaults to: .*) - * @param bool $sort Whether results should be sorted. - * @return array Files that match given pattern - */ - public function find($regexpPattern = '.*', $sort = false) - { - list(, $files) = $this->read($sort); - - return array_values(preg_grep('/^' . $regexpPattern . '$/i', $files)); - } - - /** - * Returns an array of all matching files in and below current directory. - * - * @param string $pattern Preg_match pattern (Defaults to: .*) - * @param bool $sort Whether results should be sorted. - * @return array Files matching $pattern - */ - public function findRecursive($pattern = '.*', $sort = false) - { - if (!$this->pwd()) { - return []; - } - $startsOn = $this->path; - $out = $this->_findRecursive($pattern, $sort); - $this->cd($startsOn); - - return $out; - } - - /** - * Private helper function for findRecursive. - * - * @param string $pattern Pattern to match against - * @param bool $sort Whether results should be sorted. - * @return array Files matching pattern - */ - protected function _findRecursive($pattern, $sort = false) - { - list($dirs, $files) = $this->read($sort); - $found = []; - - foreach ($files as $file) { - if (preg_match('/^' . $pattern . '$/i', $file)) { - $found[] = Folder::addPathElement($this->path, $file); - } - } - $start = $this->path; - - foreach ($dirs as $dir) { - $this->cd(Folder::addPathElement($start, $dir)); - $found = array_merge($found, $this->findRecursive($pattern, $sort)); - } - - return $found; - } - - /** - * Returns true if given $path is a Windows path. - * - * @param string $path Path to check - * @return bool true if windows path, false otherwise - */ - public static function isWindowsPath($path) - { - return (preg_match('/^[A-Z]:\\\\/i', $path) || substr($path, 0, 2) === '\\\\'); - } - - /** - * Returns true if given $path is an absolute path. - * - * @param string $path Path to check - * @return bool true if path is absolute. - */ - public static function isAbsolute($path) - { - if (empty($path)) { - return false; - } - - return $path[0] === '/' || - preg_match('/^[A-Z]:\\\\/i', $path) || - substr($path, 0, 2) === '\\\\' || - self::isRegisteredStreamWrapper($path); - } - - /** - * Returns true if given $path is a registered stream wrapper. - * - * @param string $path Path to check - * @return bool True if path is registered stream wrapper. - */ - public static function isRegisteredStreamWrapper($path) - { - return preg_match('/^[A-Z]+(?=:\/\/)/i', $path, $matches) && - in_array($matches[0], stream_get_wrappers()); - } - - /** - * Returns a correct set of slashes for given $path. (\\ for Windows paths and / for other paths.) - * - * @param string $path Path to check - * @return string Set of slashes ("\\" or "/") - */ - public static function normalizePath($path) - { - return Folder::correctSlashFor($path); - } - - /** - * Returns a correct set of slashes for given $path. (\\ for Windows paths and / for other paths.) - * - * @param string $path Path to check - * @return string Set of slashes ("\\" or "/") - */ - public static function correctSlashFor($path) - { - return Folder::isWindowsPath($path) ? '\\' : '/'; - } - - /** - * Returns $path with added terminating slash (corrected for Windows or other OS). - * - * @param string $path Path to check - * @return string Path with ending slash - */ - public static function slashTerm($path) - { - if (Folder::isSlashTerm($path)) { - return $path; - } - - return $path . Folder::correctSlashFor($path); - } - - /** - * Returns $path with $element added, with correct slash in-between. - * - * @param string $path Path - * @param string|array $element Element to add at end of path - * @return string Combined path - */ - public static function addPathElement($path, $element) - { - $element = (array)$element; - array_unshift($element, rtrim($path, DIRECTORY_SEPARATOR)); - - return implode(DIRECTORY_SEPARATOR, $element); - } - - /** - * Returns true if the Folder is in the given Cake path. - * - * @param string $path The path to check. - * @return bool - * @deprecated 3.2.12 This method will be removed in 4.0.0. Use inPath() instead. - */ - public function inCakePath($path = '') - { - $dir = substr(Folder::slashTerm(ROOT), 0, -1); - $newdir = $dir . $path; - - return $this->inPath($newdir); - } - - /** - * Returns true if the Folder is in the given path. - * - * @param string $path The absolute path to check that the current `pwd()` resides within. - * @param bool $reverse Reverse the search, check if the given `$path` resides within the current `pwd()`. - * @return bool - * @throws \InvalidArgumentException When the given `$path` argument is not an absolute path. - */ - public function inPath($path, $reverse = false) - { - if (!Folder::isAbsolute($path)) { - throw new InvalidArgumentException('The $path argument is expected to be an absolute path.'); - } - - $dir = Folder::slashTerm($path); - $current = Folder::slashTerm($this->pwd()); - - if (!$reverse) { - $return = preg_match('/^' . preg_quote($dir, '/') . '(.*)/', $current); - } else { - $return = preg_match('/^' . preg_quote($current, '/') . '(.*)/', $dir); - } - - return (bool)$return; - } - - /** - * Change the mode on a directory structure recursively. This includes changing the mode on files as well. - * - * @param string $path The path to chmod. - * @param int|bool $mode Octal value, e.g. 0755. - * @param bool $recursive Chmod recursively, set to false to only change the current directory. - * @param array $exceptions Array of files, directories to skip. - * @return bool Success. - */ - public function chmod($path, $mode = false, $recursive = true, array $exceptions = []) - { - if (!$mode) { - $mode = $this->mode; - } - - if ($recursive === false && is_dir($path)) { - //@codingStandardsIgnoreStart - if (@chmod($path, intval($mode, 8))) { - //@codingStandardsIgnoreEnd - $this->_messages[] = sprintf('%s changed to %s', $path, $mode); - - return true; - } - - $this->_errors[] = sprintf('%s NOT changed to %s', $path, $mode); - - return false; - } - - if (is_dir($path)) { - $paths = $this->tree($path); - - foreach ($paths as $type) { - foreach ($type as $fullpath) { - $check = explode(DIRECTORY_SEPARATOR, $fullpath); - $count = count($check); - - if (in_array($check[$count - 1], $exceptions)) { - continue; - } - - //@codingStandardsIgnoreStart - if (@chmod($fullpath, intval($mode, 8))) { - //@codingStandardsIgnoreEnd - $this->_messages[] = sprintf('%s changed to %s', $fullpath, $mode); - } else { - $this->_errors[] = sprintf('%s NOT changed to %s', $fullpath, $mode); - } - } - } - - if (empty($this->_errors)) { - return true; - } - } - - return false; - } - - /** - * Returns an array of subdirectories for the provided or current path. - * - * @param string|null $path The directory path to get subdirectories for. - * @param bool $fullPath Whether to return the full path or only the directory name. - * @return array Array of subdirectories for the provided or current path. - */ - public function subdirectories($path = null, $fullPath = true) - { - if (!$path) { - $path = $this->path; - } - $subdirectories = []; - - try { - $iterator = new DirectoryIterator($path); - } catch (Exception $e) { - return []; - } - - foreach ($iterator as $item) { - if (!$item->isDir() || $item->isDot()) { - continue; - } - $subdirectories[] = $fullPath ? $item->getRealPath() : $item->getFilename(); - } - - return $subdirectories; - } - - /** - * Returns an array of nested directories and files in each directory - * - * @param string|null $path the directory path to build the tree from - * @param array|bool $exceptions Either an array of files/folder to exclude - * or boolean true to not grab dot files/folders - * @param string|null $type either 'file' or 'dir'. Null returns both files and directories - * @return array Array of nested directories and files in each directory - */ - public function tree($path = null, $exceptions = false, $type = null) - { - if (!$path) { - $path = $this->path; - } - $files = []; - $directories = [$path]; - - if (is_array($exceptions)) { - $exceptions = array_flip($exceptions); - } - $skipHidden = false; - if ($exceptions === true) { - $skipHidden = true; - } elseif (isset($exceptions['.'])) { - $skipHidden = true; - unset($exceptions['.']); - } - - try { - $directory = new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::KEY_AS_PATHNAME | RecursiveDirectoryIterator::CURRENT_AS_SELF); - $iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::SELF_FIRST); - } catch (Exception $e) { - if ($type === null) { - return [[], []]; - } - - return []; - } - - foreach ($iterator as $itemPath => $fsIterator) { - if ($skipHidden) { - $subPathName = $fsIterator->getSubPathname(); - if ($subPathName{0} === '.' || strpos($subPathName, DIRECTORY_SEPARATOR . '.') !== false) { - continue; - } - } - $item = $fsIterator->current(); - if (!empty($exceptions) && isset($exceptions[$item->getFilename()])) { - continue; - } - - if ($item->isFile()) { - $files[] = $itemPath; - } elseif ($item->isDir() && !$item->isDot()) { - $directories[] = $itemPath; - } - } - if ($type === null) { - return [$directories, $files]; - } - if ($type === 'dir') { - return $directories; - } - - return $files; - } - - /** - * Create a directory structure recursively. - * - * Can be used to create deep path structures like `/foo/bar/baz/shoe/horn` - * - * @param string $pathname The directory structure to create. Either an absolute or relative - * path. If the path is relative and exists in the process' cwd it will not be created. - * Otherwise relative paths will be prefixed with the current pwd(). - * @param int|bool $mode octal value 0755 - * @return bool Returns TRUE on success, FALSE on failure - */ - public function create($pathname, $mode = false) - { - if (is_dir($pathname) || empty($pathname)) { - return true; - } - - if (!self::isAbsolute($pathname)) { - $pathname = self::addPathElement($this->pwd(), $pathname); - } - - if (!$mode) { - $mode = $this->mode; - } - - if (is_file($pathname)) { - $this->_errors[] = sprintf('%s is a file', $pathname); - - return false; - } - $pathname = rtrim($pathname, DIRECTORY_SEPARATOR); - $nextPathname = substr($pathname, 0, strrpos($pathname, DIRECTORY_SEPARATOR)); - - if ($this->create($nextPathname, $mode)) { - if (!file_exists($pathname)) { - $old = umask(0); - if (mkdir($pathname, $mode, true)) { - umask($old); - $this->_messages[] = sprintf('%s created', $pathname); - - return true; - } - umask($old); - $this->_errors[] = sprintf('%s NOT created', $pathname); - - return false; - } - } - - return false; - } - - /** - * Returns the size in bytes of this Folder and its contents. - * - * @return int size in bytes of current folder - */ - public function dirsize() - { - $size = 0; - $directory = Folder::slashTerm($this->path); - $stack = [$directory]; - $count = count($stack); - for ($i = 0, $j = $count; $i < $j; ++$i) { - if (is_file($stack[$i])) { - $size += filesize($stack[$i]); - } elseif (is_dir($stack[$i])) { - $dir = dir($stack[$i]); - if ($dir) { - while (($entry = $dir->read()) !== false) { - if ($entry === '.' || $entry === '..') { - continue; - } - $add = $stack[$i] . $entry; - - if (is_dir($stack[$i] . $entry)) { - $add = Folder::slashTerm($add); - } - $stack[] = $add; - } - $dir->close(); - } - } - $j = count($stack); - } - - return $size; - } - - /** - * Recursively Remove directories if the system allows. - * - * @param string|null $path Path of directory to delete - * @return bool Success - */ - public function delete($path = null) - { - if (!$path) { - $path = $this->pwd(); - } - if (!$path) { - return false; - } - $path = Folder::slashTerm($path); - if (is_dir($path)) { - try { - $directory = new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::CURRENT_AS_SELF); - $iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::CHILD_FIRST); - } catch (Exception $e) { - return false; - } - - foreach ($iterator as $item) { - $filePath = $item->getPathname(); - if ($item->isFile() || $item->isLink()) { - //@codingStandardsIgnoreStart - if (@unlink($filePath)) { - //@codingStandardsIgnoreEnd - $this->_messages[] = sprintf('%s removed', $filePath); - } else { - $this->_errors[] = sprintf('%s NOT removed', $filePath); - } - } elseif ($item->isDir() && !$item->isDot()) { - //@codingStandardsIgnoreStart - if (@rmdir($filePath)) { - //@codingStandardsIgnoreEnd - $this->_messages[] = sprintf('%s removed', $filePath); - } else { - $this->_errors[] = sprintf('%s NOT removed', $filePath); - - return false; - } - } - } - - $path = rtrim($path, DIRECTORY_SEPARATOR); - //@codingStandardsIgnoreStart - if (@rmdir($path)) { - //@codingStandardsIgnoreEnd - $this->_messages[] = sprintf('%s removed', $path); - } else { - $this->_errors[] = sprintf('%s NOT removed', $path); - - return false; - } - } - - return true; - } - - /** - * Recursive directory copy. - * - * ### Options - * - * - `to` The directory to copy to. - * - `from` The directory to copy from, this will cause a cd() to occur, changing the results of pwd(). - * - `mode` The mode to copy the files/directories with as integer, e.g. 0775. - * - `skip` Files/directories to skip. - * - `scheme` Folder::MERGE, Folder::OVERWRITE, Folder::SKIP - * - `recursive` Whether to copy recursively or not (default: true - recursive) - * - * @param array|string $options Either an array of options (see above) or a string of the destination directory. - * @return bool Success. - */ - public function copy($options) - { - if (!$this->pwd()) { - return false; - } - $to = null; - if (is_string($options)) { - $to = $options; - $options = []; - } - $options += [ - 'to' => $to, - 'from' => $this->path, - 'mode' => $this->mode, - 'skip' => [], - 'scheme' => Folder::MERGE, - 'recursive' => true - ]; - - $fromDir = $options['from']; - $toDir = $options['to']; - $mode = $options['mode']; - - if (!$this->cd($fromDir)) { - $this->_errors[] = sprintf('%s not found', $fromDir); - - return false; - } - - if (!is_dir($toDir)) { - $this->create($toDir, $mode); - } - - if (!is_writable($toDir)) { - $this->_errors[] = sprintf('%s not writable', $toDir); - - return false; - } - - $exceptions = array_merge(['.', '..', '.svn'], $options['skip']); - //@codingStandardsIgnoreStart - if ($handle = @opendir($fromDir)) { - //@codingStandardsIgnoreEnd - while (($item = readdir($handle)) !== false) { - $to = Folder::addPathElement($toDir, $item); - if (($options['scheme'] != Folder::SKIP || !is_dir($to)) && !in_array($item, $exceptions)) { - $from = Folder::addPathElement($fromDir, $item); - if (is_file($from) && (!is_file($to) || $options['scheme'] != Folder::SKIP)) { - if (copy($from, $to)) { - chmod($to, intval($mode, 8)); - touch($to, filemtime($from)); - $this->_messages[] = sprintf('%s copied to %s', $from, $to); - } else { - $this->_errors[] = sprintf('%s NOT copied to %s', $from, $to); - } - } - - if (is_dir($from) && file_exists($to) && $options['scheme'] === Folder::OVERWRITE) { - $this->delete($to); - } - - if (is_dir($from) && $options['recursive'] === false) { - continue; - } - - if (is_dir($from) && !file_exists($to)) { - $old = umask(0); - if (mkdir($to, $mode, true)) { - umask($old); - $old = umask(0); - chmod($to, $mode); - umask($old); - $this->_messages[] = sprintf('%s created', $to); - $options = ['to' => $to, 'from' => $from] + $options; - $this->copy($options); - } else { - $this->_errors[] = sprintf('%s not created', $to); - } - } elseif (is_dir($from) && $options['scheme'] === Folder::MERGE) { - $options = ['to' => $to, 'from' => $from] + $options; - $this->copy($options); - } - } - } - closedir($handle); - } else { - return false; - } - - return empty($this->_errors); - } - - /** - * Recursive directory move. - * - * ### Options - * - * - `to` The directory to copy to. - * - `from` The directory to copy from, this will cause a cd() to occur, changing the results of pwd(). - * - `chmod` The mode to copy the files/directories with. - * - `skip` Files/directories to skip. - * - `scheme` Folder::MERGE, Folder::OVERWRITE, Folder::SKIP - * - `recursive` Whether to copy recursively or not (default: true - recursive) - * - * @param array|string $options (to, from, chmod, skip, scheme) - * @return bool Success - */ - public function move($options) - { - $to = null; - if (is_string($options)) { - $to = $options; - $options = (array)$options; - } - $options += ['to' => $to, 'from' => $this->path, 'mode' => $this->mode, 'skip' => [], 'recursive' => true]; - - if ($this->copy($options) && $this->delete($options['from'])) { - return (bool)$this->cd($options['to']); - } - - return false; - } - - /** - * get messages from latest method - * - * @param bool $reset Reset message stack after reading - * @return array - */ - public function messages($reset = true) - { - $messages = $this->_messages; - if ($reset) { - $this->_messages = []; - } - - return $messages; - } - - /** - * get error from latest method - * - * @param bool $reset Reset error stack after reading - * @return array - */ - public function errors($reset = true) - { - $errors = $this->_errors; - if ($reset) { - $this->_errors = []; - } - - return $errors; - } - - /** - * Get the real path (taking ".." and such into account) - * - * @param string $path Path to resolve - * @return string|bool The resolved path - */ - public function realpath($path) - { - if (strpos($path, '..') === false) { - if (!Folder::isAbsolute($path)) { - $path = Folder::addPathElement($this->path, $path); - } - - return $path; - } - $path = str_replace('/', DIRECTORY_SEPARATOR, trim($path)); - $parts = explode(DIRECTORY_SEPARATOR, $path); - $newparts = []; - $newpath = ''; - if ($path[0] === DIRECTORY_SEPARATOR) { - $newpath = DIRECTORY_SEPARATOR; - } - - while (($part = array_shift($parts)) !== null) { - if ($part === '.' || $part === '') { - continue; - } - if ($part === '..') { - if (!empty($newparts)) { - array_pop($newparts); - continue; - } - - return false; - } - $newparts[] = $part; - } - $newpath .= implode(DIRECTORY_SEPARATOR, $newparts); - - return Folder::slashTerm($newpath); - } - - /** - * Returns true if given $path ends in a slash (i.e. is slash-terminated). - * - * @param string $path Path to check - * @return bool true if path ends with slash, false otherwise - */ - public static function isSlashTerm($path) - { - $lastChar = $path[strlen($path) - 1]; - - return $lastChar === '/' || $lastChar === '\\'; - } -} diff --git a/src/Filesystem/LICENSE.txt b/src/Filesystem/LICENSE.txt deleted file mode 100644 index 0c4b7932c31..00000000000 --- a/src/Filesystem/LICENSE.txt +++ /dev/null @@ -1,22 +0,0 @@ -The MIT License (MIT) - -CakePHP(tm) : The Rapid Development PHP Framework (https://cakephp.org) -Copyright (c) 2005-2016, Cake Software Foundation, Inc. (https://cakefoundation.org) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/src/Filesystem/README.md b/src/Filesystem/README.md deleted file mode 100644 index 769837df496..00000000000 --- a/src/Filesystem/README.md +++ /dev/null @@ -1,35 +0,0 @@ -[![Total Downloads](https://img.shields.io/packagist/dt/cakephp/filesystem.svg?style=flat-square)](https://packagist.org/packages/cakephp/filesystem) -[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE.txt) - -# CakePHP Filesystem Library - -The Folder and File utilities are convenience classes to help you read from and write/append to files; list files within a folder and other common directory related tasks. - -## Basic Usage - -Create a folder instance and search for all the `.ctp` files within it: - -```php -use Cake\Filesystem\Folder; - -$dir = new Folder('/path/to/folder'); -$files = $dir->find('.*\.ctp'); -``` - -Now you can loop through the files and read from or write/append to the contents or simply delete the file: - -```php -foreach ($files as $file) { - $file = new File($dir->pwd() . DIRECTORY_SEPARATOR . $file); - $contents = $file->read(); - // $file->write('I am overwriting the contents of this file'); - // $file->append('I am adding to the bottom of this file.'); - // $file->delete(); // I am deleting this file - $file->close(); // Be sure to close the file when you're done -} -``` - -## Documentation - -Please make sure you check the [official -documentation](https://book.cakephp.org/3.0/en/core-libraries/file-folder.html) diff --git a/src/Filesystem/composer.json b/src/Filesystem/composer.json deleted file mode 100644 index 545245427f7..00000000000 --- a/src/Filesystem/composer.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "cakephp/filesystem", - "description": "CakePHP filesystem convenience classes to help you work with files and folders.", - "type": "library", - "keywords": [ - "cakephp", - "filesystem", - "files", - "folders" - ], - "homepage": "https://cakephp.org", - "license": "MIT", - "authors": [ - { - "name": "CakePHP Community", - "homepage": "https://github.com/cakephp/filesystem/graphs/contributors" - } - ], - "support": { - "issues": "https://github.com/cakephp/cakephp/issues", - "forum": "https://stackoverflow.com/tags/cakephp", - "irc": "irc://irc.freenode.org/cakephp", - "source": "https://github.com/cakephp/filesystem" - }, - "require": { - "php": ">=5.6.0" - }, - "autoload": { - "psr-4": { - "Cake\\Filesystem\\": "." - } - } -} diff --git a/src/Form/Form.php b/src/Form/Form.php index 18264a0bf89..a07c8fa6470 100644 --- a/src/Form/Form.php +++ b/src/Form/Form.php @@ -1,4 +1,6 @@ schema()` and `$form->validator()`. - * * Forms are conventionally placed in the `App\Form` namespace. */ -class Form +class Form implements EventListenerInterface, EventDispatcherInterface, ValidatorAwareInterface { + use EventDispatcherTrait; + use ValidatorAwareTrait; + + /** + * Name of default validation set. + * + * @var string + */ + public const DEFAULT_VALIDATOR = 'default'; + + /** + * The alias this object is assigned to validators as. + * + * @var string + */ + public const VALIDATOR_PROVIDER_NAME = 'form'; + + /** + * The name of the event dispatched when a validator has been built. + * + * @var string + */ + public const BUILD_VALIDATOR_EVENT = 'Form.buildValidator'; + + /** + * Schema class. + * + * @var class-string<\Cake\Form\Schema> + */ + protected string $_schemaClass = Schema::class; /** * The schema used by this form. * - * @var \Cake\Form\Schema + * @var \Cake\Form\Schema|null */ - protected $_schema; + protected ?Schema $_schema = null; /** * The errors if any * * @var array */ - protected $_errors = []; + protected array $_errors = []; /** - * The validator used by this form. + * Form's data. * - * @var \Cake\Validation\Validator + * @var array */ - protected $_validator; + protected array $_data = []; /** - * Get/Set the schema for this form. - * - * This method will call `_buildSchema()` when the schema - * is first built. This hook method lets you configure the - * schema or load a pre-defined one. + * Constructor * - * @param \Cake\Form\Schema|null $schema The schema to set, or null. - * @return \Cake\Form\Schema the schema instance. + * @param \Cake\Event\EventManager|null $eventManager The event manager. + * Defaults to a new instance. */ - public function schema(Schema $schema = null) + public function __construct(?EventManager $eventManager = null) { - if ($schema === null && empty($this->_schema)) { - $schema = $this->_buildSchema(new Schema()); - } - if ($schema) { - $this->_schema = $schema; + if ($eventManager !== null) { + $this->setEventManager($eventManager); } - return $this->_schema; + $this->getEventManager()->on($this); } /** - * A hook method intended to be implemented by subclasses. + * Get the Form callbacks this form is interested in. * - * You can use this method to define the schema using - * the methods on Cake\Form\Schema, or loads a pre-defined - * schema from a concrete class. + * The conventional method map is: * - * @param \Cake\Form\Schema $schema The schema to customize. - * @return \Cake\Form\Schema The schema to use. + * - Form.buildValidator => buildValidator + * + * @return array */ - protected function _buildSchema(Schema $schema) + public function implementedEvents(): array { - return $schema; + if (method_exists($this, 'buildValidator')) { + return [ + self::BUILD_VALIDATOR_EVENT => 'buildValidator', + ]; + } + + return []; + } + + /** + * Set the schema for this form. + * + * @since 4.1.0 + * @param \Cake\Form\Schema $schema The schema to set + * @return $this + */ + public function setSchema(Schema $schema) + { + $this->_schema = $schema; + + return $this; } /** - * Get/Set the validator for this form. + * Get the schema for this form. * - * This method will call `_buildValidator()` when the validator + * This method will call `_buildSchema()` when the schema * is first built. This hook method lets you configure the - * validator or load a pre-defined one. + * schema or load a pre-defined one. * - * @param \Cake\Validation\Validator|null $validator The validator to set, or null. - * @return \Cake\Validation\Validator the validator instance. + * @since 4.1.0 + * @return \Cake\Form\Schema the schema instance. */ - public function validator(Validator $validator = null) + public function getSchema(): Schema { - if ($validator === null && empty($this->_validator)) { - $validator = $this->_buildValidator(new Validator()); - } - if ($validator) { - $this->_validator = $validator; - } + $this->_schema ??= $this->_buildSchema(new $this->_schemaClass()); - return $this->_validator; + return $this->_schema; } /** * A hook method intended to be implemented by subclasses. * - * You can use this method to define the validator using - * the methods on Cake\Validation\Validator or loads a pre-defined - * validator from a concrete class. + * You can use this method to define the schema using + * the methods on {@link \Cake\Form\Schema}, or loads a pre-defined + * schema from a concrete class. * - * @param \Cake\Validation\Validator $validator The validator to customize. - * @return \Cake\Validation\Validator The validator to use. + * @param \Cake\Form\Schema $schema The schema to customize. + * @return \Cake\Form\Schema The schema to use. */ - protected function _buildValidator(Validator $validator) + protected function _buildSchema(Schema $schema): Schema { - return $validator; + return $schema; } /** * Used to check if $data passes this form's validation. * * @param array $data The data to check. - * @return bool Whether or not the data is valid. + * @param string|null $validator Validator name. + * @return bool Whether the data is valid. + * @throws \RuntimeException If validator is invalid. */ - public function validate(array $data) + public function validate(array $data, ?string $validator = null): bool { - $validator = $this->validator(); - $this->_errors = $validator->errors($data); + $this->_errors = $this->getValidator($validator ?: static::DEFAULT_VALIDATOR) + ->validate($data); - return count($this->_errors) === 0; + return $this->_errors === []; } /** @@ -153,11 +199,32 @@ public function validate(array $data) * * @return array Last set validation errors. */ - public function errors() + public function getErrors(): array { return $this->_errors; } + /** + * Returns validation errors for the given field + * + * Supports dot notation for nested fields. For example: + * - `$form->getError('Common.field_name')` + * - `$form->getError('parent.level.deep_field')` + * + * @param string $field Field name to get the errors from. Supports dot notation for nested fields. + * @return array The validation errors for the given field. + */ + public function getError(string $field): array + { + if (isset($this->_errors[$field])) { + return $this->_errors[$field]; + } + + $error = Hash::get($this->_errors, $field); + + return is_array($error) ? $error : []; + } + /** * Set the errors in the form. * @@ -169,7 +236,6 @@ public function errors() * $form->setErrors($errors); * ``` * - * @since 3.5.1 * @param array $errors Errors list. * @return $this */ @@ -183,22 +249,72 @@ public function setErrors(array $errors) /** * Execute the form if it is valid. * - * First validates the form, then calls the `_execute()` hook method. + * First validates the form, then calls the `process()` hook method. * This hook method can be implemented in subclasses to perform * the action of the form. This may be sending email, interacting * with a remote API, or anything else you may need. * + * ### Options: + * + * - validate: Set to `false` to disable validation. Can also be a string of the validator ruleset to be applied. + * Defaults to `true`/`'default'`. + * * @param array $data Form data. + * @param array $options List of options. * @return bool False on validation failure, otherwise returns the - * result of the `_execute()` method. + * result of the `process()` method. */ - public function execute(array $data) + public function execute(array $data, array $options = []): bool { - if (!$this->validate($data)) { - return false; + // check for deprecated _execute() method - https://github.com/cakephp/cakephp/pull/18725 + $childClass = static::class; + $parentClass = self::class; + $method = new ReflectionMethod($childClass, '_execute'); + $hasOverwrittenExecute = $method->getDeclaringClass()->getName() !== $parentClass; + + $this->_data = $data; + $options += ['validate' => true]; + + if ($options['validate'] === false) { + if ($hasOverwrittenExecute) { + deprecationWarning( + '5.3.0', + 'The _execute() method is deprecated. Override the process() method instead.', + ); + + return $this->_execute($data); + } + + return $this->process($data); } - return $this->_execute($data); + $validator = $options['validate'] === true ? static::DEFAULT_VALIDATOR : $options['validate']; + $validateResult = $this->validate($data, $validator); + + if ($hasOverwrittenExecute) { + deprecationWarning( + '5.3.0', + 'The _execute() method is deprecated. Override the process() method instead.', + ); + + return $validateResult && $this->_execute($data); + } + + return $validateResult && $this->process($data); + } + + /** + * Hook method to be implemented in subclasses. + * + * Used by `execute()` to execute the form's action. + * + * @param array $data Form data. + * @return bool + * @deprecated 5.3.0 Override process() instead. + */ + protected function _execute(array $data): bool + { + return $this->process($data); } /** @@ -209,22 +325,74 @@ public function execute(array $data) * @param array $data Form data. * @return bool */ - protected function _execute(array $data) + protected function process(array $data): bool { return true; } + /** + * Get field data. + * + * @param string|null $field The field name or null to get data array with + * all fields. + * @return mixed + */ + public function getData(?string $field = null): mixed + { + if ($field === null) { + return $this->_data; + } + + return Hash::get($this->_data, $field); + } + + /** + * Saves a variable or an associative array of variables for use inside form data. + * + * @param array|string $name The key to write, can be a dot notation value. + * Alternatively can be an array containing key(s) and value(s). + * @param mixed $value Value to set for var + * @return $this + */ + public function set(array|string $name, mixed $value = null) + { + $write = $name; + if (!is_array($name)) { + $write = [$name => $value]; + } + + /** @var array $write */ + foreach ($write as $key => $val) { + $this->_data = Hash::insert($this->_data, $key, $val); + } + + return $this; + } + + /** + * Set form data. + * + * @param array $data Data array. + * @return $this + */ + public function setData(array $data) + { + $this->_data = $data; + + return $this; + } + /** * Get the printable version of a Form instance. * - * @return array + * @return array */ - public function __debugInfo() + public function __debugInfo(): array { $special = [ - '_schema' => $this->schema()->__debugInfo(), - '_errors' => $this->errors(), - '_validator' => $this->validator()->__debugInfo() + '_schema' => $this->getSchema()->__debugInfo(), + '_errors' => $this->getErrors(), + '_validator' => $this->getValidator()->__debugInfo(), ]; return $special + get_object_vars($this); diff --git a/src/Form/FormProtector.php b/src/Form/FormProtector.php new file mode 100644 index 00000000000..61b8a122db0 --- /dev/null +++ b/src/Form/FormProtector.php @@ -0,0 +1,594 @@ + + */ + protected array $unlockedFields = []; + + /** + * Error message providing detail for failed validation. + * + * @var string|null + */ + protected ?string $debugMessage = null; + + /** + * Validate submitted form data. + * + * @param mixed $formData Form data. + * @param string $url URL form was POSTed to. + * @param string $sessionId Session id for hash generation. + * @return bool + */ + public function validate(mixed $formData, string $url, string $sessionId): bool + { + $this->debugMessage = null; + + $extractedToken = $this->extractToken($formData); + if (!$extractedToken) { + return false; + } + + $hashParts = $this->extractHashParts($formData); + $generatedToken = $this->generateHash( + $hashParts['fields'], + $hashParts['unlockedFields'], + $url, + $sessionId, + ); + + if (hash_equals($generatedToken, $extractedToken)) { + return true; + } + + if (Configure::read('debug')) { + $debugMessage = $this->debugTokenNotMatching($formData, $hashParts + compact('url', 'sessionId')); + if ($debugMessage) { + $this->debugMessage = $debugMessage; + } + } + + return false; + } + + /** + * Construct. + * + * @param array $data Data array, can contain key `unlockedFields` with list of unlocked fields. + */ + public function __construct(array $data = []) + { + if (!empty($data['unlockedFields'])) { + $this->unlockedFields = $data['unlockedFields']; + } + } + + /** + * Determine which fields of a form should be used for hash. + * + * @param array|string $field Reference to field to be secured. Can be dot + * separated string to indicate nesting or array of fieldname parts. + * @param bool $lock Whether this field should be part of the validation + * or excluded as part of the unlockedFields. Default `true`. + * @param mixed $value Field value, if value should not be tampered with. + * @return $this + */ + public function addField(array|string $field, bool $lock = true, mixed $value = null) + { + if (is_string($field)) { + $field = $this->getFieldNameArray($field); + } + + if (!$field) { + return $this; + } + + foreach ($this->unlockedFields as $unlockField) { + $unlockParts = explode('.', $unlockField); + if (array_values(array_intersect($field, $unlockParts)) === $unlockParts) { + return $this; + } + } + + $field = implode('.', $field); + $field = (string)preg_replace('/(\.\d+)+$/', '', $field); + + if ($lock) { + if (!in_array($field, $this->fields, true)) { + if ($value !== null) { + $this->fields[$field] = $value; + + return $this; + } + if (isset($this->fields[$field])) { + unset($this->fields[$field]); + } + $this->fields[] = $field; + } + } else { + $this->unlockField($field); + } + + return $this; + } + + /** + * Parses the field name to create a dot separated name value for use in + * field hash. If fieldname is of form Model[field] or Model.field an array of + * fieldname parts like ['Model', 'field'] is returned. + * + * @param string $name The form inputs name attribute. + * @return array Array of field name params like ['Model.field'] or + * ['Model', 'field'] for array fields or empty array if $name is empty. + */ + protected function getFieldNameArray(string $name): array + { + if ($name === '') { + return []; + } + + if (!str_contains($name, '[')) { + return Hash::filter(explode('.', $name)); + } + $parts = explode('[', $name); + $parts = array_map(function (string $el) { + return trim($el, ']'); + }, $parts); + + return Hash::filter($parts, 'strlen'); + } + + /** + * Add to the list of fields that are currently unlocked. + * + * Unlocked fields are not included in the field hash. + * + * @param string $name The dot separated name for the field. + * @return $this + */ + public function unlockField(string $name) + { + if (!in_array($name, $this->unlockedFields, true)) { + $this->unlockedFields[] = $name; + } + + $index = array_search($name, $this->fields, true); + if ($index !== false) { + unset($this->fields[$index]); + } + unset($this->fields[$name]); + + return $this; + } + + /** + * Get validation error message. + * + * @return string|null + */ + public function getError(): ?string + { + return $this->debugMessage; + } + + /** + * Extract token from data. + * + * @param mixed $formData Data to validate. + * @return string|null Fields token on success, null on failure. + */ + protected function extractToken(mixed $formData): ?string + { + if (!is_array($formData)) { + $this->debugMessage = 'Request data is not an array.'; + + return null; + } + + $message = '`%s` was not found in request data.'; + if (!isset($formData['_Token'])) { + $this->debugMessage = sprintf($message, '_Token'); + + return null; + } + if (!isset($formData['_Token']['fields'])) { + $this->debugMessage = sprintf($message, '_Token.fields'); + + return null; + } + if (!is_string($formData['_Token']['fields'])) { + $this->debugMessage = '`_Token.fields` is invalid.'; + + return null; + } + if (!isset($formData['_Token']['unlocked'])) { + $this->debugMessage = sprintf($message, '_Token.unlocked'); + + return null; + } + if (Configure::read('debug') && !isset($formData['_Token']['debug'])) { + $this->debugMessage = sprintf($message, '_Token.debug'); + + return null; + } + if (!Configure::read('debug') && isset($formData['_Token']['debug'])) { + $this->debugMessage = 'Unexpected `_Token.debug` found in request data'; + + return null; + } + + $token = urldecode($formData['_Token']['fields']); + if (str_contains($token, ':')) { + [$token, ] = explode(':', $token, 2); + } + + return $token; + } + + /** + * Return hash parts for the token generation + * + * @param array $formData Form data. + * @return array{fields: array, unlockedFields: array, ...} Contains 'fields' and 'unlockedFields' keys. Additional keys allowed. + */ + protected function extractHashParts(array $formData): array + { + $fields = $this->extractFields($formData); + $unlockedFields = $this->sortedUnlockedFields($formData); + + return [ + 'fields' => $fields, + 'unlockedFields' => $unlockedFields, + ]; + } + + /** + * Return the fields list for the hash calculation + * + * @param array $formData Data array + * @return array + */ + protected function extractFields(array $formData): array + { + $locked = ''; + $token = urldecode($formData['_Token']['fields']); + $unlocked = urldecode($formData['_Token']['unlocked']); + + if (str_contains($token, ':')) { + [, $locked] = explode(':', $token, 2); + } + unset($formData['_Token']); + + $locked = $locked ? explode('|', $locked) : []; + $unlocked = $unlocked ? explode('|', $unlocked) : []; + + $fields = Hash::flatten($formData); + $fieldList = array_keys($fields); + $multi = []; + $lockedFields = []; + $isUnlocked = false; + + foreach ($fieldList as $i => $key) { + if (is_string($key) && preg_match('/(\.\d+){1,10}$/', $key)) { + $multi[$i] = preg_replace('/(\.\d+){1,10}$/', '', $key); + unset($fieldList[$i]); + } else { + $fieldList[$i] = (string)$key; + } + } + if ($multi) { + $fieldList += array_unique($multi); + } + + $unlockedFields = array_unique( + array_merge( + $this->unlockedFields, + $unlocked, + ), + ); + + /** @var string $key */ + foreach ($fieldList as $i => $key) { + $isLocked = in_array($key, $locked, true); + + foreach ($unlockedFields as $off) { + $off = explode('.', $off); + $field = array_values(array_intersect(explode('.', $key), $off)); + $isUnlocked = ($field === $off); + if ($isUnlocked) { + break; + } + } + + if ($isUnlocked || $isLocked) { + unset($fieldList[$i]); + if ($isLocked) { + $lockedFields[$key] = $fields[$key]; + } + } + } + sort($fieldList, SORT_STRING); + ksort($lockedFields, SORT_STRING); + $fieldList += $lockedFields; + + return $fieldList; + } + + /** + * Get the sorted unlocked string + * + * @param array $formData Data array + * @return array + */ + protected function sortedUnlockedFields(array $formData): array + { + $unlocked = urldecode($formData['_Token']['unlocked']); + if (!$unlocked) { + return []; + } + + $unlocked = explode('|', $unlocked); + sort($unlocked, SORT_STRING); + + return $unlocked; + } + + /** + * Generate the token data. + * + * @param string $url Form URL. + * @param string $sessionId Session ID. + * @return array{fields: string, unlocked: string, debug: string, ...} The token data. Contains 'fields', 'unlocked', and 'debug' keys. Additional keys allowed. + */ + public function buildTokenData(string $url = '', string $sessionId = ''): array + { + $fields = $this->fields; + $unlockedFields = $this->unlockedFields; + + $locked = []; + foreach ($fields as $key => $value) { + if ($value === true) { + $value = '1'; + } elseif ($value === false) { + $value = '0'; + } elseif (is_numeric($value)) { + $value = (string)$value; + } + + if (!is_int($key)) { + $locked[$key] = $value; + unset($fields[$key]); + } + } + + sort($unlockedFields, SORT_STRING); + sort($fields, SORT_STRING); + ksort($locked, SORT_STRING); + $fields += $locked; + + $fields = $this->generateHash($fields, $unlockedFields, $url, $sessionId); + $locked = implode('|', array_keys($locked)); + + return [ + 'fields' => urlencode($fields . ':' . $locked), + 'unlocked' => urlencode(implode('|', $unlockedFields)), + 'debug' => urlencode((string)json_encode([ + $url, + $this->fields, + $this->unlockedFields, + ])), + ]; + } + + /** + * Generate validation hash. + * + * @param array $fields Fields list. + * @param array $unlockedFields Unlocked fields. + * @param string $url Form URL. + * @param string $sessionId Session Id. + * @return string + */ + protected function generateHash(array $fields, array $unlockedFields, string $url, string $sessionId): string + { + $hashParts = [ + $url, + serialize($fields), + implode('|', $unlockedFields), + $sessionId, + ]; + + return hash_hmac('sha1', implode('', $hashParts), Security::getSalt()); + } + + /** + * Create a message for humans to understand why Security token is not matching + * + * @param array $formData Data. + * @param array $hashParts Elements used to generate the Token hash + * @return string Message explaining why the tokens are not matching + */ + protected function debugTokenNotMatching(array $formData, array $hashParts): string + { + $messages = []; + if (!isset($formData['_Token']['debug'])) { + return 'Form protection debug token not found.'; + } + + $expectedParts = json_decode(urldecode($formData['_Token']['debug']), true); + if (!is_array($expectedParts) || count($expectedParts) !== 3) { + return 'Invalid form protection debug token.'; + } + $expectedUrl = Hash::get($expectedParts, 0); + $url = Hash::get($hashParts, 'url'); + if ($expectedUrl !== $url) { + $messages[] = sprintf('URL mismatch in POST data (expected `%s` but found `%s`)', $expectedUrl, $url); + } + $expectedFields = Hash::get($expectedParts, 1); + $dataFields = Hash::get($hashParts, 'fields') ?: []; + $fieldsMessages = $this->debugCheckFields( + (array)$dataFields, + $expectedFields, + 'Unexpected field `%s` in POST data', + 'Tampered field `%s` in POST data (expected value `%s` but found `%s`)', + 'Missing field `%s` in POST data', + ); + $expectedUnlockedFields = Hash::get($expectedParts, 2); + $dataUnlockedFields = Hash::get($hashParts, 'unlockedFields') ?: []; + $unlockFieldsMessages = $this->debugCheckFields( + (array)$dataUnlockedFields, + $expectedUnlockedFields, + 'Unexpected unlocked field `%s` in POST data', + '', + 'Missing unlocked field: `%s`', + ); + + $messages = array_merge($messages, $fieldsMessages, $unlockFieldsMessages); + + return implode(', ', $messages); + } + + /** + * Iterates data array to check against expected + * + * @param array $dataFields Fields array, containing the POST data fields + * @param array $expectedFields Fields array, containing the expected fields we should have in POST + * @param string $intKeyMessage Message string if unexpected found in data fields indexed by int (not protected) + * @param string $stringKeyMessage Message string if tampered found in + * data fields indexed by string (protected). + * @param string $missingMessage Message string if missing field + * @return array Messages + */ + protected function debugCheckFields( + array $dataFields, + array $expectedFields = [], + string $intKeyMessage = '', + string $stringKeyMessage = '', + string $missingMessage = '', + ): array { + $messages = $this->matchExistingFields($dataFields, $expectedFields, $intKeyMessage, $stringKeyMessage); + $expectedFieldsMessage = $this->debugExpectedFields($expectedFields, $missingMessage); + if ($expectedFieldsMessage !== null) { + $messages[] = $expectedFieldsMessage; + } + + return $messages; + } + + /** + * Generate array of messages for the existing fields in POST data, matching dataFields in $expectedFields + * will be unset + * + * @param array $dataFields Fields array, containing the POST data fields + * @param array $expectedFields Fields array, containing the expected fields we should have in POST + * @param string $intKeyMessage Message string if unexpected found in data fields indexed by int (not protected) + * @param string $stringKeyMessage Message string if tampered found in + * data fields indexed by string (protected) + * @return array Error messages + */ + protected function matchExistingFields( + array $dataFields, + array &$expectedFields, + string $intKeyMessage, + string $stringKeyMessage, + ): array { + $messages = []; + foreach ($dataFields as $key => $value) { + if (is_int($key)) { + $foundKey = array_search($value, $expectedFields, true); + if ($foundKey === false) { + $messages[] = sprintf($intKeyMessage, $value); + } else { + unset($expectedFields[$foundKey]); + } + } else { + if (isset($expectedFields[$key]) && $value !== $expectedFields[$key]) { + $messages[] = sprintf($stringKeyMessage, $key, $expectedFields[$key], $value); + } + unset($expectedFields[$key]); + } + } + + return $messages; + } + + /** + * Generate debug message for the expected fields + * + * @param array $expectedFields Expected fields + * @param string $missingMessage Message template + * @return string|null Error message about expected fields + */ + protected function debugExpectedFields(array $expectedFields = [], string $missingMessage = ''): ?string + { + if ($expectedFields === []) { + return null; + } + + $expectedFieldNames = []; + foreach ($expectedFields as $key => $expectedField) { + if (is_int($key)) { + $expectedFieldNames[] = $expectedField; + } else { + $expectedFieldNames[] = $key; + } + } + + return sprintf($missingMessage, implode(', ', $expectedFieldNames)); + } + + /** + * Return debug info + * + * @return array + */ + public function __debugInfo(): array + { + return [ + 'fields' => $this->fields, + 'unlockedFields' => $this->unlockedFields, + 'debugMessage' => $this->debugMessage, + ]; + } +} diff --git a/src/Form/LICENSE.txt b/src/Form/LICENSE.txt index 0c4b7932c31..b938c9e8ed3 100644 --- a/src/Form/LICENSE.txt +++ b/src/Form/LICENSE.txt @@ -1,7 +1,7 @@ The MIT License (MIT) CakePHP(tm) : The Rapid Development PHP Framework (https://cakephp.org) -Copyright (c) 2005-2016, Cake Software Foundation, Inc. (https://cakefoundation.org) +Copyright (c) 2005-2020, Cake Software Foundation, Inc. (https://cakefoundation.org) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/Form/README.md b/src/Form/README.md index c3e98806856..618c43a10b1 100644 --- a/src/Form/README.md +++ b/src/Form/README.md @@ -25,7 +25,7 @@ class ContactForm extends Form ->addField('body', ['type' => 'text']); } - protected function _buildValidator(Validator $validator) + public function validationDefault(Validator $validator) { return $validator->add('name', 'length', [ 'rule' => ['minLength', 10], @@ -47,7 +47,7 @@ class ContactForm extends Form In the above example we see the 3 hook methods that forms provide: - `_buildSchema()` is used to define the schema data. You can define field type, length, and precision. -- `_buildValidator()` Gets a `Cake\Validation\Validator` instance that you can attach validators to. +- `validationDefault()` Gets a `Cake\Validation\Validator` instance that you can attach validators to. - `_execute()` lets you define the behavior you want to happen when `execute()` is called and the data is valid. You can always define additional public methods as you need as well. @@ -55,9 +55,9 @@ You can always define additional public methods as you need as well. ```php $contact = new ContactForm(); $success = $contact->execute($data); -$errors = $contact->errors(); +$errors = $contact->getErrors(); ``` ## Documentation -Please make sure you check the [official documentation](https://book.cakephp.org/3.0/en/core-libraries/form.html) +Please make sure you check the [official documentation](https://book.cakephp.org/5/en/core-libraries/form.html) diff --git a/src/Form/Schema.php b/src/Form/Schema.php index 08d11fcdb05..942e3aae359 100644 --- a/src/Form/Schema.php +++ b/src/Form/Schema.php @@ -1,4 +1,6 @@ > */ - protected $_fields = []; + protected array $_fields = []; /** * The default values for fields. * - * @var array + * @var array */ - protected $_fieldDefaults = [ + protected array $_fieldDefaults = [ 'type' => null, 'length' => null, 'precision' => null, @@ -42,7 +43,7 @@ class Schema /** * Add multiple fields to the schema. * - * @param array $fields The fields to add. + * @param array|string> $fields The fields to add. * @return $this */ public function addFields(array $fields) @@ -58,11 +59,11 @@ public function addFields(array $fields) * Adds a field to the schema. * * @param string $name The field name. - * @param string|array $attrs The attributes for the field, or the type + * @param array|string $attrs The attributes for the field, or the type * as a string. * @return $this */ - public function addField($name, $attrs) + public function addField(string $name, array|string $attrs) { if (is_string($attrs)) { $attrs = ['type' => $attrs]; @@ -74,12 +75,12 @@ public function addField($name, $attrs) } /** - * Removes a field to the schema. + * Removes a field from the schema. * * @param string $name The field to remove. * @return $this */ - public function removeField($name) + public function removeField(string $name) { unset($this->_fields[$name]); @@ -89,9 +90,9 @@ public function removeField($name) /** * Get the list of fields in the schema. * - * @return array The list of field names. + * @return array The list of field names. */ - public function fields() + public function fields(): array { return array_keys($this->_fields); } @@ -100,15 +101,11 @@ public function fields() * Get the attributes for a given field. * * @param string $name The field name. - * @return null|array The attributes for a field, or null. + * @return array|null The attributes for a field, or null. */ - public function field($name) + public function field(string $name): ?array { - if (!isset($this->_fields[$name])) { - return null; - } - - return $this->_fields[$name]; + return $this->_fields[$name] ?? null; } /** @@ -118,7 +115,7 @@ public function field($name) * @return string|null Either the field type or null if the * field does not exist. */ - public function fieldType($name) + public function fieldType(string $name): ?string { $field = $this->field($name); if (!$field) { @@ -131,12 +128,12 @@ public function fieldType($name) /** * Get the printable version of this object * - * @return array + * @return array */ - public function __debugInfo() + public function __debugInfo(): array { return [ - '_fields' => $this->_fields + '_fields' => $this->_fields, ]; } } diff --git a/src/Form/composer.json b/src/Form/composer.json index 44f23854a18..321e9c5638e 100644 --- a/src/Form/composer.json +++ b/src/Form/composer.json @@ -21,12 +21,20 @@ "source": "https://github.com/cakephp/form" }, "require": { - "php": ">=5.6.0", - "cakephp/validation": "^3.0.0" + "php": ">=8.2", + "cakephp/event": "^5.4.0", + "cakephp/validation":"^5.4.0" }, "autoload": { "psr-4": { "Cake\\Form\\": "." } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" + } } } diff --git a/src/Http/.gitattributes b/src/Http/.gitattributes new file mode 100644 index 00000000000..0086560d10e --- /dev/null +++ b/src/Http/.gitattributes @@ -0,0 +1,10 @@ +# Define the line ending behavior of the different file extensions +# Set default behavior, in case users don't have core.autocrlf set. +* text text=auto eol=lf + +.php diff=php + +# Remove files for archives generated using `git archive` +.gitattributes export-ignore +phpstan.neon.dist export-ignore +tests/ export-ignore diff --git a/src/Http/ActionDispatcher.php b/src/Http/ActionDispatcher.php deleted file mode 100644 index 6a4a94aaafa..00000000000 --- a/src/Http/ActionDispatcher.php +++ /dev/null @@ -1,165 +0,0 @@ -setEventManager($eventManager); - } - foreach ($filters as $filter) { - $this->addFilter($filter); - } - $this->factory = $factory ?: new ControllerFactory(); - } - - /** - * Dispatches a Request & Response - * - * @param \Cake\Http\ServerRequest $request The request to dispatch. - * @param \Cake\Http\Response $response The response to dispatch. - * @return \Cake\Http\Response A modified/replaced response. - */ - public function dispatch(ServerRequest $request, Response $response) - { - if (Router::getRequest(true) !== $request) { - Router::pushRequest($request); - } - $beforeEvent = $this->dispatchEvent('Dispatcher.beforeDispatch', compact('request', 'response')); - - $request = $beforeEvent->getData('request'); - if ($beforeEvent->getResult() instanceof Response) { - return $beforeEvent->getResult(); - } - - // Use the controller built by an beforeDispatch - // event handler if there is one. - if ($beforeEvent->getData('controller') instanceof Controller) { - $controller = $beforeEvent->getData('controller'); - } else { - $controller = $this->factory->create($request, $response); - } - - $response = $this->_invoke($controller); - if (isset($request->params['return'])) { - return $response; - } - - $afterEvent = $this->dispatchEvent('Dispatcher.afterDispatch', compact('request', 'response')); - - return $afterEvent->getData('response'); - } - - /** - * Invoke a controller's action and wrapping methods. - * - * @param \Cake\Controller\Controller $controller The controller to invoke. - * @return \Cake\Http\Response The response - * @throws \LogicException If the controller action returns a non-response value. - */ - protected function _invoke(Controller $controller) - { - $this->dispatchEvent('Dispatcher.invokeController', ['controller' => $controller]); - - $result = $controller->startupProcess(); - if ($result instanceof Response) { - return $result; - } - - $response = $controller->invokeAction(); - if ($response !== null && !($response instanceof Response)) { - throw new LogicException('Controller actions can only return Cake\Http\Response or null.'); - } - - if (!$response && $controller->autoRender) { - $controller->render(); - } - - $result = $controller->shutdownProcess(); - if ($result instanceof Response) { - return $result; - } - if (!$response) { - $response = $controller->response; - } - - return $response; - } - - /** - * Add a filter to this dispatcher. - * - * The added filter will be attached to the event manager used - * by this dispatcher. - * - * @param \Cake\Event\EventListenerInterface $filter The filter to connect. Can be - * any EventListenerInterface. Typically an instance of \Cake\Routing\DispatcherFilter. - * @return void - * @deprecated This is only available for backwards compatibility with DispatchFilters - */ - public function addFilter(EventListenerInterface $filter) - { - $this->filters[] = $filter; - $this->getEventManager()->on($filter); - } - - /** - * Get the connected filters. - * - * @return array - */ - public function getFilters() - { - return $this->filters; - } -} diff --git a/src/Http/BaseApplication.php b/src/Http/BaseApplication.php index 56962dab809..ce3e710605e 100644 --- a/src/Http/BaseApplication.php +++ b/src/Http/BaseApplication.php @@ -1,4 +1,6 @@ |null + */ + protected ?ControllerFactoryInterface $controllerFactory = null; + + /** + * Container + * + * @var \Cake\Core\ContainerInterface|null + */ + protected ?ContainerInterface $container = null; /** * Constructor * * @param string $configDir The directory the bootstrap configuration is held in. + * @param \Cake\Event\EventManagerInterface|null $eventManager Application event manager instance. + * @param \Cake\Http\ControllerFactoryInterface<\Cake\Controller\Controller>|null $controllerFactory Controller factory. */ - public function __construct($configDir) - { - $this->configDir = $configDir; + public function __construct( + string $configDir, + ?EventManagerInterface $eventManager = null, + ?ControllerFactoryInterface $controllerFactory = null, + ) { + $this->configDir = rtrim($configDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $this->plugins = new PluginCollection(); + $this->_eventManager = $eventManager ?: EventManager::instance(); + $this->controllerFactory = $controllerFactory; + Plugin::setCollection($this->plugins); } /** - * @param \Cake\Http\MiddlewareQueue $middleware The middleware queue to set in your App Class + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to set in your App Class * @return \Cake\Http\MiddlewareQueue */ - abstract public function middleware($middleware); + abstract public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue; /** - * {@inheritDoc} + * @inheritDoc */ - public function bootstrap() + public function pluginMiddleware(MiddlewareQueue $middleware): MiddlewareQueue { - require_once $this->configDir . '/bootstrap.php'; + foreach ($this->plugins->with('middleware') as $plugin) { + $middleware = $plugin->middleware($middleware); + } + + return $middleware; + } + + /** + * @inheritDoc + */ + public function addPlugin($name, array $config = []) + { + if (is_string($name)) { + $plugin = $this->plugins->create($name, $config); + } else { + $plugin = $name; + } + $this->plugins->add($plugin); + + return $this; + } + + /** + * Add an optional plugin + * + * If it isn't available, ignore it. + * + * @param \Cake\Core\PluginInterface|string $name The plugin name or plugin object. + * @param array $config The configuration data for the plugin if using a string for $name + * @return $this + */ + public function addOptionalPlugin(PluginInterface|string $name, array $config = []) + { + try { + $this->addPlugin($name, $config); + } catch (MissingPluginException) { + // Do not halt if the plugin is missing + } + + return $this; + } + + /** + * Get the plugin collection in use. + * + * @return \Cake\Core\PluginCollection + */ + public function getPlugins(): PluginCollection + { + return $this->plugins; + } + + /** + * @inheritDoc + */ + public function bootstrap(): void + { + require_once $this->configDir . 'bootstrap.php'; + + // phpcs:ignore + $plugins = @include $this->configDir . 'plugins.php'; + if (is_array($plugins)) { + $this->plugins->addFromConfig($plugins); + } + + $this->registerEvents(); + } + + /** + * Define global event listeners for the application. + * + * Listener classes are resolved through the application container and can + * declare constructor dependencies. + * + * @return list> + */ + public function eventListeners(): array + { + return []; + } + + /** + * @inheritDoc + */ + public function pluginBootstrap(): void + { + foreach ($this->plugins->with('bootstrap') as $plugin) { + $plugin->bootstrap($this); + } } /** * {@inheritDoc} * - * By default this will load `config/routes.php` for ease of use and backwards compatibility. + * By default, this will load `config/routes.php` for ease of use and backwards compatibility. * * @param \Cake\Routing\RouteBuilder $routes A route builder to add routes into. * @return void */ - public function routes($routes) + public function routes(RouteBuilder $routes): void + { + // Only load routes if the router is empty + if (!Router::routes()) { + $return = require $this->configDir . 'routes.php'; + if ($return instanceof Closure) { + $return($routes); + } + } + } + + /** + * @inheritDoc + */ + public function pluginRoutes(RouteBuilder $routes): RouteBuilder { - if (!Router::$initialized) { - require $this->configDir . '/routes.php'; - // Prevent routes from being loaded again - Router::$initialized = true; + foreach ($this->plugins->with('routes') as $plugin) { + $plugin->routes($routes); } + + return $routes; } /** * Define the console commands for an application. * - * By default all commands in CakePHP, plugins and the application will be + * By default, all commands in CakePHP, plugins and the application will be * loaded using conventions based names. * * @param \Cake\Console\CommandCollection $commands The CommandCollection to add commands into. * @return \Cake\Console\CommandCollection The updated collection. */ - public function console($commands) + public function console(CommandCollection $commands): CommandCollection { return $commands->addMany($commands->autoDiscover()); } /** - * Invoke the application. + * @inheritDoc + */ + public function pluginConsole(CommandCollection $commands): CommandCollection + { + foreach ($this->plugins->with('console') as $plugin) { + $commands = $plugin->console($commands); + } + + return $commands; + } + + /** + * @inheritDoc + */ + public function pluginEvents(EventManagerInterface $eventManager): EventManagerInterface + { + return $eventManager; + } + + /** + * Register application events. * - * - Convert the PSR response into CakePHP equivalents. - * - Create the controller that will handle this request. - * - Invoke the controller. + * @return void + */ + protected function registerEvents(): void + { + $eventManager = $this->getEventManager(); + $listeners = $this->eventListeners(); + if ($listeners) { + $this->registerEventListeners($listeners, $eventManager, $this->getContainer()); + } + + $this->events($eventManager); + } + + /** + * Get the dependency injection container for the application. * - * @param \Psr\Http\Message\ServerRequestInterface $request The request - * @param \Psr\Http\Message\ResponseInterface $response The response - * @param callable $next The next middleware - * @return \Psr\Http\Message\ResponseInterface + * The first time the container is fetched it will be constructed + * and stored for future calls. + * + * @return \Cake\Core\ContainerInterface */ - public function __invoke(ServerRequestInterface $request, ResponseInterface $response, $next) + public function getContainer(): ContainerInterface { - return $this->getDispatcher()->dispatch($request, $response); + return $this->container ??= $this->buildContainer(); } /** - * Get the ActionDispatcher. + * Build the service container + * + * Override this method if you need to use a custom container or + * want to change how the container is built. * - * @return \Cake\Http\ActionDispatcher + * The container type is determined by `Configure::read('App.container')`: + * - 'cake': Uses the built-in CakePHP container + * - Any other value: Uses the League container (default) + * + * @return \Cake\Core\ContainerInterface */ - protected function getDispatcher() + protected function buildContainer(): ContainerInterface { - return new ActionDispatcher(null, null, DispatcherFactory::filters()); + $container = ContainerFactory::create(); + $this->services($container); + foreach ($this->plugins->with('services') as $plugin) { + $plugin->services($container); + } + + $event = $this->dispatchEvent('Application.buildContainer', ['container' => $container]); + if ($event->getResult() instanceof ContainerInterface) { + return $event->getResult(); + } + + return $container; + } + + /** + * Register application container services. + * + * @param \Cake\Core\ContainerInterface $container The Container to update. + * @return void + */ + public function services(ContainerInterface $container): void + { + } + + /** + * Register application events. + * + * @param \Cake\Event\EventManagerInterface $eventManager The global event manager to register listeners on + * @return \Cake\Event\EventManagerInterface + */ + public function events(EventManagerInterface $eventManager): EventManagerInterface + { + return $eventManager; + } + + /** + * Invoke the application. + * + * - Add the request to the container, enabling its injection into other services. + * - Create the controller that will handle this request. + * - Invoke the controller. + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request + * @return \Psr\Http\Message\ResponseInterface + */ + public function handle( + ServerRequestInterface $request, + ): ResponseInterface { + $container = $this->getContainer(); + $container->add(ServerRequest::class, $request); + $container->add(ContainerInterface::class, $container); + + $this->controllerFactory ??= new ControllerFactory($container); + + if (Router::getRequest() !== $request) { + assert($request instanceof ServerRequest); + Router::setRequest($request); + } + + $controller = $this->controllerFactory->create($request); + + return $this->controllerFactory->invoke($controller); } } diff --git a/src/Http/CallbackStream.php b/src/Http/CallbackStream.php index f165f3123f3..8289c19df4f 100644 --- a/src/Http/CallbackStream.php +++ b/src/Http/CallbackStream.php @@ -1,4 +1,6 @@ detach(); - $result = $callback ? $callback() : ''; + $result = ''; + if ($callback !== null) { + $result = $callback(); + } if (!is_string($result)) { return ''; } diff --git a/src/Http/Client.php b/src/Http/Client.php index 5cbcd535042..a7b00597cdf 100644 --- a/src/Http/Client.php +++ b/src/Http/Client.php @@ -1,4 +1,6 @@ */ - protected $_defaultConfig = [ - 'adapter' => 'Cake\Http\Client\Adapter\Stream', + protected array $_defaultConfig = [ + 'auth' => null, + 'adapter' => null, 'host' => null, 'port' => null, 'scheme' => 'http', + 'basePath' => '', 'timeout' => 30, 'ssl_verify_peer' => true, 'ssl_verify_peer_name' => true, 'ssl_verify_depth' => 5, 'ssl_verify_host' => true, 'redirect' => false, + 'protocolVersion' => '1.1', ]; /** @@ -122,17 +136,23 @@ class Client * Cookies are indexed by the cookie's domain or * request host name. * - * @var \Cake\Http\Client\CookieCollection + * @var \Cake\Http\Cookie\CookieCollection */ - protected $_cookies; + protected CookieCollection $_cookies; /** - * Adapter for sending requests. Defaults to - * Cake\Http\Client\Adapter\Stream + * Mock adapter for stubbing requests in tests. * - * @var \Cake\Http\Client\Adapter\Stream + * @var \Cake\Http\Client\Adapter\Mock|null */ - protected $_adapter; + protected static ?MockAdapter $_mockAdapter = null; + + /** + * Adapter for sending requests. + * + * @var \Cake\Http\Client\AdapterInterface + */ + protected AdapterInterface $_adapter; /** * Create a new HTTP Client. @@ -144,44 +164,99 @@ class Client * - host - The hostname to do requests on. * - port - The port to use. * - scheme - The default scheme/protocol to use. Defaults to http. + * - basePath - A path to append to the domain to use. (/api/v1/) * - timeout - The timeout in seconds. Defaults to 30 - * - ssl_verify_peer - Whether or not SSL certificates should be validated. + * - ssl_verify_peer - Whether SSL certificates should be validated. * Defaults to true. - * - ssl_verify_peer_name - Whether or not peer names should be validated. + * - ssl_verify_peer_name - Whether peer names should be validated. * Defaults to true. * - ssl_verify_depth - The maximum certificate chain depth to traverse. * Defaults to 5. * - ssl_verify_host - Verify that the certificate and hostname match. * Defaults to true. * - redirect - Number of redirects to follow. Defaults to false. - * - * @param array $config Config options for scoped clients. + * - adapter - The adapter class name or instance. Defaults to + * \Cake\Http\Client\Adapter\Curl if `curl` extension is loaded else + * \Cake\Http\Client\Adapter\Stream. + * - protocolVersion - The HTTP protocol version to use. Defaults to 1.1 + * - auth - The authentication credentials to use. If a `username` and `password` + * key are provided without a `type` key Basic authentication will be assumed. + * You can use the `type` key to define the authentication adapter classname + * to use. Short class names are resolved to the `Http\Client\Auth` namespace. + * + * @param array $config Config options for scoped clients. */ - public function __construct($config = []) + public function __construct(array $config = []) { + $this->_eventClass = ClientEvent::class; $this->setConfig($config); $adapter = $this->_config['adapter']; - $this->setConfig('adapter', null); + if ($adapter === null) { + $adapter = Curl::class; + + if (!extension_loaded('curl')) { + $adapter = Stream::class; + } + } else { + $this->deleteConfig('adapter'); + } + if (is_string($adapter)) { $adapter = new $adapter(); } + $this->_adapter = $adapter; if (!empty($this->_config['cookieJar'])) { $this->_cookies = $this->_config['cookieJar']; - $this->setConfig('cookieJar', null); + $this->deleteConfig('cookieJar'); } else { $this->_cookies = new CookieCollection(); } } + /** + * Client instance returned is scoped to the domain, port, and scheme parsed from the passed URL string. The passed + * string must have a scheme and a domain. Optionally, if a port is included in the string, the port will be scoped + * too. If a path is included in the URL, the client instance will build urls with it prepended. + * Other parts of the url string are ignored. + * + * @param string $url A string URL e.g. https://example.com + * @return static + * @throws \InvalidArgumentException + */ + public static function createFromUrl(string $url): static + { + $parts = parse_url($url); + + if ($parts === false) { + throw new InvalidArgumentException(sprintf( + 'String `%s` did not parse.', + $url, + )); + } + + $config = array_intersect_key($parts, ['scheme' => '', 'port' => '', 'host' => '', 'path' => '']); + + if (empty($config['scheme']) || empty($config['host'])) { + throw new InvalidArgumentException('The URL was parsed but did not contain a scheme or host'); + } + + if (isset($config['path'])) { + $config['basePath'] = $config['path']; + unset($config['path']); + } + + return new static($config); + } + /** * Get the cookies stored in the Client. * - * @return \Cake\Http\Client\CookieCollection + * @return \Cake\Http\Cookie\CookieCollection */ - public function cookies() + public function cookies(): CookieCollection { return $this->_cookies; } @@ -191,6 +266,7 @@ public function cookies() * * @param \Cake\Http\Cookie\CookieInterface $cookie Cookie object. * @return $this + * @throws \InvalidArgumentException */ public function addCookie(CookieInterface $cookie) { @@ -211,15 +287,15 @@ public function addCookie(CookieInterface $cookie) * this feature. * * @param string $url The url or path you want to request. - * @param array $data The query data you want to send. - * @param array $options Additional options for the request. + * @param array|string $data The query data you want to send. + * @param array $options Additional options for the request. * @return \Cake\Http\Client\Response */ - public function get($url, $data = [], array $options = []) + public function get(string $url, array|string $data = [], array $options = []): Response { $options = $this->_mergeOptions($options); $body = null; - if (isset($data['_content'])) { + if (is_array($data) && isset($data['_content'])) { $body = $data['_content']; unset($data['_content']); } @@ -229,7 +305,7 @@ public function get($url, $data = [], array $options = []) Request::METHOD_GET, $url, $body, - $options + $options, ); } @@ -238,10 +314,10 @@ public function get($url, $data = [], array $options = []) * * @param string $url The url or path you want to request. * @param mixed $data The post data you want to send. - * @param array $options Additional options for the request. + * @param array $options Additional options for the request. * @return \Cake\Http\Client\Response */ - public function post($url, $data = [], array $options = []) + public function post(string $url, mixed $data = [], array $options = []): Response { $options = $this->_mergeOptions($options); $url = $this->buildUrl($url, [], $options); @@ -254,10 +330,10 @@ public function post($url, $data = [], array $options = []) * * @param string $url The url or path you want to request. * @param mixed $data The request data you want to send. - * @param array $options Additional options for the request. + * @param array $options Additional options for the request. * @return \Cake\Http\Client\Response */ - public function put($url, $data = [], array $options = []) + public function put(string $url, mixed $data = [], array $options = []): Response { $options = $this->_mergeOptions($options); $url = $this->buildUrl($url, [], $options); @@ -270,10 +346,10 @@ public function put($url, $data = [], array $options = []) * * @param string $url The url or path you want to request. * @param mixed $data The request data you want to send. - * @param array $options Additional options for the request. + * @param array $options Additional options for the request. * @return \Cake\Http\Client\Response */ - public function patch($url, $data = [], array $options = []) + public function patch(string $url, mixed $data = [], array $options = []): Response { $options = $this->_mergeOptions($options); $url = $this->buildUrl($url, [], $options); @@ -286,10 +362,10 @@ public function patch($url, $data = [], array $options = []) * * @param string $url The url or path you want to request. * @param mixed $data The request data you want to send. - * @param array $options Additional options for the request. + * @param array $options Additional options for the request. * @return \Cake\Http\Client\Response */ - public function options($url, $data = [], array $options = []) + public function options(string $url, mixed $data = [], array $options = []): Response { $options = $this->_mergeOptions($options); $url = $this->buildUrl($url, [], $options); @@ -302,10 +378,10 @@ public function options($url, $data = [], array $options = []) * * @param string $url The url or path you want to request. * @param mixed $data The request data you want to send. - * @param array $options Additional options for the request. + * @param array $options Additional options for the request. * @return \Cake\Http\Client\Response */ - public function trace($url, $data = [], array $options = []) + public function trace(string $url, mixed $data = [], array $options = []): Response { $options = $this->_mergeOptions($options); $url = $this->buildUrl($url, [], $options); @@ -318,10 +394,10 @@ public function trace($url, $data = [], array $options = []) * * @param string $url The url or path you want to request. * @param mixed $data The request data you want to send. - * @param array $options Additional options for the request. + * @param array $options Additional options for the request. * @return \Cake\Http\Client\Response */ - public function delete($url, $data = [], array $options = []) + public function delete(string $url, mixed $data = [], array $options = []): Response { $options = $this->_mergeOptions($options); $url = $this->buildUrl($url, [], $options); @@ -334,10 +410,10 @@ public function delete($url, $data = [], array $options = []) * * @param string $url The url or path you want to request. * @param array $data The query string data you want to send. - * @param array $options Additional options for the request. + * @param array $options Additional options for the request. * @return \Cake\Http\Client\Response */ - public function head($url, array $data = [], array $options = []) + public function head(string $url, array $data = [], array $options = []): Response { $options = $this->_mergeOptions($options); $url = $this->buildUrl($url, $data, $options); @@ -351,16 +427,16 @@ public function head($url, array $data = [], array $options = []) * @param string $method HTTP method. * @param string $url URL to request. * @param mixed $data The request body. - * @param array $options The options to use. Contains auth, proxy, etc. + * @param array $options The options to use. Contains auth, proxy, etc. * @return \Cake\Http\Client\Response */ - protected function _doRequest($method, $url, $data, $options) + protected function _doRequest(string $method, string $url, mixed $data, array $options): Response { $request = $this->_createRequest( $method, $url, $data, - $options + $options, ); return $this->send($request, $options); @@ -369,25 +445,37 @@ protected function _doRequest($method, $url, $data, $options) /** * Does a recursive merge of the parameter with the scope config. * - * @param array $options Options to merge. + * @param array $options Options to merge. * @return array Options merged with set config. */ - protected function _mergeOptions($options) + protected function _mergeOptions(array $options): array { return Hash::merge($this->_config, $options); } + /** + * Sends a PSR-7 request and returns a PSR-7 response. + * + * @param \Psr\Http\Message\RequestInterface $request Request instance. + * @return \Psr\Http\Message\ResponseInterface Response instance. + * @throws \Psr\Http\Client\ClientExceptionInterface If an error happens while processing the request. + */ + public function sendRequest(RequestInterface $request): ResponseInterface + { + return $this->send($request, $this->_config); + } + /** * Send a request. * * Used internally by other methods, but can also be used to send * handcrafted Request objects. * - * @param \Cake\Http\Client\Request $request The request to send. - * @param array $options Additional options to use. + * @param \Psr\Http\Message\RequestInterface $request The request to send. + * @param array $options Additional options to use. * @return \Cake\Http\Client\Response */ - public function send(Request $request, $options = []) + public function send(RequestInterface $request, array $options = []): Response { $redirects = 0; if (isset($options['redirect'])) { @@ -396,43 +484,123 @@ public function send(Request $request, $options = []) } do { - $response = $this->_sendRequest($request, $options); + /** @var \Cake\Http\Client\ClientEvent $event */ + $event = $this->dispatchEvent( + 'HttpClient.beforeSend', + ['request' => $request, 'adapterOptions' => $options, 'redirects' => $redirects], + ); + + $request = $event->getRequest(); + $response = $event->getResult(); + $requestSent = false; + if ($response === null) { + $requestSent = true; + $response = $this->_sendRequest($request, $event->getAdapterOptions()); + } + + /** @var \Cake\Http\Client\ClientEvent $event */ + $event = $this->dispatchEvent( + 'HttpClient.afterSend', + [ + 'request' => $request, + 'adapterOptions' => $options, + 'redirects' => $redirects, + 'requestSent' => $requestSent, + 'response' => $response, + ], + ); + $response = $event->getResult(); + assert($response instanceof Response); $handleRedirect = $response->isRedirect() && $redirects-- > 0; if ($handleRedirect) { $url = $request->getUri(); - $request = $this->_cookies->addToRequest($request, []); $location = $response->getHeaderLine('Location'); $locationUrl = $this->buildUrl($location, [], [ 'host' => $url->getHost(), 'port' => $url->getPort(), 'scheme' => $url->getScheme(), - 'protocolRelative' => true + 'protocolRelative' => true, ]); - $request = $request->withUri(new Uri($locationUrl)); + $request = $this->_cookies->addToRequest($request, []); } } while ($handleRedirect); return $response; } + /** + * Clear all mocked responses + * + * @return void + */ + public static function clearMockResponses(): void + { + static::$_mockAdapter = null; + } + + /** + * Add a mocked response. + * + * Mocked responses are stored in an adapter that is called + * _before_ the network adapter is called. + * + * ### Matching Requests + * + * Request matching is done on the HTTP method and URL. If the URL is + * an exact match, the response will be returned. You can use `*` as + * a wildcard to match any suffix: + * + * ``` + * // Match any URL starting with https://example.com/api/ + * Client::addMockResponse('GET', 'https://example.com/api/*', $response); + * ``` + * + * For more complex matching, use the `match` option with a closure + * that receives the request and returns a boolean. + * + * ### Options + * + * - `match` An additional closure to match requests with. + * + * @param string $method The HTTP method being mocked. + * @param string $url The URL being matched. See above for examples. + * @param \Cake\Http\Client\Response $response The response that matches the request. + * @param array $options See above. + * @return void + */ + public static function addMockResponse(string $method, string $url, Response $response, array $options = []): void + { + if (!static::$_mockAdapter) { + static::$_mockAdapter = new MockAdapter(); + } + $request = new Request($url, $method); + static::$_mockAdapter->addResponse($request, $response, $options); + } + /** * Send a request without redirection. * - * @param \Cake\Http\Client\Request $request The request to send. - * @param array $options Additional options to use. + * @param \Psr\Http\Message\RequestInterface $request The request to send. + * @param array $options Additional options to use. * @return \Cake\Http\Client\Response */ - protected function _sendRequest(Request $request, $options) + protected function _sendRequest(RequestInterface $request, array $options): Response { - $responses = $this->_adapter->send($request, $options); - $url = $request->getUri(); + $responses = []; + if (static::$_mockAdapter) { + $responses = static::$_mockAdapter->send($request, $options); + } + if (!$responses) { + $responses = $this->_adapter->send($request, $options); + } foreach ($responses as $response) { $this->_cookies = $this->_cookies->addFromResponse($response, $request); } + /** @var \Cake\Http\Client\Response */ return array_pop($responses); } @@ -440,29 +608,31 @@ protected function _sendRequest(Request $request, $options) * Generate a URL based on the scoped client options. * * @param string $url Either a full URL or just the path. - * @param string|array $query The query data for the URL. - * @param array $options The config options stored with Client::config() + * @param array|string $query The query data for the URL. + * @param array $options The config options stored with Client::config() * @return string A complete url with scheme, port, host, and path. */ - public function buildUrl($url, $query = [], $options = []) + public function buildUrl(string $url, array|string $query = [], array $options = []): string { - if (empty($options) && empty($query)) { + if (!$options && !$query) { return $url; } - if ($query) { - $q = (strpos($url, '?') === false) ? '?' : '&'; - $url .= $q; - $url .= is_string($query) ? $query : http_build_query($query); - } $defaults = [ 'host' => null, 'port' => null, 'scheme' => 'http', - 'protocolRelative' => false + 'basePath' => '', + 'protocolRelative' => false, ]; $options += $defaults; - if ($options['protocolRelative'] && preg_match('#^//#', $url)) { + if ($query) { + $q = str_contains($url, '?') ? '&' : '?'; + $url .= $q; + $url .= is_string($query) ? $query : http_build_query($query, '', '&', PHP_QUERY_RFC3986); + } + + if ($options['protocolRelative'] && str_starts_with($url, '//')) { $url = $options['scheme'] . ':' . $url; } if (preg_match('#^https?://#', $url)) { @@ -471,12 +641,15 @@ public function buildUrl($url, $query = [], $options = []) $defaultPorts = [ 'http' => 80, - 'https' => 443 + 'https' => 443, ]; $out = $options['scheme'] . '://' . $options['host']; - if ($options['port'] && $options['port'] != $defaultPorts[$options['scheme']]) { + if ($options['port'] && (int)$options['port'] !== $defaultPorts[$options['scheme']]) { $out .= ':' . $options['port']; } + if (!empty($options['basePath'])) { + $out .= '/' . trim($options['basePath'], '/'); + } $out .= '/' . ltrim($url, '/'); return $out; @@ -488,12 +661,13 @@ public function buildUrl($url, $query = [], $options = []) * @param string $method HTTP method name. * @param string $url The url including query string. * @param mixed $data The request body. - * @param array $options The options to use. Contains auth, proxy, etc. + * @param array $options The options to use. Contains auth, proxy, etc. * @return \Cake\Http\Client\Request */ - protected function _createRequest($method, $url, $data, $options) + protected function _createRequest(string $method, string $url, mixed $data, array $options): Request { - $headers = isset($options['headers']) ? (array)$options['headers'] : []; + /** @var array $headers */ + $headers = (array)($options['headers'] ?? []); if (isset($options['type'])) { $headers = array_merge($headers, $this->_typeHeaders($options['type'])); } @@ -502,14 +676,15 @@ protected function _createRequest($method, $url, $data, $options) } $request = new Request($url, $method, $headers, $data); - $cookies = isset($options['cookies']) ? $options['cookies'] : []; + $request = $request->withProtocolVersion($this->getConfig('protocolVersion')); + $cookies = $options['cookies'] ?? []; /** @var \Cake\Http\Client\Request $request */ $request = $this->_cookies->addToRequest($request, $cookies); if (isset($options['auth'])) { $request = $this->_addAuthentication($request, $options); } if (isset($options['proxy'])) { - $request = $this->_addProxy($request, $options); + return $this->_addProxy($request, $options); } return $request; @@ -520,15 +695,15 @@ protected function _createRequest($method, $url, $data, $options) * or full mime-type. * * @param string $type short type alias or full mimetype. - * @return array Headers to set on the request. - * @throws \Cake\Core\Exception\Exception When an unknown type alias is used. + * @return array{'Accept': non-empty-string, 'Content-Type': non-empty-string} Headers to set on the request. + * @throws \Cake\Core\Exception\CakeException When an unknown type alias is used. */ - protected function _typeHeaders($type) + protected function _typeHeaders(string $type): array { - if (strpos($type, '/') !== false) { + if (str_contains($type, '/')) { return [ 'Accept' => $type, - 'Content-Type' => $type + 'Content-Type' => $type, ]; } $typeMap = [ @@ -536,7 +711,10 @@ protected function _typeHeaders($type) 'xml' => 'application/xml', ]; if (!isset($typeMap[$type])) { - throw new Exception("Unknown type alias '$type'."); + throw new CakeException(sprintf( + 'Unknown type alias `%s`.', + $type, + )); } return [ @@ -552,16 +730,16 @@ protected function _typeHeaders($type) * and use its methods to add headers. * * @param \Cake\Http\Client\Request $request The request to modify. - * @param array $options Array of options containing the 'auth' key. + * @param array $options Array of options containing the 'auth' key. * @return \Cake\Http\Client\Request The updated request object. */ - protected function _addAuthentication(Request $request, $options) + protected function _addAuthentication(Request $request, array $options): Request { $auth = $options['auth']; + /** @var \Cake\Http\Client\Auth\Basic $adapter */ $adapter = $this->_createAuth($auth, $options); - $result = $adapter->authentication($request, $options['auth']); - return $result ?: $request; + return $adapter->authentication($request, $options['auth']); } /** @@ -571,16 +749,16 @@ protected function _addAuthentication(Request $request, $options) * and use its methods to add headers. * * @param \Cake\Http\Client\Request $request The request to modify. - * @param array $options Array of options containing the 'proxy' key. + * @param array $options Array of options containing the 'proxy' key. * @return \Cake\Http\Client\Request The updated request object. */ - protected function _addProxy(Request $request, $options) + protected function _addProxy(Request $request, array $options): Request { $auth = $options['proxy']; + /** @var \Cake\Http\Client\Auth\Basic $adapter */ $adapter = $this->_createAuth($auth, $options); - $result = $adapter->proxyAuthentication($request, $options['proxy']); - return $result ?: $request; + return $adapter->proxyAuthentication($request, $options['proxy']); } /** @@ -590,11 +768,11 @@ protected function _addProxy(Request $request, $options) * authentication strategy handler. * * @param array $auth The authentication options to use. - * @param array $options The overall request options to use. - * @return mixed Authentication strategy instance. - * @throws \Cake\Core\Exception\Exception when an invalid strategy is chosen. + * @param array $options The overall request options to use. + * @return object Authentication strategy instance. + * @throws \Cake\Core\Exception\CakeException when an invalid strategy is chosen. */ - protected function _createAuth($auth, $options) + protected function _createAuth(array $auth, array $options): object { if (empty($auth['type'])) { $auth['type'] = 'basic'; @@ -602,13 +780,11 @@ protected function _createAuth($auth, $options) $name = ucfirst($auth['type']); $class = App::className($name, 'Http/Client/Auth'); if (!$class) { - throw new Exception( - sprintf('Invalid authentication type %s', $name) + throw new CakeException( + sprintf('Invalid authentication type `%s`.', $name), ); } return new $class($this, $options); } } -// @deprecated Backwards compatibility with earler 3.x versions. -class_alias('Cake\Http\Client', 'Cake\Network\Http\Client'); diff --git a/src/Http/Client/Adapter/Curl.php b/src/Http/Client/Adapter/Curl.php new file mode 100644 index 00000000000..2083f301fb4 --- /dev/null +++ b/src/Http/Client/Adapter/Curl.php @@ -0,0 +1,213 @@ +buildOptions($request, $options); + curl_setopt_array($ch, $options); + + $body = $this->exec($ch); + assert($body !== true); + if ($body === false) { + $errorCode = curl_errno($ch); + $error = curl_error($ch); + + $message = "cURL Error ({$errorCode}) {$error}"; + $errorNumbers = [ + CURLE_FAILED_INIT, + CURLE_URL_MALFORMAT, + CURLE_URL_MALFORMAT_USER, + ]; + if (in_array($errorCode, $errorNumbers, true)) { + throw new RequestException($message, $request); + } + throw new NetworkException($message, $request); + } + + return $this->createResponse($ch, $body); + } + + /** + * Convert client options into curl options. + * + * @param \Psr\Http\Message\RequestInterface $request The request. + * @param array $options The client options + * @return array + */ + public function buildOptions(RequestInterface $request, array $options): array + { + $headers = []; + foreach ($request->getHeaders() as $key => $values) { + $headers[] = $key . ': ' . implode(', ', $values); + } + + $out = [ + CURLOPT_URL => (string)$request->getUri(), + CURLOPT_HTTP_VERSION => $this->getProtocolVersion($request), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => $headers, + ]; + switch ($request->getMethod()) { + case Request::METHOD_GET: + $out[CURLOPT_HTTPGET] = true; + break; + + case Request::METHOD_POST: + $out[CURLOPT_POST] = true; + break; + + case Request::METHOD_HEAD: + $out[CURLOPT_NOBODY] = true; + break; + + default: + $out[CURLOPT_POST] = true; + $out[CURLOPT_CUSTOMREQUEST] = $request->getMethod(); + break; + } + + $body = $request->getBody(); + $body->rewind(); + $out[CURLOPT_POSTFIELDS] = $body->getContents(); + // GET requests with bodies require custom request to be used. + if ($out[CURLOPT_POSTFIELDS] !== '' && isset($out[CURLOPT_HTTPGET])) { + $out[CURLOPT_CUSTOMREQUEST] = 'GET'; + } + if ($out[CURLOPT_POSTFIELDS] === '') { + unset($out[CURLOPT_POSTFIELDS]); + } + + if (empty($options['ssl_cafile'])) { + $options['ssl_cafile'] = ini_get('curl.cainfo') ?: CaBundle::getBundledCaBundlePath(); + } + if (!empty($options['ssl_verify_host'])) { + // Value of 1 or true is deprecated. Only 2 or 0 should be used now. + $options['ssl_verify_host'] = 2; + } + $optionMap = [ + 'timeout' => CURLOPT_TIMEOUT, + 'ssl_verify_peer' => CURLOPT_SSL_VERIFYPEER, + 'ssl_verify_host' => CURLOPT_SSL_VERIFYHOST, + 'ssl_cafile' => CURLOPT_CAINFO, + 'ssl_local_cert' => CURLOPT_SSLCERT, + 'ssl_passphrase' => CURLOPT_SSLCERTPASSWD, + ]; + foreach ($optionMap as $option => $curlOpt) { + if (isset($options[$option])) { + $out[$curlOpt] = $options[$option]; + } + } + if (isset($options['proxy']['proxy'])) { + $out[CURLOPT_PROXY] = $options['proxy']['proxy']; + } + if (isset($options['proxy']['username'])) { + $password = !empty($options['proxy']['password']) ? $options['proxy']['password'] : ''; + $out[CURLOPT_PROXYUSERPWD] = $options['proxy']['username'] . ':' . $password; + } + if (isset($options['curl']) && is_array($options['curl'])) { + // Can't use array_merge() because keys will be re-ordered. + foreach ($options['curl'] as $key => $value) { + $out[$key] = $value; + } + } + + return $out; + } + + /** + * Convert HTTP version number into curl value. + * + * @param \Psr\Http\Message\RequestInterface $request The request to get a protocol version for. + * @return int + */ + protected function getProtocolVersion(RequestInterface $request): int + { + return match ($request->getProtocolVersion()) { + '1.0' => CURL_HTTP_VERSION_1_0, + '1.1' => CURL_HTTP_VERSION_1_1, + '2', '2.0' => defined('CURL_HTTP_VERSION_2TLS') + ? CURL_HTTP_VERSION_2TLS + : (defined('CURL_HTTP_VERSION_2_0') + ? CURL_HTTP_VERSION_2_0 + : throw new HttpException('libcurl 7.33 or greater required for HTTP/2 support') + ), + default => CURL_HTTP_VERSION_NONE, + }; + } + + /** + * Convert the raw curl response into an Http\Client\Response + * + * @param \CurlHandle $handle Curl handle + * @param string $responseData string The response data from curl_exec + * @return array<\Cake\Http\Client\Response> + */ + protected function createResponse(CurlHandle $handle, string $responseData): array + { + $headerSize = curl_getinfo($handle, CURLINFO_HEADER_SIZE); + $headers = trim(substr($responseData, 0, $headerSize)); + $body = substr($responseData, $headerSize); + $response = new Response(explode("\r\n", $headers), $body); + + return [$response]; + } + + /** + * Execute the curl handle. + * + * @param \CurlHandle $ch Curl Resource handle + * @return string|bool + */ + protected function exec(CurlHandle $ch): string|bool + { + return curl_exec($ch); + } +} diff --git a/src/Http/Client/Adapter/Mock.php b/src/Http/Client/Adapter/Mock.php new file mode 100644 index 00000000000..5bb55a14b46 --- /dev/null +++ b/src/Http/Client/Adapter/Mock.php @@ -0,0 +1,140 @@ + $options See above. + * @return void + */ + public function addResponse(RequestInterface $request, Response $response, array $options): void + { + if (isset($options['match']) && !($options['match'] instanceof Closure)) { + $type = get_debug_type($options['match']); + throw new InvalidArgumentException(sprintf( + 'The `match` option must be a `Closure`. Got `%s`.', + $type, + )); + } + $this->responses[] = [ + 'request' => $request, + 'response' => $response, + 'options' => $options, + ]; + } + + /** + * Find a response if one exists. + * + * @param \Psr\Http\Message\RequestInterface $request The request to match + * @param array $options The options are passed to match callbacks. + * @return array<\Cake\Http\Client\Response> The matched response. + * @throws \Cake\Http\Client\Exception\MissingResponseException When no mock response matches. + */ + public function send(RequestInterface $request, array $options): array + { + $found = null; + $method = $request->getMethod(); + $requestUri = (string)$request->getUri(); + + foreach ($this->responses as $index => $mock) { + /** @var \Psr\Http\Message\RequestInterface $mockRequest */ + $mockRequest = $mock['request']; + if ($method !== $mockRequest->getMethod()) { + continue; + } + if (!$this->urlMatches($requestUri, $mockRequest)) { + continue; + } + if (isset($mock['options']['match'])) { + $match = $mock['options']['match']($request, $options); + if (!is_bool($match)) { + throw new InvalidArgumentException('Match callback must return a boolean value.'); + } + if (!$match) { + continue; + } + } + $found = $index; + break; + } + if ($found !== null) { + // Move the current mock to the end so that when there are multiple + // matches for a URL the next match is used on subsequent requests. + $mock = $this->responses[$found]; + unset($this->responses[$found]); + $this->responses[] = $mock; + + return [$mock['response']]; + } + + throw new MissingResponseException(['method' => $method, 'url' => $requestUri]); + } + + /** + * Check if the request URI matches the mock URI. + * + * @param string $requestUri The request being sent. + * @param \Psr\Http\Message\RequestInterface $mock The request being mocked. + * @return bool + */ + protected function urlMatches(string $requestUri, RequestInterface $mock): bool + { + $mockUri = (string)$mock->getUri(); + if ($requestUri === $mockUri) { + return true; + } + $starPosition = strrpos($mockUri, '/%2A'); + if ($starPosition === strlen($mockUri) - 4) { + $mockUri = substr($mockUri, 0, $starPosition); + + return str_starts_with($requestUri, $mockUri); + } + + return false; + } +} diff --git a/src/Http/Client/Adapter/Stream.php b/src/Http/Client/Adapter/Stream.php index 10f7c2b4088..ad1d28c55c7 100644 --- a/src/Http/Client/Adapter/Stream.php +++ b/src/Http/Client/Adapter/Stream.php @@ -1,4 +1,6 @@ */ - protected $_contextOptions; + protected array $_contextOptions = []; /** * Array of options/content for the SSL stream context. * - * @var array + * @var array */ - protected $_sslContextOptions; + protected array $_sslContextOptions = []; /** * The stream resource. @@ -60,16 +64,12 @@ class Stream * * @var array */ - protected $_connectionErrors = []; + protected array $_connectionErrors = []; /** - * Send a request and get a response back. - * - * @param \Cake\Http\Client\Request $request The request object to send. - * @param array $options Array of options for the stream. - * @return array Array of populated Response objects + * @inheritDoc */ - public function send(Request $request, array $options) + public function send(RequestInterface $request, array $options): array { $this->_stream = null; $this->_context = null; @@ -88,13 +88,14 @@ public function send(Request $request, array $options) * Creates one or many response objects based on the number * of redirects that occurred. * - * @param array $headers The list of headers from the request(s) + * @param list $headers The list of headers from the request(s) * @param string $content The response content. - * @return \Cake\Http\Client\Response[] The list of responses from the request(s) + * @return array<\Cake\Http\Client\Response> The list of responses from the request(s) */ - public function createResponses($headers, $content) + public function createResponses(array $headers, string $content): array { - $indexes = $responses = []; + $indexes = []; + $responses = []; foreach ($headers as $i => $header) { if (strtoupper(substr($header, 0, 5)) === 'HTTP/') { $indexes[] = $i; @@ -104,7 +105,7 @@ public function createResponses($headers, $content) foreach ($indexes as $i => $start) { $end = isset($indexes[$i + 1]) ? $indexes[$i + 1] - $start : null; $headerSlice = array_slice($headers, $start, $end); - $body = $i == $last ? $content : ''; + $body = $i === $last ? $content : ''; $responses[] = $this->_buildResponse($headerSlice, $body); } @@ -114,18 +115,18 @@ public function createResponses($headers, $content) /** * Build the stream context out of the request object. * - * @param \Cake\Http\Client\Request $request The request to build context from. - * @param array $options Additional request options. + * @param \Psr\Http\Message\RequestInterface $request The request to build context from. + * @param array $options Additional request options. * @return void */ - protected function _buildContext(Request $request, $options) + protected function _buildContext(RequestInterface $request, array $options): void { $this->_buildContent($request, $options); $this->_buildHeaders($request, $options); $this->_buildOptions($request, $options); $url = $request->getUri(); - $scheme = parse_url($url, PHP_URL_SCHEME); + $scheme = parse_url((string)$url, PHP_URL_SCHEME); if ($scheme === 'https') { $this->_buildSslContext($request, $options); } @@ -140,11 +141,11 @@ protected function _buildContext(Request $request, $options) * * Creates cookies & headers. * - * @param \Cake\Http\Client\Request $request The request being sent. - * @param array $options Array of options to use. + * @param \Psr\Http\Message\RequestInterface $request The request being sent. + * @param array $options Array of options to use. * @return void */ - protected function _buildHeaders(Request $request, $options) + protected function _buildHeaders(RequestInterface $request, array $options): void { $headers = []; foreach ($request->getHeaders() as $name => $values) { @@ -157,20 +158,15 @@ protected function _buildHeaders(Request $request, $options) * Builds the request content based on the request object. * * If the $request->body() is a string, it will be used as is. - * Array data will be processed with Cake\Http\Client\FormData + * Array data will be processed with {@link \Cake\Http\Client\FormData} * - * @param \Cake\Http\Client\Request $request The request being sent. - * @param array $options Array of options to use. + * @param \Psr\Http\Message\RequestInterface $request The request being sent. + * @param array $options Array of options to use. * @return void */ - protected function _buildContent(Request $request, $options) + protected function _buildContent(RequestInterface $request, array $options): void { $body = $request->getBody(); - if (empty($body)) { - $this->_contextOptions['content'] = ''; - - return; - } $body->rewind(); $this->_contextOptions['content'] = $body->getContents(); } @@ -178,14 +174,14 @@ protected function _buildContent(Request $request, $options) /** * Build miscellaneous options for the request. * - * @param \Cake\Http\Client\Request $request The request being sent. - * @param array $options Array of options to use. + * @param \Psr\Http\Message\RequestInterface $request The request being sent. + * @param array $options Array of options to use. * @return void */ - protected function _buildOptions(Request $request, $options) + protected function _buildOptions(RequestInterface $request, array $options): void { - $this->_contextOptions['method'] = $request->method(); - $this->_contextOptions['protocol_version'] = $request->version(); + $this->_contextOptions['method'] = $request->getMethod(); + $this->_contextOptions['protocol_version'] = $request->getProtocolVersion(); $this->_contextOptions['ignore_errors'] = true; if (isset($options['timeout'])) { @@ -203,11 +199,11 @@ protected function _buildOptions(Request $request, $options) /** * Build SSL options for the request. * - * @param \Cake\Http\Client\Request $request The request being sent. - * @param array $options Array of options to use. + * @param \Psr\Http\Message\RequestInterface $request The request being sent. + * @param array $options Array of options to use. * @return void */ - protected function _buildSslContext(Request $request, $options) + protected function _buildSslContext(RequestInterface $request, array $options): void { $sslOptions = [ 'ssl_verify_peer', @@ -216,14 +212,15 @@ protected function _buildSslContext(Request $request, $options) 'ssl_allow_self_signed', 'ssl_cafile', 'ssl_local_cert', + 'ssl_local_pk', 'ssl_passphrase', ]; if (empty($options['ssl_cafile'])) { - $options['ssl_cafile'] = CORE_PATH . 'config' . DIRECTORY_SEPARATOR . 'cacert.pem'; + $options['ssl_cafile'] = CaBundle::getBundledCaBundlePath(); } if (!empty($options['ssl_verify_host'])) { $url = $request->getUri(); - $host = parse_url($url, PHP_URL_HOST); + $host = parse_url((string)$url, PHP_URL_HOST); $this->_sslContextOptions['peer_name'] = $host; } foreach ($sslOptions as $key) { @@ -237,22 +234,25 @@ protected function _buildSslContext(Request $request, $options) /** * Open the stream and send the request. * - * @param \Cake\Http\Client\Request $request The request object. + * @param \Psr\Http\Message\RequestInterface $request The request object. * @return array Array of populated Response objects - * @throws \Cake\Network\Exception\HttpException + * @throws \Psr\Http\Client\NetworkExceptionInterface */ - protected function _send(Request $request) + protected function _send(RequestInterface $request): array { $deadline = false; if (isset($this->_contextOptions['timeout']) && $this->_contextOptions['timeout'] > 0) { + /** @var int $deadline */ $deadline = time() + $this->_contextOptions['timeout']; } $url = $request->getUri(); - $this->_open($url); + $this->_open((string)$url, $request); $content = ''; $timedOut = false; + assert($this->_stream !== null, 'HTTP stream failed to open'); + while (!feof($this->_stream)) { if ($deadline !== false) { stream_set_timeout($this->_stream, max($deadline - time(), 1)); @@ -266,11 +266,12 @@ protected function _send(Request $request) break; } } + $meta = stream_get_meta_data($this->_stream); fclose($this->_stream); if ($timedOut) { - throw new HttpException('Connection timed out ' . $url, 504); + throw new NetworkException('Connection timed out ' . $url, $request); } $headers = $meta['wrapper_data']; @@ -284,12 +285,11 @@ protected function _send(Request $request) /** * Build a response object * - * @param array $headers Unparsed headers. + * @param array $headers Unparsed headers. * @param string $body The response body. - * * @return \Cake\Http\Client\Response */ - protected function _buildResponse($headers, $body) + protected function _buildResponse(array $headers, string $body): Response { return new Response($headers, $body); } @@ -298,19 +298,33 @@ protected function _buildResponse($headers, $body) * Open the socket and handle any connection errors. * * @param string $url The url to connect to. + * @param \Psr\Http\Message\RequestInterface $request The request object. * @return void - * @throws \Cake\Core\Exception\Exception + * @throws \Psr\Http\Client\RequestExceptionInterface */ - protected function _open($url) + protected function _open(string $url, RequestInterface $request): void { - set_error_handler(function ($code, $message) { + if (!(bool)ini_get('allow_url_fopen')) { + throw new ClientException('The PHP directive `allow_url_fopen` must be enabled.'); + } + + set_error_handler(function ($code, $message): bool { $this->_connectionErrors[] = $message; + + return true; }); - $this->_stream = fopen($url, 'rb', false, $this->_context); - restore_error_handler(); + try { + $stream = fopen($url, 'rb', false, $this->_context); + if ($stream === false) { + $stream = null; + } + $this->_stream = $stream; + } finally { + restore_error_handler(); + } - if (!$this->_stream || !empty($this->_connectionErrors)) { - throw new Exception(implode("\n", $this->_connectionErrors)); + if (!$this->_stream || $this->_connectionErrors) { + throw new RequestException(implode("\n", $this->_connectionErrors), $request); } } @@ -319,13 +333,10 @@ protected function _open($url) * * Useful for debugging and testing context creation. * - * @return array + * @return array */ - public function contextOptions() + public function contextOptions(): array { return array_merge($this->_contextOptions, $this->_sslContextOptions); } } - -// @deprecated Add backwards compat alias. -class_alias('Cake\Http\Client\Adapter\Stream', 'Cake\Network\Http\Adapter\Stream'); diff --git a/src/Http/Client/AdapterInterface.php b/src/Http/Client/AdapterInterface.php new file mode 100644 index 00000000000..8c6de39c01b --- /dev/null +++ b/src/Http/Client/AdapterInterface.php @@ -0,0 +1,33 @@ + $options Array of options for the stream. + * @return array<\Cake\Http\Client\Response> Array of populated Response objects + */ + public function send(RequestInterface $request, array $options): array; +} diff --git a/src/Http/Client/Auth/Basic.php b/src/Http/Client/Auth/Basic.php index 6debf1bcd3b..71521e32aa3 100644 --- a/src/Http/Client/Auth/Basic.php +++ b/src/Http/Client/Auth/Basic.php @@ -1,4 +1,6 @@ _generateHeader($credentials['username'], $credentials['password']); @@ -50,7 +52,7 @@ public function authentication(Request $request, array $credentials) * @return \Cake\Http\Client\Request The updated request. * @see https://www.ietf.org/rfc/rfc2617.txt */ - public function proxyAuthentication(Request $request, array $credentials) + public function proxyAuthentication(Request $request, #[SensitiveParameter] array $credentials): Request { if (isset($credentials['username'], $credentials['password'])) { $value = $this->_generateHeader($credentials['username'], $credentials['password']); @@ -67,11 +69,8 @@ public function proxyAuthentication(Request $request, array $credentials) * @param string $pass Password. * @return string */ - protected function _generateHeader($user, $pass) + protected function _generateHeader(string $user, #[SensitiveParameter] string $pass): string { return 'Basic ' . base64_encode($user . ':' . $pass); } } - -// @deprecated Add backwards compat alias. -class_alias('Cake\Http\Client\Auth\Basic', 'Cake\Network\Http\Auth\Basic'); diff --git a/src/Http/Client/Auth/Digest.php b/src/Http/Client/Auth/Digest.php index c639818ddae..684cb98aa01 100644 --- a/src/Http/Client/Auth/Digest.php +++ b/src/Http/Client/Auth/Digest.php @@ -1,4 +1,6 @@ Hash type + */ + public const HASH_ALGORITHMS = [ + self::ALGO_MD5 => 'md5', + self::ALGO_SHA_256 => 'sha256', + self::ALGO_SHA_512_256 => 'sha512/256', + self::ALGO_MD5_SESS => 'md5', + self::ALGO_SHA_256_SESS => 'sha256', + self::ALGO_SHA_512_256_SESS => 'sha512/256', + ]; /** * Instance of Cake\Http\Client * * @var \Cake\Http\Client */ - protected $_client; + protected Client $_client; + + /** + * Algorithm + * + * @var string + */ + protected string $algorithm; + + /** + * Hash type + * + * @var string + */ + protected string $hashType; + + /** + * Is Sess algorithm + * + * @var bool + */ + protected bool $isSessAlgorithm = false; /** * Constructor * + * Deprecated: $options list is unused and will be removed in 6.0. + * * @param \Cake\Http\Client $client Http client object. * @param array|null $options Options list. */ - public function __construct(Client $client, $options = null) + public function __construct(Client $client, ?array $options = null) { $this->_client = $client; } + /** + * Set algorithm based on credentials + * + * @param array $credentials authentication params + * @return void + */ + protected function setAlgorithm(array $credentials): void + { + $algorithm = $credentials['algorithm'] ?? self::ALGO_MD5; + if (!isset(self::HASH_ALGORITHMS[$algorithm])) { + throw new InvalidArgumentException('Invalid Algorithm. Valid ones are: ' . + implode(',', array_keys(self::HASH_ALGORITHMS))); + } + $this->algorithm = $algorithm; + $this->isSessAlgorithm = str_contains($this->algorithm, '-sess'); + $this->hashType = Hash::get(self::HASH_ALGORITHMS, $this->algorithm); + } + /** * Add Authorization header to the request. * * @param \Cake\Http\Client\Request $request The request object. - * @param array $credentials Authentication credentials. + * @param array $credentials Authentication credentials. * @return \Cake\Http\Client\Request The updated request. * @see https://www.ietf.org/rfc/rfc2617.txt */ - public function authentication(Request $request, array $credentials) + public function authentication(Request $request, #[SensitiveParameter] array $credentials): Request { if (!isset($credentials['username'], $credentials['password'])) { return $request; @@ -62,6 +135,8 @@ public function authentication(Request $request, array $credentials) if (!isset($credentials['realm'])) { return $request; } + + $this->setAlgorithm($credentials); $value = $this->_generateHeader($request, $credentials); return $request->withHeader('Authorization', $value); @@ -78,53 +153,79 @@ public function authentication(Request $request, array $credentials) * @param array $credentials Authentication credentials. * @return array modified credentials. */ - protected function _getServerInfo(Request $request, $credentials) + protected function _getServerInfo(Request $request, array $credentials): array { $response = $this->_client->get( - $request->getUri(), + (string)$request->getUri(), [], - ['auth' => ['type' => null]] + ['auth' => ['type' => null]], ); - if (!$response->getHeader('WWW-Authenticate')) { + $header = $response->getHeader('WWW-Authenticate'); + if (!$header) { return []; } - preg_match_all( - '@(\w+)=(?:(?:")([^"]+)"|([^\s,$]+))@', - $response->getHeaderLine('WWW-Authenticate'), - $matches, - PREG_SET_ORDER - ); - foreach ($matches as $match) { - $credentials[$match[1]] = $match[2]; - } - if (!empty($credentials['qop']) && empty($credentials['nc'])) { + $matches = HeaderUtility::parseWwwAuthenticate($header[0]); + $credentials = array_merge($credentials, $matches); + + if (($this->isSessAlgorithm || !empty($credentials['qop'])) && empty($credentials['nc'])) { $credentials['nc'] = 1; } return $credentials; } + /** + * @return string + */ + protected function generateCnonce(): string + { + return uniqid(); + } + /** * Generate the header Authorization * * @param \Cake\Http\Client\Request $request The request object. - * @param array $credentials Authentication credentials. + * @param array $credentials Authentication credentials. * @return string */ - protected function _generateHeader(Request $request, $credentials) + protected function _generateHeader(Request $request, #[SensitiveParameter] array $credentials): string { - $path = $request->getUri()->getPath(); - $a1 = md5($credentials['username'] . ':' . $credentials['realm'] . ':' . $credentials['password']); - $a2 = md5($request->method() . ':' . $path); - $nc = null; + $path = $request->getRequestTarget(); + + if ($this->isSessAlgorithm) { + $credentials['cnonce'] = $this->generateCnonce(); + $a1 = hash($this->hashType, $credentials['username'] . ':' . + $credentials['realm'] . ':' . $credentials['password']) . ':' . + $credentials['nonce'] . ':' . $credentials['cnonce']; + } else { + $a1 = $credentials['username'] . ':' . $credentials['realm'] . ':' . $credentials['password']; + } + $ha1 = hash($this->hashType, $a1); + $a2 = $request->getMethod() . ':' . $path; + $nc = sprintf('%08x', $credentials['nc'] ?? 1); if (empty($credentials['qop'])) { - $response = md5($a1 . ':' . $credentials['nonce'] . ':' . $a2); + $ha2 = hash($this->hashType, $a2); + $response = hash($this->hashType, $ha1 . ':' . $credentials['nonce'] . ':' . $ha2); } else { - $credentials['cnonce'] = uniqid(); - $nc = sprintf('%08x', $credentials['nc']++); - $response = md5($a1 . ':' . $credentials['nonce'] . ':' . $nc . ':' . $credentials['cnonce'] . ':auth:' . $a2); + if (!in_array($credentials['qop'], [self::QOP_AUTH, self::QOP_AUTH_INT])) { + throw new InvalidArgumentException('Invalid QOP parameter. Valid types are: ' . + implode(',', [self::QOP_AUTH, self::QOP_AUTH_INT])); + } + if ($credentials['qop'] === self::QOP_AUTH_INT) { + $a2 = $request->getMethod() . ':' . $path . ':' . hash($this->hashType, (string)$request->getBody()); + } + if (empty($credentials['cnonce'])) { + $credentials['cnonce'] = $this->generateCnonce(); + } + $ha2 = hash($this->hashType, $a2); + $response = hash( + $this->hashType, + $ha1 . ':' . $credentials['nonce'] . ':' . $nc . ':' . + $credentials['cnonce'] . ':' . $credentials['qop'] . ':' . $ha2, + ); } $authHeader = 'Digest '; @@ -132,17 +233,20 @@ protected function _generateHeader(Request $request, $credentials) $authHeader .= 'realm="' . $credentials['realm'] . '", '; $authHeader .= 'nonce="' . $credentials['nonce'] . '", '; $authHeader .= 'uri="' . $path . '", '; - $authHeader .= 'response="' . $response . '"'; + $authHeader .= 'algorithm="' . $this->algorithm . '"'; + + if (!empty($credentials['qop'])) { + $authHeader .= ', qop=' . $credentials['qop']; + } + if ($this->isSessAlgorithm || !empty($credentials['qop'])) { + $authHeader .= ', nc=' . $nc . ', cnonce="' . $credentials['cnonce'] . '"'; + } + $authHeader .= ', response="' . $response . '"'; + if (!empty($credentials['opaque'])) { $authHeader .= ', opaque="' . $credentials['opaque'] . '"'; } - if (!empty($credentials['qop'])) { - $authHeader .= ', qop="auth", nc=' . $nc . ', cnonce="' . $credentials['cnonce'] . '"'; - } return $authHeader; } } - -// @deprecated Add backwards compat alias. -class_alias('Cake\Http\Client\Auth\Digest', 'Cake\Network\Http\Auth\Digest'); diff --git a/src/Http/Client/Auth/Oauth.php b/src/Http/Client/Auth/Oauth.php index 2673c382ea5..17e7d01e203 100644 --- a/src/Http/Client/Auth/Oauth.php +++ b/src/Http/Client/Auth/Oauth.php @@ -1,4 +1,6 @@ withHeader('Authorization', $value); @@ -98,7 +102,7 @@ public function authentication(Request $request, array $credentials) * @param array $credentials Authentication credentials. * @return string Authorization header. */ - protected function _plaintext($request, $credentials) + protected function _plaintext(Request $request, #[SensitiveParameter] array $credentials): string { $values = [ 'oauth_version' => '1.0', @@ -127,29 +131,33 @@ protected function _plaintext($request, $credentials) * @param array $credentials Authentication credentials. * @return string */ - protected function _hmacSha1($request, $credentials) + protected function _hmacSha1(Request $request, #[SensitiveParameter] array $credentials): string { - $nonce = isset($credentials['nonce']) ? $credentials['nonce'] : uniqid(); - $timestamp = isset($credentials['timestamp']) ? $credentials['timestamp'] : time(); + $nonce = $credentials['nonce'] ?? uniqid(); + $timestamp = $credentials['timestamp'] ?? time(); $values = [ 'oauth_version' => '1.0', 'oauth_nonce' => $nonce, 'oauth_timestamp' => $timestamp, 'oauth_signature_method' => 'HMAC-SHA1', 'oauth_token' => $credentials['token'], - 'oauth_consumer_key' => $credentials['consumerKey'], + 'oauth_consumer_key' => $this->_encode($credentials['consumerKey']), ]; $baseString = $this->baseString($request, $values); + // Consumer key should only be encoded for base string calculation as + // auth header generation already encodes independently + $values['oauth_consumer_key'] = $credentials['consumerKey']; + if (isset($credentials['realm'])) { $values['oauth_realm'] = $credentials['realm']; } $key = [$credentials['consumerSecret'], $credentials['tokenSecret']]; - $key = array_map([$this, '_encode'], $key); + $key = array_map($this->_encode(...), $key); $key = implode('&', $key); $values['oauth_signature'] = base64_encode( - hash_hmac('sha1', $baseString, $key, true) + hash_hmac('sha1', $baseString, $key, true), ); return $this->_buildAuth($values); @@ -163,17 +171,15 @@ protected function _hmacSha1($request, $credentials) * @param \Cake\Http\Client\Request $request The request object. * @param array $credentials Authentication credentials. * @return string - * - * @throws \RuntimeException */ - protected function _rsaSha1($request, $credentials) + protected function _rsaSha1(Request $request, #[SensitiveParameter] array $credentials): string { if (!function_exists('openssl_pkey_get_private')) { - throw new RuntimeException('RSA-SHA1 signature method requires the OpenSSL extension.'); + throw new CakeException('RSA-SHA1 signature method requires the OpenSSL extension.'); } - $nonce = isset($credentials['nonce']) ? $credentials['nonce'] : bin2hex(Security::randomBytes(16)); - $timestamp = isset($credentials['timestamp']) ? $credentials['timestamp'] : time(); + $nonce = $credentials['nonce'] ?? bin2hex(Security::randomBytes(16)); + $timestamp = $credentials['timestamp'] ?? time(); $values = [ 'oauth_version' => '1.0', 'oauth_nonce' => $nonce, @@ -204,7 +210,7 @@ protected function _rsaSha1($request, $credentials) } $credentials += [ - 'privateKeyPassphrase' => null, + 'privateKeyPassphrase' => '', ]; if (is_resource($credentials['privateKeyPassphrase'])) { $resource = $credentials['privateKeyPassphrase']; @@ -213,9 +219,13 @@ protected function _rsaSha1($request, $credentials) $credentials['privateKeyPassphrase'] = $passphrase; } $privateKey = openssl_pkey_get_private($credentials['privateKey'], $credentials['privateKeyPassphrase']); + $this->checkSslError(); + + assert($privateKey !== false); + $signature = ''; openssl_sign($baseString, $signature, $privateKey); - openssl_free_key($privateKey); + $this->checkSslError(); $values['oauth_signature'] = base64_encode($signature); @@ -235,14 +245,14 @@ protected function _rsaSha1($request, $credentials) * @param array $oauthValues Oauth values. * @return string */ - public function baseString($request, $oauthValues) + public function baseString(Request $request, array $oauthValues): string { $parts = [ $request->getMethod(), $this->_normalizedUrl($request->getUri()), $this->_normalizedParams($request, $oauthValues), ]; - $parts = array_map([$this, '_encode'], $parts); + $parts = array_map($this->_encode(...), $parts); return implode('&', $parts); } @@ -255,7 +265,7 @@ public function baseString($request, $oauthValues) * @param \Psr\Http\Message\UriInterface $uri Uri object to build a normalized version of. * @return string Normalized URL */ - protected function _normalizedUrl($uri) + protected function _normalizedUrl(UriInterface $uri): string { $out = $uri->getScheme() . '://'; $out .= strtolower($uri->getHost()); @@ -276,20 +286,16 @@ protected function _normalizedUrl($uri) * @param array $oauthValues Oauth values. * @return string sorted and normalized values */ - protected function _normalizedParams($request, $oauthValues) + protected function _normalizedParams(Request $request, array $oauthValues): string { - $query = parse_url($request->getUri(), PHP_URL_QUERY); - parse_str($query, $queryArgs); + $query = parse_url((string)$request->getUri(), PHP_URL_QUERY); + parse_str((string)$query, $queryArgs); $post = []; - $body = $request->body(); - if (is_string($body) && $request->getHeaderLine('content-type') === 'application/x-www-form-urlencoded') { - parse_str($body, $post); + $contentType = $request->getHeaderLine('Content-Type'); + if ($contentType === '' || $contentType === 'application/x-www-form-urlencoded') { + parse_str((string)$request->getBody(), $post); } - if (is_array($body)) { - $post = $body; - } - $args = array_merge($queryArgs, $oauthValues, $post); $pairs = $this->_normalizeData($args); $data = []; @@ -309,7 +315,7 @@ protected function _normalizedParams($request, $oauthValues) * @see https://tools.ietf.org/html/rfc5849#section-3.4.1.3.2 * @return array */ - protected function _normalizeData($args, $path = '') + protected function _normalizeData(array $args, string $path = ''): array { $data = []; foreach ($args as $key => $value) { @@ -340,12 +346,12 @@ protected function _normalizeData($args, $path = '') * @param array $data The oauth_* values to build * @return string */ - protected function _buildAuth($data) + protected function _buildAuth(array $data): string { $out = 'OAuth '; $params = []; foreach ($data as $key => $value) { - $params[] = $key . '="' . $this->_encode($value) . '"'; + $params[] = $key . '="' . $this->_encode((string)$value) . '"'; } $out .= implode(',', $params); @@ -358,15 +364,26 @@ protected function _buildAuth($data) * @param string $value Value to encode. * @return string */ - protected function _encode($value) + protected function _encode(string $value): string { - return str_replace( - '+', - ' ', - str_replace('%7E', '~', rawurlencode($value)) - ); + return str_replace(['%7E', '+'], ['~', ' '], rawurlencode($value)); } -} -// @deprecated Add backwards compat alias. -class_alias('Cake\Http\Client\Auth\Oauth', 'Cake\Network\Http\Auth\Oauth'); + /** + * Check for SSL errors and throw an exception if found. + * + * @return void + * @throws \Cake\Core\Exception\CakeException When an error is found + */ + protected function checkSslError(): void + { + $error = ''; + while ($text = openssl_error_string()) { + $error .= $text; + } + + if ($error !== '') { + throw new CakeException('openssl error: ' . $error); + } + } +} diff --git a/src/Http/Client/ClientEvent.php b/src/Http/Client/ClientEvent.php new file mode 100644 index 00000000000..741b0fed536 --- /dev/null +++ b/src/Http/Client/ClientEvent.php @@ -0,0 +1,120 @@ + + */ +class ClientEvent extends Event +{ + /** + * Constructor + * + * @param string $name Name of the event + * @param \Cake\Http\Client $subject The Http Client instance this event applies to. + * @param array $data Any value you wish to be transported + * with this event to it can be read by listeners. + */ + public function __construct(string $name, Client $subject, array $data = []) + { + if (isset($data['response'])) { + $this->result = $data['response']; + unset($data['response']); + } + + parent::__construct($name, $subject, $data); + } + + /** + * The result value of the event listeners + * + * @return \Cake\Http\Client\Response|null + */ + public function getResult(): ?Response + { + return $this->result; + } + + /** + * Listeners can attach a result value to the event. + * + * @param mixed $value The value to set. + * @return $this + */ + public function setResult(mixed $value = null) + { + if ($value !== null && !$value instanceof Response) { + throw new InvalidArgumentException( + 'The result for Http Client events must be a `Cake\Http\Client\Response` instance.', + ); + } + + return parent::setResult($value); + } + + /** + * Set request instance. + * + * @param \Psr\Http\Message\RequestInterface $request + * @return $this + */ + public function setRequest(RequestInterface $request) + { + $this->_data['request'] = $request; + + return $this; + } + + /** + * Get the request instance. + * + * @return \Psr\Http\Message\RequestInterface + */ + public function getRequest(): RequestInterface + { + return $this->_data['request']; + } + + /** + * Set the adapter options. + * + * @return $this + */ + public function setAdapterOptions(array $options = []) + { + $this->_data['adapterOptions'] = $options; + + return $this; + } + + /** + * Get the adapter options. + * + * @return array + */ + public function getAdapterOptions(): array + { + return $this->_data['adapterOptions']; + } +} diff --git a/src/Http/Client/CookieCollection.php b/src/Http/Client/CookieCollection.php deleted file mode 100644 index e10a25b1983..00000000000 --- a/src/Http/Client/CookieCollection.php +++ /dev/null @@ -1,111 +0,0 @@ -getHeader('Set-Cookie'); - $cookies = $this->parseSetCookieHeader($header); - $cookies = $this->setRequestDefaults($cookies, $host, $path); - foreach ($cookies as $cookie) { - $this->cookies[$cookie->getId()] = $cookie; - } - $this->removeExpiredCookies($host, $path); - } - - /** - * Get stored cookies for a URL. - * - * Finds matching stored cookies and returns a simple array - * of name => value - * - * @param string $url The URL to find cookies for. - * @return array - */ - public function get($url) - { - $path = parse_url($url, PHP_URL_PATH) ?: '/'; - $host = parse_url($url, PHP_URL_HOST); - $scheme = parse_url($url, PHP_URL_SCHEME); - - return $this->findMatchingCookies($scheme, $host, $path); - } - - /** - * Get all the stored cookies as arrays. - * - * @return array - */ - public function getAll() - { - $out = []; - foreach ($this->cookies as $cookie) { - $out[] = $this->convertCookieToArray($cookie); - } - - return $out; - } - - /** - * Convert the cookie into an array of its properties. - * - * Primarily useful where backwards compatibility is needed. - * - * @param \Cake\Http\Cookie\CookieInterface $cookie Cookie object. - * @return array - */ - protected function convertCookieToArray(CookieInterface $cookie) - { - return [ - 'name' => $cookie->getName(), - 'value' => $cookie->getValue(), - 'path' => $cookie->getPath(), - 'domain' => $cookie->getDomain(), - 'secure' => $cookie->isSecure(), - 'httponly' => $cookie->isHttpOnly(), - 'expires' => $cookie->getExpiresTimestamp() - ]; - } -} - -// @deprecated Add backwards compat alias. -class_alias('Cake\Http\Client\CookieCollection', 'Cake\Network\Http\CookieCollection'); diff --git a/src/Http/Client/Exception/ClientException.php b/src/Http/Client/Exception/ClientException.php new file mode 100644 index 00000000000..c3f77459ad5 --- /dev/null +++ b/src/Http/Client/Exception/ClientException.php @@ -0,0 +1,26 @@ +request = $request; + parent::__construct($message, 0, $previous); + } + + /** + * Returns the request. + * + * The request object MAY be a different object from the one passed to ClientInterface::sendRequest() + * + * @return \Psr\Http\Message\RequestInterface + */ + public function getRequest(): RequestInterface + { + return $this->request; + } +} diff --git a/src/Http/Client/Exception/RequestException.php b/src/Http/Client/Exception/RequestException.php new file mode 100644 index 00000000000..f57ae507144 --- /dev/null +++ b/src/Http/Client/Exception/RequestException.php @@ -0,0 +1,62 @@ +request = $request; + parent::__construct($message, 0, $previous); + } + + /** + * Returns the request. + * + * The request object MAY be a different object from the one passed to ClientInterface::sendRequest() + * + * @return \Psr\Http\Message\RequestInterface + */ + public function getRequest(): RequestInterface + { + return $this->request; + } +} diff --git a/src/Http/Client/FormData.php b/src/Http/Client/FormData.php index 261f5cecaf2..9b0c87aca82 100644 --- a/src/Http/Client/FormData.php +++ b/src/Http/Client/FormData.php @@ -1,4 +1,6 @@ */ - protected $_parts = []; + protected array $_parts = []; /** * Get the boundary marker * * @return string */ - public function boundary() + public function boundary(): string { if ($this->_boundary) { return $this->_boundary; } - $this->_boundary = md5(uniqid(time())); + $this->_boundary = hash('xxh128', uniqid((string)time())); return $this->_boundary; } @@ -76,7 +79,7 @@ public function boundary() * @param string $value The value to add. * @return \Cake\Http\Client\FormDataPart */ - public function newPart($name, $value) + public function newPart(string $name, string $value): FormDataPart { return new FormDataPart($name, $value); } @@ -90,29 +93,24 @@ public function newPart($name, $value) * If the $value is an array, multiple parts will be added. * Files will be read from their current position and saved in memory. * - * @param string|\Cake\Http\Client\FormData $name The name of the part to add, + * @param \Cake\Http\Client\FormDataPart|string $name The name of the part to add, * or the part data object. * @param mixed $value The value for the part. * @return $this */ - public function add($name, $value = null) + public function add(FormDataPart|string $name, mixed $value = null) { - if (is_array($value)) { - $this->addRecursive($name, $value); - } elseif (is_resource($value)) { - $this->addFile($name, $value); - } elseif (is_string($value) && strlen($value) && $value[0] === '@') { - trigger_error( - 'Using the @ syntax for file uploads is not safe and is deprecated. ' . - 'Instead you should use file handles.', - E_USER_DEPRECATED - ); - $this->addFile($name, $value); - } elseif ($name instanceof FormDataPart && $value === null) { + if (is_string($name)) { + if (is_array($value)) { + $this->addRecursive($name, $value); + } elseif (is_resource($value) || $value instanceof UploadedFileInterface) { + $this->addFile($name, $value); + } else { + $this->_parts[] = $this->newPart($name, (string)$value); + } + } else { $this->_hasComplexPart = true; $this->_parts[] = $name; - } else { - $this->_parts[] = $this->newPart($name, $value); } return $this; @@ -140,29 +138,44 @@ public function addMany(array $data) * or a file handle. * * @param string $name The name to use. - * @param mixed $value Either a string filename, or a filehandle. + * @param \Psr\Http\Message\UploadedFileInterface|resource|string $value Either a string filename, or a filehandle, + * or a UploadedFileInterface instance. * @return \Cake\Http\Client\FormDataPart */ - public function addFile($name, $value) + public function addFile(string $name, mixed $value): FormDataPart { $this->_hasFile = true; $filename = false; $contentType = 'application/octet-stream'; - if (is_resource($value)) { - $content = stream_get_contents($value); + if ($value instanceof UploadedFileInterface) { + $content = (string)$value->getStream(); + $contentType = $value->getClientMediaType(); + $filename = $value->getClientFilename(); + } elseif (is_resource($value)) { + $content = (string)stream_get_contents($value); if (stream_is_local($value)) { $finfo = new finfo(FILEINFO_MIME); $metadata = stream_get_meta_data($value); - $contentType = $finfo->file($metadata['uri']); - $filename = basename($metadata['uri']); + $uri = $metadata['uri'] ?? ''; + $contentType = (string)$finfo->file($uri); + $filename = basename($uri); } } else { + assert( + is_string($value), + sprintf( + '`$value` must be a string, a resource or an instance of `Psr\Http\Message\UploadedFileInterface`.' + . ' `%s` given.', + get_debug_type($value), + ), + ); + $finfo = new finfo(FILEINFO_MIME); $value = substr($value, 1); $filename = basename($value); - $content = file_get_contents($value); - $contentType = $finfo->file($value); + $content = (string)file_get_contents($value); + $contentType = (string)$finfo->file($value); } $part = $this->newPart($name, $content); $part->type($contentType); @@ -181,11 +194,11 @@ public function addFile($name, $value) * @param mixed $value The value to add. * @return void */ - public function addRecursive($name, $value) + public function addRecursive(string $name, mixed $value): void { - foreach ($value as $key => $value) { + foreach ($value as $key => $item) { $key = $name . '[' . $key . ']'; - $this->add($key, $value); + $this->add($key, $item); } } @@ -194,32 +207,32 @@ public function addRecursive($name, $value) * * @return int */ - public function count() + public function count(): int { return count($this->_parts); } /** - * Check whether or not the current payload + * Check whether the current payload * has any files. * - * @return bool Whether or not there is a file in this payload. + * @return bool Whether there is a file in this payload. */ - public function hasFile() + public function hasFile(): bool { return $this->_hasFile; } /** - * Check whether or not the current payload + * Check whether the current payload * is multipart. * * A payload will become multipart when you add files * or use add() with a Part instance. * - * @return bool Whether or not the payload is multipart. + * @return bool Whether the payload is multipart. */ - public function isMultipart() + public function isMultipart(): bool { return $this->hasFile() || $this->_hasComplexPart; } @@ -232,13 +245,13 @@ public function isMultipart() * * @return string */ - public function contentType() + public function contentType(): string { if (!$this->isMultipart()) { return 'application/x-www-form-urlencoded'; } - return 'multipart/form-data; boundary="' . $this->boundary() . '"'; + return 'multipart/form-data; boundary=' . $this->boundary(); } /** @@ -247,17 +260,17 @@ public function contentType() * * @return string */ - public function __toString() + public function __toString(): string { if ($this->isMultipart()) { $boundary = $this->boundary(); $out = ''; foreach ($this->_parts as $part) { - $out .= "--$boundary\r\n"; + $out .= "--{$boundary}\r\n"; $out .= (string)$part; $out .= "\r\n"; } - $out .= "--$boundary--\r\n\r\n"; + $out .= "--{$boundary}--\r\n"; return $out; } @@ -269,6 +282,3 @@ public function __toString() return http_build_query($data); } } - -// @deprecated Add backwards compat alias. -class_alias('Cake\Http\Client\FormData', 'Cake\Network\Http\FormData'); diff --git a/src/Http/Client/FormDataPart.php b/src/Http/Client/FormDataPart.php index a079c7274f3..561b9f94552 100644 --- a/src/Http/Client/FormDataPart.php +++ b/src/Http/Client/FormDataPart.php @@ -1,4 +1,6 @@ _name = $name; - $this->_value = $value; - $this->_disposition = $disposition; + * @param string|null $charset The charset of the data. + */ + public function __construct( + protected string $name, + protected string $value, + protected string $disposition = 'form-data', + protected ?string $charset = null, + ) { } /** @@ -94,29 +79,31 @@ public function __construct($name, $value, $disposition = 'form-data') * By passing in `false` you can disable the disposition * header from being added. * - * @param null|string $disposition Use null to get/string to set. - * @return string|null + * @param string|null $disposition Use null to get/string to set. + * @return string */ - public function disposition($disposition = null) + public function disposition(?string $disposition = null): string { if ($disposition === null) { - return $this->_disposition; + return $this->disposition; } - $this->_disposition = $disposition; + + return $this->disposition = $disposition; } /** * Get/set the contentId for a part. * - * @param null|string $id The content id. + * @param string|null $id The content id. * @return string|null */ - public function contentId($id = null) + public function contentId(?string $id = null): ?string { if ($id === null) { - return $this->_contentId; + return $this->contentId; } - $this->_contentId = $id; + + return $this->contentId = $id; } /** @@ -125,29 +112,31 @@ public function contentId($id = null) * Setting the filename to `false` will exclude it from the * generated output. * - * @param null|string $filename Use null to get/string to set. + * @param string|null $filename Use null to get/string to set. * @return string|null */ - public function filename($filename = null) + public function filename(?string $filename = null): ?string { if ($filename === null) { - return $this->_filename; + return $this->filename; } - $this->_filename = $filename; + + return $this->filename = $filename; } /** * Get/set the content type. * - * @param null|string $type Use null to get/string to set. + * @param string|null $type Use null to get/string to set. * @return string|null */ - public function type($type) + public function type(?string $type): ?string { if ($type === null) { - return $this->_type; + return $this->type; } - $this->_type = $type; + + return $this->type = $type; } /** @@ -155,15 +144,16 @@ public function type($type) * * Useful when content bodies are in encodings like base64. * - * @param null|string $type The type of encoding the value has. + * @param string|null $type The type of encoding the value has. * @return string|null */ - public function transferEncoding($type) + public function transferEncoding(?string $type): ?string { if ($type === null) { - return $this->_transferEncoding; + return $this->transferEncoding; } - $this->_transferEncoding = $type; + + return $this->transferEncoding = $type; } /** @@ -171,9 +161,9 @@ public function transferEncoding($type) * * @return string */ - public function name() + public function name(): string { - return $this->_name; + return $this->name; } /** @@ -181,9 +171,9 @@ public function name() * * @return string */ - public function value() + public function value(): string { - return $this->_value; + return $this->value; } /** @@ -193,34 +183,52 @@ public function value() * * @return string */ - public function __toString() + public function __toString(): string { $out = ''; - if ($this->_disposition) { - $out .= 'Content-Disposition: ' . $this->_disposition; - if ($this->_name) { - $out .= '; name="' . $this->_name . '"'; + if ($this->disposition) { + $out .= 'Content-Disposition: ' . $this->disposition; + if ($this->name) { + $out .= '; ' . $this->_headerParameterToString('name', $this->name); } - if ($this->_filename) { - $out .= '; filename="' . $this->_filename . '"'; + if ($this->filename) { + $out .= '; ' . $this->_headerParameterToString('filename', $this->filename); } $out .= "\r\n"; } - if ($this->_type) { - $out .= 'Content-Type: ' . $this->_type . "\r\n"; + if ($this->type) { + $out .= 'Content-Type: ' . $this->type . "\r\n"; } - if ($this->_transferEncoding) { - $out .= 'Content-Transfer-Encoding: ' . $this->_transferEncoding . "\r\n"; + if ($this->transferEncoding) { + $out .= 'Content-Transfer-Encoding: ' . $this->transferEncoding . "\r\n"; } - if ($this->_contentId) { - $out .= 'Content-ID: <' . $this->_contentId . ">\r\n"; + if ($this->contentId) { + $out .= 'Content-ID: <' . $this->contentId . ">\r\n"; } $out .= "\r\n"; - $out .= (string)$this->_value; + $out .= $this->value; return $out; } -} -// @deprecated Add backwards compat alias. -class_alias('Cake\Http\Client\FormDataPart', 'Cake\Network\Http\FormData\Part'); + /** + * Get the string for the header parameter. + * + * If the value contains non-ASCII letters an additional header indicating + * the charset encoding will be set. + * + * @param string $name The name of the header parameter + * @param string $value The value of the header parameter + * @return string + */ + protected function _headerParameterToString(string $name, string $value): string + { + $transliterated = Text::transliterate(str_replace('"', '', $value)); + $return = sprintf('%s="%s"', $name, $transliterated); + if ($this->charset !== null && $value !== $transliterated) { + $return .= sprintf("; %s*=%s''%s", $name, strtolower($this->charset), rawurlencode($value)); + } + + return $return; + } +} diff --git a/src/Http/Client/Message.php b/src/Http/Client/Message.php index 5443e5062b1..c3b978bb41c 100644 --- a/src/Http/Client/Message.php +++ b/src/Http/Client/Message.php @@ -1,4 +1,6 @@ headers; - } + protected array $_cookies = []; /** * Get all cookies * * @return array + * @deprecated 5.3.0 Use getCookies() instead. */ - public function cookies() - { - return $this->_cookies; - } - - /** - * Get/set the body for the message. - * - * @param string|null $body The body for the request. Leave null for get - * @return mixed Either $this or the body value. - */ - public function body($body = null) + public function cookies(): array { - if ($body === null) { - return $this->_body; - } - $this->_body = $body; + deprecationWarning('5.3.0', 'Use `getCookies()` instead.'); - return $this; + return $this->_cookies; } } - -// @deprecated Add backwards compat alias. -class_alias('Cake\Http\Client\Message', 'Cake\Network\Http\Message'); diff --git a/src/Http/Client/Request.php b/src/Http/Client/Request.php index 72b72b4ceee..52abaa3dbb3 100644 --- a/src/Http/Client/Request.php +++ b/src/Http/Client/Request.php @@ -1,4 +1,6 @@ $headers The HTTP headers to set. * @param array|string|null $data The request body to use. */ - public function __construct($url = '', $method = self::METHOD_GET, array $headers = [], $data = null) - { - $this->validateMethod($method); - $this->method = $method; + public function __construct( + UriInterface|string $url = '', + string $method = self::METHOD_GET, + array $headers = [], + array|string|null $data = null, + ) { + $this->setMethod($method); $this->uri = $this->createUri($url); $headers += [ 'Connection' => 'close', - 'User-Agent' => 'CakePHP' + 'User-Agent' => ini_get('user_agent') ?: 'CakePHP', ]; $this->addHeaders($headers); - $this->body($data); - } - - /** - * Get/Set the HTTP method. - * - * *Warning* This method mutates the request in-place for backwards - * compatibility reasons, and is not part of the PSR7 interface. - * - * @param string|null $method The method for the request. - * @return $this|string Either this or the current method. - * @throws \Cake\Core\Exception\Exception On invalid methods. - * @deprecated 3.3.0 Use getMethod() and withMethod() instead. - */ - public function method($method = null) - { - if ($method === null) { - return $this->method; - } - $name = get_called_class() . '::METHOD_' . strtoupper($method); - if (!defined($name)) { - throw new Exception('Invalid method type'); + if (in_array($data, [null, '', []], true)) { + $this->stream = new Stream('php://memory', 'rw'); + } else { + $this->setContent($data); } - $this->method = $method; - - return $this; - } - - /** - * Get/Set the url for the request. - * - * *Warning* This method mutates the request in-place for backwards - * compatibility reasons, and is not part of the PSR7 interface. - * - * @param string|null $url The url for the request. Leave null for get - * @return $this|string Either $this or the url value. - * @deprecated 3.3.0 Use getUri() and withUri() instead. - */ - public function url($url = null) - { - if ($url === null) { - return '' . $this->getUri(); - } - $this->uri = $this->createUri($url); - - return $this; - } - - /** - * Get/Set headers into the request. - * - * You can get the value of a header, or set one/many headers. - * Headers are set / fetched in a case insensitive way. - * - * ### Getting headers - * - * ``` - * $request->header('Content-Type'); - * ``` - * - * ### Setting one header - * - * ``` - * $request->header('Content-Type', 'application/json'); - * ``` - * - * ### Setting multiple headers - * - * ``` - * $request->header(['Connection' => 'close', 'User-Agent' => 'CakePHP']); - * ``` - * - * *Warning* This method mutates the request in-place for backwards - * compatibility reasons, and is not part of the PSR7 interface. - * - * @param string|array|null $name The name to get, or array of multiple values to set. - * @param string|null $value The value to set for the header. - * @return mixed Either $this when setting or header value when getting. - * @deprecated 3.3.0 Use withHeader() and getHeaderLine() instead. - */ - public function header($name = null, $value = null) - { - if ($value === null && is_string($name)) { - $val = $this->getHeaderLine($name); - if ($val === '') { - return null; - } - - return $val; - } - - if ($value !== null && !is_array($name)) { - $name = [$name => $value]; - } - $this->addHeaders($name); - - return $this; } /** * Add an array of headers to the request. * - * @param array $headers The headers to add. + * @param array $headers The headers to add. * @return void */ - protected function addHeaders(array $headers) + protected function addHeaders(array $headers): void { foreach ($headers as $key => $val) { $normalized = strtolower($key); @@ -163,98 +77,39 @@ protected function addHeaders(array $headers) } /** - * Get/Set cookie values. - * - * ### Getting a cookie - * - * ``` - * $request->cookie('session'); - * ``` - * - * ### Setting one cookie - * - * ``` - * $request->cookie('session', '123456'); - * ``` - * - * ### Setting multiple headers - * - * ``` - * $request->cookie(['test' => 'value', 'split' => 'banana']); - * ``` - * - * @param string $name The name of the cookie to get/set - * @param string|null $value Either the value or null when getting values. - * @return mixed Either $this or the cookie value. - * @deprecated 3.5.0 No longer used. CookieCollections now add `Cookie` header to the request - * before sending. Use Cake\Http\Cookie\CookieCollection::addToRequest() to make adding cookies - * to a request easier. - */ - public function cookie($name, $value = null) - { - if ($value === null && is_string($name)) { - return isset($this->_cookies[$name]) ? $this->_cookies[$name] : null; - } - if (is_string($name) && is_string($value)) { - $name = [$name => $value]; - } - foreach ($name as $key => $val) { - $this->_cookies[$key] = $val; - } - - return $this; - } - - /** - * Get/Set HTTP version. + * Set the body/payload for the message. * - * *Warning* This method mutates the request in-place for backwards - * compatibility reasons, and is not part of the PSR7 interface. - * - * @param string|null $version The HTTP version. - * @return $this|string Either $this or the HTTP version. - * @deprecated 3.3.0 Use getProtocolVersion() and withProtocolVersion() instead. - */ - public function version($version = null) - { - if ($version === null) { - return $this->protocol; - } - - $this->protocol = $version; - - return $this; - } - - /** - * Get/set the body/payload for the message. - * - * Array data will be serialized with Cake\Http\FormData, + * Array data will be serialized with {@link \Cake\Http\FormData}, * and the content-type will be set. * - * @param string|array|null $body The body for the request. Leave null for get - * @return mixed Either $this or the body value. + * @param array|string $content The body for the request. + * @return $this */ - public function body($body = null) + protected function setContent(array|string $content) { - if ($body === null) { - $body = $this->getBody(); - - return $body ? $body->__toString() : ''; - } - if (is_array($body)) { - $formData = new FormData(); - $formData->addMany($body); - $this->header('Content-Type', $formData->contentType()); - $body = (string)$formData; + if (is_array($content)) { + $contentType = $this->getHeaderLine('content-type'); + + if (str_contains($contentType, 'application/json')) { + $content = json_encode($content, JSON_THROW_ON_ERROR); + } elseif (str_contains($contentType, 'application/xml')) { + /** @phpstan-ignore-next-line */ + $content = (string)Xml::fromArray($content); + } else { + $formData = new FormData(); + $formData->addMany($content); + + /** @var array $headers */ + $headers = ['Content-Type' => $formData->contentType()]; + $this->addHeaders($headers); + $content = (string)$formData; + } } + $stream = new Stream('php://memory', 'rw'); - $stream->write($body); + $stream->write($content); $this->stream = $stream; return $this; } } - -// @deprecated Add backwards compact alias. -class_alias('Cake\Http\Client\Request', 'Cake\Network\Http\Request'); diff --git a/src/Http/Client/Response.php b/src/Http/Client/Response.php index 35d584d38af..5900bb754a5 100644 --- a/src/Http/Client/Response.php +++ b/src/Http/Client/Response.php @@ -1,4 +1,6 @@ getHeaders(); * ``` * - * You can also get at the headers using object access. When getting - * headers with object access, you have to use case-sensitive header - * names: - * - * ``` - * $val = $response->headers['Content-Type']; - * ``` - * * ### Get the response body * * You can access the response body stream using: @@ -60,11 +52,10 @@ * $content = $response->getBody(); * ``` * - * You can also use object access to get the string version - * of the response body: + * You can get the body string using: * * ``` - * $content = $response->body; + * $content = $response->getStringBody(); * ``` * * If your response body is in XML or JSON you can use @@ -73,10 +64,10 @@ * as SimpleXML nodes: * * ``` - * // Get as xml - * $content = $response->xml - * // Get as json - * $content = $response->json + * // Get as XML + * $content = $response->getXml() + * // Get as JSON + * $content = $response->getJson() * ``` * * If the response cannot be decoded, null will be returned. @@ -88,12 +79,6 @@ * ``` * $content = $response->getStatusCode(); * ``` - * - * You can also use object access: - * - * ``` - * $content = $response->code; - * ``` */ class Response extends Message implements ResponseInterface { @@ -104,57 +89,43 @@ class Response extends Message implements ResponseInterface * * @var int */ - protected $code; + protected int $code = 0; /** * Cookie Collection instance * - * @var \Cake\Http\Cookie\CookieCollection + * @var \Cake\Http\Cookie\CookieCollection|null */ - protected $cookies; + protected ?CookieCollection $cookies = null; /** * The reason phrase for the status code * * @var string */ - protected $reasonPhrase; + protected string $reasonPhrase = ''; /** * Cached decoded XML data. * - * @var \SimpleXMLElement + * @var \SimpleXMLElement|null */ - protected $_xml; + protected ?SimpleXMLElement $_xml = null; /** * Cached decoded JSON data. * - * @var array - */ - protected $_json; - - /** - * Map of public => property names for __get() - * - * @var array + * @var mixed */ - protected $_exposedProperties = [ - 'cookies' => '_getCookies', - 'body' => '_getBody', - 'code' => 'code', - 'json' => '_getJson', - 'xml' => '_getXml', - 'headers' => '_getHeaders', - ]; + protected mixed $_json = null; /** * Constructor * - * @param array $headers Unparsed headers. + * @param array $headers Unparsed headers. * @param string $body The response body. */ - public function __construct($headers = [], $body = '') + public function __construct(array $headers = [], string $body = '') { $this->_parseHeaders($headers); if ($this->getHeaderLine('Content-Encoding') === 'gzip') { @@ -174,45 +145,50 @@ public function __construct($headers = [], $body = '') * * @param string $body Gzip encoded body. * @return string - * @throws \RuntimeException When attempting to decode gzip content without gzinflate. + * @throws \Cake\Core\Exception\CakeException When attempting to decode gzip content without gzinflate. */ - protected function _decodeGzipBody($body) + protected function _decodeGzipBody(string $body): string { if (!function_exists('gzinflate')) { - throw new RuntimeException('Cannot decompress gzip response body without gzinflate()'); + throw new CakeException('Cannot decompress gzip response body without gzinflate()'); } $offset = 0; // Look for gzip 'signature' - if (substr($body, 0, 2) === "\x1f\x8b") { + if (str_starts_with($body, "\x1f\x8b")) { $offset = 2; } // Check the format byte if (substr($body, $offset, 1) === "\x08") { - return gzinflate(substr($body, $offset + 8)); + return (string)gzinflate(substr($body, $offset + 8)); } + + throw new CakeException('Invalid gzip response'); } /** * Parses headers if necessary. * - * - Decodes the status code and reasonphrase. - * - Parses and normalizes header names + values. + * - Decodes the status code and reason phrase. + * - Parses and normalizes header names and values. * - * @param array $headers Headers to parse. + * @param array $headers Headers to parse. * @return void */ - protected function _parseHeaders($headers) + protected function _parseHeaders(array $headers): void { - foreach ($headers as $key => $value) { - if (substr($value, 0, 5) === 'HTTP/') { - preg_match('/HTTP\/([\d.]+) ([0-9]+)(.*)/i', $value, $matches); + foreach ($headers as $value) { + if (preg_match('/^HTTP\/([\d.]+) ([0-9]+)(.*)/i', $value, $matches)) { $this->protocol = $matches[1]; $this->code = (int)$matches[2]; $this->reasonPhrase = trim($matches[3]); continue; } - list($name, $value) = explode(':', $value, 2); + if (!str_contains($value, ':')) { + continue; + } + [$name, $value] = explode(':', $value, 2); $value = trim($value); + /** @var non-empty-string $name */ $name = trim($name); $normalized = strtolower($name); @@ -227,21 +203,23 @@ protected function _parseHeaders($headers) } /** - * Check if the response was OK + * Check if the response status code was in the 2xx/3xx range * * @return bool */ - public function isOk() + public function isOk(): bool { - $codes = [ - static::STATUS_OK, - static::STATUS_CREATED, - static::STATUS_ACCEPTED, - static::STATUS_NON_AUTHORITATIVE_INFORMATION, - static::STATUS_NO_CONTENT - ]; + return $this->code >= 200 && $this->code <= 399; + } - return in_array($this->code, $codes); + /** + * Check if the response status code was in the 2xx range + * + * @return bool + */ + public function isSuccess(): bool + { + return $this->code >= 200 && $this->code <= 299; } /** @@ -249,50 +227,38 @@ public function isOk() * * @return bool */ - public function isRedirect() + public function isRedirect(): bool { $codes = [ static::STATUS_MOVED_PERMANENTLY, static::STATUS_FOUND, static::STATUS_SEE_OTHER, static::STATUS_TEMPORARY_REDIRECT, + static::STATUS_PERMANENT_REDIRECT, ]; - return ( - in_array($this->code, $codes) && - $this->getHeaderLine('Location') - ); - } - - /** - * Get the status code from the response - * - * @return int - * @deprecated 3.3.0 Use getStatusCode() instead. - */ - public function statusCode() - { - return $this->code; + return in_array($this->code, $codes, true) && + $this->getHeaderLine('Location'); } /** - * {@inheritdoc} + * {@inheritDoc} * * @return int The status code. */ - public function getStatusCode() + public function getStatusCode(): int { return $this->code; } /** - * {@inheritdoc} + * {@inheritDoc} * * @param int $code The status code to set. * @param string $reasonPhrase The status reason phrase. - * @return $this A copy of the current object with an updated status code. + * @return static A copy of the current object with an updated status code. */ - public function withStatus($code, $reasonPhrase = '') + public function withStatus(int $code, string $reasonPhrase = ''): static { $new = clone $this; $new->code = $code; @@ -302,11 +268,11 @@ public function withStatus($code, $reasonPhrase = '') } /** - * {@inheritdoc} + * {@inheritDoc} * * @return string The current reason phrase. */ - public function getReasonPhrase() + public function getReasonPhrase(): string { return $this->reasonPhrase; } @@ -315,19 +281,8 @@ public function getReasonPhrase() * Get the encoding if it was set. * * @return string|null - * @deprecated 3.3.0 Use getEncoding() instead. */ - public function encoding() - { - return $this->getEncoding(); - } - - /** - * Get the encoding if it was set. - * - * @return string|null - */ - public function getEncoding() + public function getEncoding(): ?string { $content = $this->getHeaderLine('content-type'); if (!$content) { @@ -341,62 +296,12 @@ public function getEncoding() return $matches[1]; } - /** - * Read single/multiple header value(s) out. - * - * @param string|null $name The name of the header you want. Leave - * null to get all headers. - * @return mixed Null when the header doesn't exist. An array - * will be returned when getting all headers or when getting - * a header that had multiple values set. Otherwise a string - * will be returned. - * @deprecated 3.3.0 Use getHeader() and getHeaderLine() instead. - */ - public function header($name = null) - { - if ($name === null) { - return $this->_getHeaders(); - } - $header = $this->getHeader($name); - if (count($header) === 1) { - return $header[0]; - } - - return $header; - } - - /** - * Read single/multiple cookie values out. - * - * *Note* This method will only provide access to cookies that - * were added as part of the constructor. If cookies are added post - * construction they will not be accessible via this method. - * - * @param string|null $name The name of the cookie you want. Leave - * null to get all cookies. - * @param bool $all Get all parts of the cookie. When false only - * the value will be returned. - * @return mixed - * @deprecated 3.3.0 Use getCookie(), getCookieData() or getCookies() instead. - */ - public function cookie($name = null, $all = false) - { - if ($name === null) { - return $this->getCookies(); - } - if ($all) { - return $this->getCookieData($name); - } - - return $this->getCookie($name); - } - /** * Get the all cookie data. * * @return array The cookie data */ - public function getCookies() + public function getCookies(): array { return $this->_getCookies(); } @@ -409,27 +314,26 @@ public function getCookies() * * @return \Cake\Http\Cookie\CookieCollection */ - public function getCookieCollection() + public function getCookieCollection(): CookieCollection { - $this->buildCookieCollection(); - - return $this->cookies; + return $this->buildCookieCollection(); } /** * Get the value of a single cookie. * * @param string $name The name of the cookie value. - * @return string|null Either the cookie's value or null when the cookie is undefined. + * @return array|string|null Either the cookie's value or null when the cookie is undefined. */ - public function getCookie($name) + public function getCookie(string $name): array|string|null { - $this->buildCookieCollection(); - if (!$this->cookies->has($name)) { + $cookies = $this->buildCookieCollection(); + + if (!$cookies->has($name)) { return null; } - return $this->cookies->get($name)->getValue(); + return $cookies->get($name)->getValue(); } /** @@ -438,52 +342,27 @@ public function getCookie($name) * @param string $name The name of the cookie value. * @return array|null Either the cookie's data or null when the cookie is undefined. */ - public function getCookieData($name) + public function getCookieData(string $name): ?array { - $this->buildCookieCollection(); + $cookies = $this->buildCookieCollection(); - if (!$this->cookies->has($name)) { + if (!$cookies->has($name)) { return null; } - $cookie = $this->cookies->get($name); - - return $this->convertCookieToArray($cookie); - } - - /** - * Convert the cookie into an array of its properties. - * - * This method is compatible with older client code that - * expects date strings instead of timestamps. - * - * @param \Cake\Http\Cookie\CookieInterface $cookie Cookie object. - * @return array - */ - protected function convertCookieToArray(CookieInterface $cookie) - { - return [ - 'name' => $cookie->getName(), - 'value' => $cookie->getValue(), - 'path' => $cookie->getPath(), - 'domain' => $cookie->getDomain(), - 'secure' => $cookie->isSecure(), - 'httponly' => $cookie->isHttpOnly(), - 'expires' => $cookie->getFormattedExpires() - ]; + return $cookies->get($name)->toArray(); } /** * Lazily build the CookieCollection and cookie objects from the response header * - * @return void + * @return \Cake\Http\Cookie\CookieCollection */ - protected function buildCookieCollection() + protected function buildCookieCollection(): CookieCollection { - if ($this->cookies) { - return; - } - $this->cookies = CookiesCollection::createFromHeader($this->getHeader('Set-Cookie')); + $this->cookies ??= CookieCollection::createFromHeader($this->getHeader('Set-Cookie')); + + return $this->cookies; } /** @@ -491,62 +370,42 @@ protected function buildCookieCollection() * * @return array Array of Cookie data. */ - protected function _getCookies() + protected function _getCookies(): array { - $this->buildCookieCollection(); - - $cookies = []; - foreach ($this->cookies as $cookie) { - $cookies[$cookie->getName()] = $this->convertCookieToArray($cookie); + $out = []; + foreach ($this->buildCookieCollection() as $cookie) { + $out[$cookie->getName()] = $cookie->toArray(); } - return $cookies; + return $out; } /** - * Get the HTTP version used. + * Get the response body as string. * * @return string - * @deprecated 3.3.0 Use getProtocolVersion() */ - public function version() + public function getStringBody(): string { - return $this->protocol; + return $this->_getBody(); } /** - * Get the response body. - * - * By passing in a $parser callable, you can get the decoded - * response content back. - * - * For example to get the json data as an object: - * - * ``` - * $body = $response->body('json_decode'); - * ``` + * Get the response body as JSON decoded data. * - * @param callable|null $parser The callback to use to decode - * the response body. - * @return mixed The response body. + * @return mixed */ - public function body($parser = null) + public function getJson(): mixed { - $stream = $this->stream; - $stream->rewind(); - if ($parser) { - return $parser($stream->getContents()); - } - - return $stream->getContents(); + return $this->_getJson(); } /** * Get the response body as JSON decoded data. * - * @return array|null + * @return mixed */ - protected function _getJson() + protected function _getJson(): mixed { if ($this->_json) { return $this->_json; @@ -558,30 +417,40 @@ protected function _getJson() /** * Get the response body as XML decoded data. * - * @return null|\SimpleXMLElement + * @return \SimpleXMLElement|null */ - protected function _getXml() + public function getXml(): ?SimpleXMLElement { - if ($this->_xml) { + return $this->_getXml(); + } + + /** + * Get the response body as XML decoded data. + * + * @return \SimpleXMLElement|null + */ + protected function _getXml(): ?SimpleXMLElement + { + if ($this->_xml !== null) { return $this->_xml; } libxml_use_internal_errors(); $data = simplexml_load_string($this->_getBody()); - if ($data) { - $this->_xml = $data; - - return $this->_xml; + if (!$data) { + return null; } - return null; + $this->_xml = $data; + + return $this->_xml; } /** * Provides magic __get() support. * - * @return array + * @return array */ - protected function _getHeaders() + protected function _getHeaders(): array { $out = []; foreach ($this->headers as $key => $values) { @@ -594,55 +463,12 @@ protected function _getHeaders() /** * Provides magic __get() support. * - * @return array + * @return string */ - protected function _getBody() + protected function _getBody(): string { $this->stream->rewind(); return $this->stream->getContents(); } - - /** - * Read values as properties. - * - * @param string $name Property name. - * @return mixed - */ - public function __get($name) - { - if (!isset($this->_exposedProperties[$name])) { - return false; - } - $key = $this->_exposedProperties[$name]; - if (substr($key, 0, 4) === '_get') { - return $this->{$key}(); - } - - return $this->{$key}; - } - - /** - * isset/empty test with -> syntax. - * - * @param string $name Property name. - * @return bool - */ - public function __isset($name) - { - if (!isset($this->_exposedProperties[$name])) { - return false; - } - $key = $this->_exposedProperties[$name]; - if (substr($key, 0, 4) === '_get') { - $val = $this->{$key}(); - - return $val !== null; - } - - return isset($this->{$key}); - } } - -// @deprecated Add backwards compat alias. -class_alias('Cake\Http\Client\Response', 'Cake\Network\Http\Response'); diff --git a/src/Http/ContentTypeNegotiation.php b/src/Http/ContentTypeNegotiation.php new file mode 100644 index 00000000000..1811a78937d --- /dev/null +++ b/src/Http/ContentTypeNegotiation.php @@ -0,0 +1,135 @@ +> A mapping of preference values => content types + */ + public function parseAccept(RequestInterface $request): array + { + $header = $request->getHeaderLine('Accept'); + + return $this->parseQualifiers($header); + } + + /** + * Parse the Accept-Language header + * + * Only qualifiers will be extracted, other extensions will be ignored + * as they are not frequently used. + * + * @param \Psr\Http\Message\RequestInterface $request The request to get an accept from. + * @return array> A mapping of preference values => languages + */ + public function parseAcceptLanguage(RequestInterface $request): array + { + $header = $request->getHeaderLine('Accept-Language'); + + return $this->parseQualifiers($header); + } + + /** + * Parse a header value into preference => value mapping + * + * @param string $header The header value to parse + * @return array> + */ + protected function parseQualifiers(string $header): array + { + return HeaderUtility::parseAccept($header); + } + + /** + * Get the most preferred content type from a request. + * + * Parse the Accept header preferences and return the most + * preferred type. If multiple types are tied in preference + * the first type of that preference value will be returned. + * + * You can expect null when the request has no Accept header. + * + * @param \Psr\Http\Message\RequestInterface $request The request to use. + * @param array $choices The supported content type choices. + * @return string|null The preferred type or null if there is no match with choices or if the + * request had no Accept header. + */ + public function preferredType(RequestInterface $request, array $choices = []): ?string + { + $parsed = $this->parseAccept($request); + if (!$parsed) { + return null; + } + if (!$choices) { + $preferred = array_shift($parsed); + + return $preferred[0]; + } + + foreach ($parsed as $acceptTypes) { + $common = array_intersect($acceptTypes, $choices); + if ($common) { + return array_shift($common); + } + } + + return null; + } + + /** + * Get the normalized list of accepted languages + * + * Language codes in the request will be normalized to lower case and have + * `_` replaced with `-`. + * + * @param \Psr\Http\Message\RequestInterface $request The request to read headers from. + * @return array A list of language codes that are accepted. + */ + public function acceptedLanguages(RequestInterface $request): array + { + $raw = $this->parseAcceptLanguage($request); + $accept = []; + foreach ($raw as $languages) { + foreach ($languages as &$lang) { + if (strpos($lang, '_')) { + $lang = str_replace('_', '-', $lang); + } + $lang = strtolower($lang); + } + $accept = array_merge($accept, $languages); + } + + return $accept; + } + + /** + * Check if the request accepts a given language code. + * + * Language codes in the request will be normalized to lower case and have `_` replaced + * with `-`. + * + * @param \Psr\Http\Message\RequestInterface $request The request to read headers from. + * @param string $lang The language code to check. + * @return bool Whether the request accepts $lang + */ + public function acceptLanguage(RequestInterface $request, string $lang): bool + { + $accept = $this->acceptedLanguages($request); + + return in_array(strtolower($lang), $accept, true); + } +} diff --git a/src/Http/ControllerFactory.php b/src/Http/ControllerFactory.php deleted file mode 100644 index eebb1ec8f69..00000000000 --- a/src/Http/ControllerFactory.php +++ /dev/null @@ -1,107 +0,0 @@ -getControllerClass($request); - if (!$className) { - $this->missingController($request); - } - $reflection = new ReflectionClass($className); - if ($reflection->isAbstract() || $reflection->isInterface()) { - $this->missingController($request); - } - - return $reflection->newInstance($request, $response); - } - - /** - * Determine the controller class name based on current request and controller param - * - * @param \Cake\Http\ServerRequest $request The request to build a controller for. - * @return string|null - */ - public function getControllerClass(ServerRequest $request) - { - $pluginPath = $controller = null; - $namespace = 'Controller'; - if ($request->getParam('controller')) { - $controller = $request->getParam('controller'); - } - if ($request->getParam('plugin')) { - $pluginPath = $request->getParam('plugin') . '.'; - } - if ($request->getParam('prefix')) { - if (strpos($request->getParam('prefix'), '/') === false) { - $namespace .= '/' . Inflector::camelize($request->getParam('prefix')); - } else { - $prefixes = array_map( - 'Cake\Utility\Inflector::camelize', - explode('/', $request->getParam('prefix')) - ); - $namespace .= '/' . implode('/', $prefixes); - } - } - $firstChar = substr($controller, 0, 1); - - // Disallow plugin short forms, / and \\ from - // controller names as they allow direct references to - // be created. - if (strpos($controller, '\\') !== false || - strpos($controller, '/') !== false || - strpos($controller, '.') !== false || - $firstChar === strtolower($firstChar) - ) { - $this->missingController($request); - } - - return App::className($pluginPath . $controller, $namespace, 'Controller') ?: null; - } - - /** - * Throws an exception when a controller is missing. - * - * @param \Cake\Http\ServerRequest $request The request. - * @throws \Cake\Routing\Exception\MissingControllerException - * @return void - */ - protected function missingController($request) - { - throw new MissingControllerException([ - 'class' => $request->getParam('controller'), - 'plugin' => $request->getParam('plugin'), - 'prefix' => $request->getParam('prefix'), - '_ext' => $request->getParam('_ext') - ]); - } -} diff --git a/src/Http/ControllerFactoryInterface.php b/src/Http/ControllerFactoryInterface.php new file mode 100644 index 00000000000..8406b440891 --- /dev/null +++ b/src/Http/ControllerFactoryInterface.php @@ -0,0 +1,45 @@ +withValue('0'); * ``` * - * @link https://tools.ietf.org/html/rfc6265 + * @link https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03 * @link https://en.wikipedia.org/wiki/HTTP_cookie - * @see Cake\Http\Cookie\CookieCollection for working with collections of cookies. - * @see Cake\Http\Response::getCookieCollection() for working with response cookies. + * @see \Cake\Http\Cookie\CookieCollection for working with collections of cookies. + * @see \Cake\Http\Response::getCookieCollection() for working with response cookies. */ class Cookie implements CookieInterface { - /** * Cookie name * * @var string */ - protected $name = ''; + protected string $name = ''; /** * Raw Cookie value. * - * @var string|array + * @var array|string */ - protected $value = ''; + protected array|string $value = ''; /** - * Whether or not a JSON value has been expanded into an array. + * Whether a JSON value has been expanded into an array. * * @var bool */ - protected $isExpanded = false; + protected bool $isExpanded = false; /** * Expiration time * - * @var \DateTime|\DateTimeImmutable|null + * @var \DateTimeInterface|null */ - protected $expiresAt; + protected ?DateTimeInterface $expiresAt = null; /** * Path * * @var string */ - protected $path = ''; + protected string $path = '/'; /** * Domain * * @var string */ - protected $domain = ''; + protected string $domain = ''; /** * Secure * * @var bool */ - protected $secure = false; + protected bool $secure = false; /** * HTTP only * * @var bool */ - protected $httpOnly = false; + protected bool $httpOnly = false; + + /** + * Samesite + * + * @var \Cake\Http\Cookie\SameSiteEnum|null + */ + protected ?SameSiteEnum $sameSite = null; + + /** + * Default attributes for a cookie. + * + * @var array + * @see \Cake\Http\Cookie\Cookie::setDefaults() + */ + protected static array $defaults = [ + 'expires' => null, + 'path' => '/', + 'domain' => '', + 'secure' => false, + 'httponly' => false, + 'samesite' => null, + ]; /** * Constructor @@ -111,57 +136,214 @@ class Cookie implements CookieInterface * The only difference is the 3rd argument which excepts null or an * DateTime or DateTimeImmutable object instead an integer. * - * @link http://php.net/manual/en/function.setcookie.php + * @link https://php.net/manual/en/function.setcookie.php * @param string $name Cookie name - * @param string|array $value Value of the cookie - * @param \DateTime|\DateTimeImmutable|null $expiresAt Expiration time and date - * @param string $path Path - * @param string $domain Domain - * @param bool $secure Is secure - * @param bool $httpOnly HTTP Only + * @param array|string|float|int|bool $value Value of the cookie + * @param \DateTimeInterface|null $expiresAt Expiration time and date + * @param string|null $path Path + * @param string|null $domain Domain + * @param bool|null $secure Is secure + * @param bool|null $httpOnly HTTP Only + * @param \Cake\Http\Cookie\SameSiteEnum|string|null $sameSite Samesite */ public function __construct( - $name, - $value = '', - $expiresAt = null, - $path = '', - $domain = '', - $secure = false, - $httpOnly = false + string $name, + array|string|float|int|bool $value = '', + ?DateTimeInterface $expiresAt = null, + ?string $path = null, + ?string $domain = null, + ?bool $secure = null, + ?bool $httpOnly = null, + SameSiteEnum|string|null $sameSite = null, ) { $this->validateName($name); $this->name = $name; $this->_setValue($value); - $this->validateString($domain); - $this->domain = $domain; - - $this->validateBool($httpOnly); - $this->httpOnly = $httpOnly; + $this->domain = $domain ?? static::$defaults['domain']; + $this->httpOnly = $httpOnly ?? static::$defaults['httponly']; + $this->path = $path ?? static::$defaults['path']; + $this->secure = $secure ?? static::$defaults['secure']; + $this->sameSite = static::resolveSameSiteEnum($sameSite ?? static::$defaults['samesite']); - $this->validateString($path); - $this->path = $path; - - $this->validateBool($secure); - $this->secure = $secure; if ($expiresAt) { + if ($expiresAt instanceof DateTime) { + $expiresAt = clone $expiresAt; + } + /** @var \DateTimeImmutable|\DateTime $expiresAt */ $expiresAt = $expiresAt->setTimezone(new DateTimeZone('GMT')); + } else { + $expiresAt = static::$defaults['expires']; } $this->expiresAt = $expiresAt; } + /** + * Set default options for the cookies. + * + * Valid option keys are: + * + * - `expires`: Can be a UNIX timestamp or `strtotime()` compatible string or `DateTimeInterface` instance or `null`. + * - `path`: A path string. Defaults to `'/'`. + * - `domain`: Domain name string. Defaults to `''`. + * - `httponly`: Boolean. Defaults to `false`. + * - `secure`: Boolean. Defaults to `false`. + * - `samesite`: Can be one of `CookieInterface::SAMESITE_LAX`, `CookieInterface::SAMESITE_STRICT`, + * `CookieInterface::SAMESITE_NONE` or `null`. Defaults to `null`. + * + * @param array $options Default options. + * @return void + */ + public static function setDefaults(array $options): void + { + if (isset($options['expires'])) { + $options['expires'] = static::dateTimeInstance($options['expires']); + } + if (isset($options['samesite'])) { + $options['samesite'] = static::resolveSameSiteEnum($options['samesite']); + } + + static::$defaults = $options + static::$defaults; + } + + /** + * Factory method to create Cookie instances. + * + * @param string $name Cookie name + * @param array|string|float|int|bool $value Value of the cookie + * @param array $options Cookies options. + * @return static + * @see \Cake\Http\Cookie\Cookie::setDefaults() + */ + public static function create(string $name, array|string|float|int|bool $value, array $options = []): static + { + $options += static::$defaults; + $options['expires'] = static::dateTimeInstance($options['expires']); + + return new static( + $name, + $value, + $options['expires'], + $options['path'], + $options['domain'], + $options['secure'], + $options['httponly'], + $options['samesite'], + ); + } + + /** + * Converts non null expiry value into DateTimeInterface instance. + * + * @param \DateTimeInterface|string|int|null $expires Expiry value. + * @return \DateTimeInterface|null + */ + protected static function dateTimeInstance(DateTimeInterface|string|int|null $expires): ?DateTimeInterface + { + if ($expires === null) { + return null; + } + + if ($expires instanceof DateTimeInterface) { + /** + * @phpstan-ignore-next-line + */ + return $expires->setTimezone(new DateTimeZone('GMT')); + } + + if (!is_numeric($expires)) { + $expires = strtotime($expires) ?: null; + } + + if ($expires !== null) { + return new DateTimeImmutable('@' . $expires); + } + + return null; + } + + /** + * Create Cookie instance from "set-cookie" header string. + * + * @param string $cookie Cookie header string. + * @param array $defaults Default attributes. + * @return static + * @see \Cake\Http\Cookie\Cookie::setDefaults() + */ + public static function createFromHeaderString(string $cookie, array $defaults = []): static + { + if (str_contains($cookie, '";"')) { + $cookie = str_replace('";"', '{__cookie_replace__}', $cookie); + $parts = str_replace('{__cookie_replace__}', '";"', explode(';', $cookie)); + } else { + $parts = preg_split('/\;[ \t]*/', $cookie) ?: []; + } + + $nameValue = explode('=', (string)array_shift($parts), 2); + $name = array_shift($nameValue); + $value = array_shift($nameValue) ?? ''; + + $data = [ + 'name' => urldecode($name), + 'value' => urldecode($value), + ] + $defaults; + + foreach ($parts as $part) { + if (str_contains($part, '=')) { + [$key, $value] = explode('=', $part); + } else { + $key = $part; + $value = true; + } + + $key = strtolower($key); + $data[$key] = $value; + } + + if (isset($data['max-age'])) { + $data['expires'] = time() + (int)$data['max-age']; + unset($data['max-age']); + } + + // Ignore invalid value when parsing headers + // https://tools.ietf.org/html/draft-west-first-party-cookies-07#section-4.1 + if (isset($data['samesite'])) { + try { + $data['samesite'] = static::resolveSameSiteEnum($data['samesite']); + } catch (ValueError) { + unset($data['samesite']); + } + } + + $name = $data['name']; + $value = $data['value']; + unset($data['name'], $data['value']); + + /** @phpstan-ignore return.type */ + return Cookie::create( + $name, + $value, + $data, + ); + } + /** * Returns a header value as string * * @return string */ - public function toHeaderValue() + public function toHeaderValue(): string { $value = $this->value; if ($this->isExpanded) { - $value = $this->_flatten($this->value); + assert(is_array($value), '$value is not an array'); + + $value = $this->_flatten($value); } + + $headerValue = []; + /** @var string $value */ $headerValue[] = sprintf('%s=%s', $this->name, rawurlencode($value)); if ($this->expiresAt) { @@ -173,6 +355,9 @@ public function toHeaderValue() if ($this->domain !== '') { $headerValue[] = sprintf('domain=%s', $this->domain); } + if ($this->sameSite) { + $headerValue[] = sprintf('samesite=%s', $this->sameSite->value); + } if ($this->secure) { $headerValue[] = 'secure'; } @@ -184,9 +369,9 @@ public function toHeaderValue() } /** - * {@inheritDoc} + * @inheritDoc */ - public function withName($name) + public function withName(string $name): static { $this->validateName($name); $new = clone $this; @@ -196,19 +381,17 @@ public function withName($name) } /** - * {@inheritDoc} + * @inheritDoc */ - public function getId() + public function getId(): string { - $name = mb_strtolower($this->name); - - return "{$name};{$this->domain};{$this->path}"; + return "{$this->name};{$this->domain};{$this->path}"; } /** - * {@inheritDoc} + * @inheritDoc */ - public function getName() + public function getName(): string { return $this->name; } @@ -221,43 +404,47 @@ public function getName() * @throws \InvalidArgumentException * @link https://tools.ietf.org/html/rfc2616#section-2.2 Rules for naming cookies. */ - protected function validateName($name) + protected function validateName(string $name): void { if (preg_match("/[=,;\t\r\n\013\014]/", $name)) { throw new InvalidArgumentException( - sprintf('The cookie name `%s` contains invalid characters.', $name) + sprintf('The cookie name `%s` contains invalid characters.', $name), ); } - if (empty($name)) { + if (!$name) { throw new InvalidArgumentException('The cookie name cannot be empty.'); } } /** - * {@inheritDoc} + * @inheritDoc */ - public function getValue() + public function getValue(): array|string { return $this->value; } /** - * {@inheritDoc} + * @inheritDoc */ - public function getStringValue() + public function getScalarValue(): string { if ($this->isExpanded) { + assert(is_array($this->value), '$value is not an array'); + return $this->_flatten($this->value); } + assert(is_string($this->value), '$value is not a string'); + return $this->value; } /** - * {@inheritDoc} + * @inheritDoc */ - public function withValue($value) + public function withValue(array|string|float|int|bool $value): static { $new = clone $this; $new->_setValue($value); @@ -268,21 +455,20 @@ public function withValue($value) /** * Setter for the value attribute. * - * @param mixed $value The value to store. + * @param array|string|float|int|bool $value The value to store. * @return void */ - protected function _setValue($value) + protected function _setValue(array|string|float|int|bool $value): void { $this->isExpanded = is_array($value); - $this->value = $value; + $this->value = is_array($value) ? $value : (string)$value; } /** - * {@inheritDoc} + * @inheritDoc */ - public function withPath($path) + public function withPath(string $path): static { - $this->validateString($path); $new = clone $this; $new->path = $path; @@ -290,19 +476,18 @@ public function withPath($path) } /** - * {@inheritDoc} + * @inheritDoc */ - public function getPath() + public function getPath(): string { return $this->path; } /** - * {@inheritDoc} + * @inheritDoc */ - public function withDomain($domain) + public function withDomain(string $domain): static { - $this->validateString($domain); $new = clone $this; $new->domain = $domain; @@ -310,44 +495,26 @@ public function withDomain($domain) } /** - * {@inheritDoc} + * @inheritDoc */ - public function getDomain() + public function getDomain(): string { return $this->domain; } /** - * Validate that an argument is a string - * - * @param string $value The value to validate. - * @return void - * @throws \InvalidArgumentException + * @inheritDoc */ - protected function validateString($value) - { - if (!is_string($value)) { - throw new InvalidArgumentException(sprintf( - 'The provided arg must be of type `string` but `%s` given', - gettype($value) - )); - } - } - - /** - * {@inheritDoc} - */ - public function isSecure() + public function isSecure(): bool { return $this->secure; } /** - * {@inheritDoc} + * @inheritDoc */ - public function withSecure($secure) + public function withSecure(bool $secure): static { - $this->validateBool($secure); $new = clone $this; $new->secure = $secure; @@ -355,11 +522,10 @@ public function withSecure($secure) } /** - * {@inheritDoc} + * @inheritDoc */ - public function withHttpOnly($httpOnly) + public function withHttpOnly(bool $httpOnly): static { - $this->validateBool($httpOnly); $new = clone $this; $new->httpOnly = $httpOnly; @@ -367,35 +533,22 @@ public function withHttpOnly($httpOnly) } /** - * Validate that an argument is a boolean - * - * @param bool $value The value to validate. - * @return void - * @throws \InvalidArgumentException - */ - protected function validateBool($value) - { - if (!is_bool($value)) { - throw new InvalidArgumentException(sprintf( - 'The provided arg must be of type `bool` but `%s` given', - gettype($value) - )); - } - } - - /** - * {@inheritDoc} + * @inheritDoc */ - public function isHttpOnly() + public function isHttpOnly(): bool { return $this->httpOnly; } /** - * {@inheritDoc} + * @inheritDoc */ - public function withExpiry($dateTime) + public function withExpiry(DateTimeInterface $dateTime): static { + if ($dateTime instanceof DateTime) { + $dateTime = clone $dateTime; + } + $new = clone $this; $new->expiresAt = $dateTime->setTimezone(new DateTimeZone('GMT')); @@ -403,29 +556,29 @@ public function withExpiry($dateTime) } /** - * {@inheritDoc} + * @inheritDoc */ - public function getExpiry() + public function getExpiry(): ?DateTimeInterface { return $this->expiresAt; } /** - * {@inheritDoc} + * @inheritDoc */ - public function getExpiresTimestamp() + public function getExpiresTimestamp(): ?int { if (!$this->expiresAt) { return null; } - return $this->expiresAt->format('U'); + return (int)$this->expiresAt->format('U'); } /** - * {@inheritDoc} + * @inheritDoc */ - public function getFormattedExpires() + public function getFormattedExpires(): string { if (!$this->expiresAt) { return ''; @@ -435,11 +588,15 @@ public function getFormattedExpires() } /** - * {@inheritDoc} + * @inheritDoc */ - public function isExpired($time = null) + public function isExpired(?DateTimeInterface $time = null): bool { $time = $time ?: new DateTimeImmutable('now', new DateTimeZone('UTC')); + if ($time instanceof DateTime) { + $time = clone $time; + } + if (!$this->expiresAt) { return false; } @@ -448,27 +605,61 @@ public function isExpired($time = null) } /** - * {@inheritDoc} + * @inheritDoc */ - public function withNeverExpire() + public function withNeverExpire(): static { $new = clone $this; - $new->expiresAt = Chronos::createFromDate(2038, 1, 1); + $new->expiresAt = new DateTimeImmutable('2038-01-01'); return $new; } /** - * {@inheritDoc} + * @inheritDoc */ - public function withExpired() + public function withExpired(): static { $new = clone $this; - $new->expiresAt = Chronos::createFromTimestamp(1); + $new->expiresAt = new DateTimeImmutable('@1'); return $new; } + /** + * @inheritDoc + */ + public function getSameSite(): ?SameSiteEnum + { + return $this->sameSite; + } + + /** + * @inheritDoc + */ + public function withSameSite(SameSiteEnum|string|null $sameSite): static + { + $new = clone $this; + $new->sameSite = static::resolveSameSiteEnum($sameSite); + + return $new; + } + + /** + * Create SameSiteEnum instance. + * + * @param \Cake\Http\Cookie\SameSiteEnum|string|null $sameSite SameSite value + * @return \Cake\Http\Cookie\SameSiteEnum|null + */ + protected static function resolveSameSiteEnum(SameSiteEnum|string|null $sameSite): ?SameSiteEnum + { + return match (true) { + $sameSite === null => $sameSite, + $sameSite instanceof SameSiteEnum => $sameSite, + default => SameSiteEnum::from(ucfirst(strtolower($sameSite))), + }; + } + /** * Checks if a value exists in the cookie data. * @@ -478,12 +669,15 @@ public function withExpired() * @param string $path Path to check * @return bool */ - public function check($path) + public function check(string $path): bool { if ($this->isExpanded === false) { + assert(is_string($this->value), '$value is not a string'); $this->value = $this->_expand($this->value); } + assert(is_array($this->value), '$value is not an array'); + return Hash::check($this->value, $path); } @@ -494,12 +688,15 @@ public function check($path) * @param mixed $value Value to write * @return static */ - public function withAddedValue($path, $value) + public function withAddedValue(string $path, mixed $value): static { $new = clone $this; if ($new->isExpanded === false) { + assert(is_string($new->value), '$value is not a string'); $new->value = $new->_expand($new->value); } + + assert(is_array($new->value), '$value is not an array'); $new->value = Hash::insert($new->value, $path, $value); return $new; @@ -511,12 +708,16 @@ public function withAddedValue($path, $value) * @param string $path Path to remove * @return static */ - public function withoutAddedValue($path) + public function withoutAddedValue(string $path): static { $new = clone $this; if ($new->isExpanded === false) { + assert(is_string($new->value), '$value is not a string'); $new->value = $new->_expand($new->value); } + + assert(is_array($new->value), '$value is not an array'); + $new->value = Hash::remove($new->value, $path); return $new; @@ -528,12 +729,14 @@ public function withoutAddedValue($path) * This method will expand serialized complex data, * on first use. * - * @param string $path Path to read the data from + * @param string|null $path Path to read the data from * @return mixed */ - public function read($path = null) + public function read(?string $path = null): mixed { if ($this->isExpanded === false) { + assert(is_string($this->value), '$value is not a string'); + $this->value = $this->_expand($this->value); } @@ -541,6 +744,8 @@ public function read($path = null) return $this->value; } + assert(is_array($this->value), '$value is not an array'); + return Hash::get($this->value, $path); } @@ -549,20 +754,51 @@ public function read($path = null) * * @return bool */ - public function isExpanded() + public function isExpanded(): bool { return $this->isExpanded; } + /** + * @inheritDoc + */ + public function getOptions(): array + { + $options = [ + 'expires' => (int)$this->getExpiresTimestamp(), + 'path' => $this->path, + 'domain' => $this->domain, + 'secure' => $this->secure, + 'httponly' => $this->httpOnly, + ]; + + if ($this->sameSite !== null) { + $options['samesite'] = $this->sameSite->value; + } + + return $options; + } + + /** + * @inheritDoc + */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'value' => $this->getScalarValue(), + ] + $this->getOptions(); + } + /** * Implode method to keep keys are multidimensional arrays * * @param array $array Map of key and values - * @return string A json encoded string. + * @return string A JSON encoded string. */ - protected function _flatten(array $array) + protected function _flatten(array $array): string { - return json_encode($array); + return json_encode($array, JSON_THROW_ON_ERROR); } /** @@ -570,16 +806,14 @@ protected function _flatten(array $array) * Maintains reading backwards compatibility with 1.x CookieComponent::_flatten(). * * @param string $string A string containing JSON encoded data, or a bare string. - * @return string|array Map of key and values + * @return array|string Map of key and values */ - protected function _expand($string) + protected function _expand(string $string): array|string { $this->isExpanded = true; $first = substr($string, 0, 1); if ($first === '{' || $first === '[') { - $ret = json_decode($string, true); - - return ($ret !== null) ? $ret : $string; + return json_decode($string, true) ?? $string; } $array = []; diff --git a/src/Http/Cookie/CookieCollection.php b/src/Http/Cookie/CookieCollection.php index 58b9e507dd6..82abdef2b98 100644 --- a/src/Http/Cookie/CookieCollection.php +++ b/src/Http/Cookie/CookieCollection.php @@ -1,15 +1,17 @@ */ class CookieCollection implements IteratorAggregate, Countable { - /** * Cookie objects * - * @var \Cake\Http\Cookie\CookieInterface[] + * @var array */ - protected $cookies = []; + protected array $cookies = []; /** * Constructor * - * @param array $cookies Array of cookie objects + * @param array<\Cake\Http\Cookie\CookieInterface> $cookies Array of cookie objects */ public function __construct(array $cookies = []) { @@ -55,12 +62,20 @@ public function __construct(array $cookies = []) /** * Create a Cookie Collection from an array of Set-Cookie Headers * - * @param array $header The array of set-cookie header values. + * @param array $header The array of set-cookie header values. + * @param array $defaults The defaults attributes. * @return static */ - public static function createFromHeader(array $header) + public static function createFromHeader(array $header, array $defaults = []): static { - $cookies = static::parseSetCookieHeader($header); + $cookies = []; + foreach ($header as $value) { + try { + $cookies[] = Cookie::createFromHeaderString($value, $defaults); + } catch (Exception | TypeError) { + // Don't blow up on invalid cookies + } + } return new static($cookies); } @@ -71,12 +86,12 @@ public static function createFromHeader(array $header) * @param \Psr\Http\Message\ServerRequestInterface $request The request to extract cookie data from * @return static */ - public static function createFromServerRequest(ServerRequestInterface $request) + public static function createFromServerRequest(ServerRequestInterface $request): static { $data = $request->getCookieParams(); $cookies = []; foreach ($data as $name => $value) { - $cookies[] = new Cookie($name, $value); + $cookies[] = new Cookie((string)$name, $value); } return new static($cookies); @@ -87,7 +102,7 @@ public static function createFromServerRequest(ServerRequestInterface $request) * * @return int */ - public function count() + public function count(): int { return count($this->cookies); } @@ -102,7 +117,7 @@ public function count() * @param \Cake\Http\Cookie\CookieInterface $cookie Cookie instance to add. * @return static */ - public function add(CookieInterface $cookie) + public function add(CookieInterface $cookie): static { $new = clone $this; $new->cookies[$cookie->getId()] = $cookie; @@ -114,9 +129,43 @@ public function add(CookieInterface $cookie) * Get the first cookie by name. * * @param string $name The name of the cookie. + * @return \Cake\Http\Cookie\CookieInterface + * @throws \InvalidArgumentException If cookie not found. + */ + public function get(string $name): CookieInterface + { + $cookie = $this->__get($name); + + if ($cookie === null) { + throw new InvalidArgumentException( + sprintf( + 'Cookie `%s` not found. Use `has()` to check first for existence.', + $name, + ), + ); + } + + return $cookie; + } + + /** + * Check if a cookie with the given name exists + * + * @param string $name The cookie name to check. + * @return bool True if the cookie exists, otherwise false. + */ + public function has(string $name): bool + { + return $this->__get($name) !== null; + } + + /** + * Get the first cookie by name if cookie with provided name exists + * + * @param string $name The name of the cookie. * @return \Cake\Http\Cookie\CookieInterface|null */ - public function get($name) + public function __get(string $name): ?CookieInterface { $key = mb_strtolower($name); foreach ($this->cookies as $cookie) { @@ -134,16 +183,9 @@ public function get($name) * @param string $name The cookie name to check. * @return bool True if the cookie exists, otherwise false. */ - public function has($name) + public function __isset(string $name): bool { - $key = mb_strtolower($name); - foreach ($this->cookies as $cookie) { - if (mb_strtolower($cookie->getName()) === $key) { - return true; - } - } - - return false; + return $this->__get($name) !== null; } /** @@ -154,7 +196,7 @@ public function has($name) * @param string $name The name of the cookie to remove. * @return static */ - public function remove($name) + public function remove(string $name): static { $new = clone $this; $key = mb_strtolower($name); @@ -170,11 +212,11 @@ public function remove($name) /** * Checks if only valid cookie objects are in the array * - * @param array $cookies Array of cookie objects + * @param array<\Cake\Http\Cookie\CookieInterface> $cookies Array of cookie objects * @return void * @throws \InvalidArgumentException */ - protected function checkCookies(array $cookies) + protected function checkCookies(array $cookies): void { foreach ($cookies as $index => $cookie) { if (!$cookie instanceof CookieInterface) { @@ -182,9 +224,9 @@ protected function checkCookies(array $cookies) sprintf( 'Expected `%s[]` as $cookies but instead got `%s` at index %d', static::class, - is_object($cookie) ? get_class($cookie) : gettype($cookie), - $index - ) + get_debug_type($cookie), + $index, + ), ); } } @@ -193,9 +235,9 @@ protected function checkCookies(array $cookies) /** * Gets the iterator * - * @return \ArrayIterator + * @return \Traversable */ - public function getIterator() + public function getIterator(): Traversable { return new ArrayIterator($this->cookies); } @@ -212,20 +254,29 @@ public function getIterator() * is useful when you have cookie data from outside the collection you want to send. * @return \Psr\Http\Message\RequestInterface An updated request. */ - public function addToRequest(RequestInterface $request, array $extraCookies = []) + public function addToRequest(RequestInterface $request, array $extraCookies = []): RequestInterface { $uri = $request->getUri(); $cookies = $this->findMatchingCookies( $uri->getScheme(), $uri->getHost(), - $uri->getPath() ?: '/' + $uri->getPath() ?: '/', ); - $cookies = array_merge($cookies, $extraCookies); + $cookies = $extraCookies + $cookies; $cookiePairs = []; foreach ($cookies as $key => $value) { - $cookiePairs[] = sprintf("%s=%s", rawurlencode($key), rawurlencode($value)); + $cookie = sprintf('%s=%s', rawurlencode((string)$key), rawurlencode($value)); + $size = strlen($cookie); + if ($size > 4096) { + triggerWarning(sprintf( + 'The cookie `%s` exceeds the recommended maximum cookie length of 4096 bytes.', + $key, + )); + } + $cookiePairs[] = $cookie; } - if (empty($cookiePairs)) { + + if (!$cookiePairs) { return $request; } @@ -238,9 +289,9 @@ public function addToRequest(RequestInterface $request, array $extraCookies = [] * @param string $scheme The http scheme to match * @param string $host The host to match. * @param string $path The path to match - * @return array An array of cookie name/value pairs + * @return array An array of cookie name/value pairs */ - protected function findMatchingCookies($scheme, $host, $path) + protected function findMatchingCookies(string $scheme, string $host, string $path): array { $out = []; $now = new DateTimeImmutable('now', new DateTimeZone('UTC')); @@ -248,11 +299,11 @@ protected function findMatchingCookies($scheme, $host, $path) if ($scheme === 'http' && $cookie->isSecure()) { continue; } - if (strpos($path, $cookie->getPath()) !== 0) { + if (!str_starts_with($path, $cookie->getPath())) { continue; } $domain = $cookie->getDomain(); - $leadingDot = substr($domain, 0, 1) === '.'; + $leadingDot = str_starts_with($domain, '.'); if ($leadingDot) { $domain = ltrim($domain, '.'); } @@ -279,14 +330,16 @@ protected function findMatchingCookies($scheme, $host, $path) * @param \Psr\Http\Message\RequestInterface $request Request to get cookie context from. * @return static */ - public function addFromResponse(ResponseInterface $response, RequestInterface $request) + public function addFromResponse(ResponseInterface $response, RequestInterface $request): static { $uri = $request->getUri(); $host = $uri->getHost(); $path = $uri->getPath() ?: '/'; - $cookies = static::parseSetCookieHeader($response->getHeader('Set-Cookie')); - $cookies = $this->setRequestDefaults($cookies, $host, $path); + $cookies = static::createFromHeader( + $response->getHeader('Set-Cookie'), + ['domain' => $host, 'path' => $path], + ); $new = clone $this; foreach ($cookies as $cookie) { $new->cookies[$cookie->getId()] = $cookie; @@ -296,91 +349,6 @@ public function addFromResponse(ResponseInterface $response, RequestInterface $r return $new; } - /** - * Apply path and host to the set of cookies if they are not set. - * - * @param array $cookies An array of cookies to update. - * @param string $host The host to set. - * @param string $path The path to set. - * @return array An array of updated cookies. - */ - protected function setRequestDefaults(array $cookies, $host, $path) - { - $out = []; - foreach ($cookies as $name => $cookie) { - if (!$cookie->getDomain()) { - $cookie = $cookie->withDomain($host); - } - if (!$cookie->getPath()) { - $cookie = $cookie->withPath($path); - } - $out[] = $cookie; - } - - return $out; - } - - /** - * Parse Set-Cookie headers into array - * - * @param array $values List of Set-Cookie Header values. - * @return \Cake\Http\Cookie\Cookie[] An array of cookie objects - */ - protected static function parseSetCookieHeader($values) - { - $cookies = []; - foreach ($values as $value) { - $value = rtrim($value, ';'); - $parts = preg_split('/\;[ \t]*/', $value); - - $name = false; - $cookie = [ - 'value' => '', - 'path' => '', - 'domain' => '', - 'secure' => false, - 'httponly' => false, - 'expires' => null, - 'max-age' => null - ]; - foreach ($parts as $i => $part) { - if (strpos($part, '=') !== false) { - list($key, $value) = explode('=', $part, 2); - } else { - $key = $part; - $value = true; - } - if ($i === 0) { - $name = $key; - $cookie['value'] = urldecode($value); - continue; - } - $key = strtolower($key); - if (array_key_exists($key, $cookie) && !strlen($cookie[$key])) { - $cookie[$key] = $value; - } - } - $expires = null; - if ($cookie['max-age'] !== null) { - $expires = new DateTimeImmutable('@' . (time() + $cookie['max-age'])); - } elseif ($cookie['expires']) { - $expires = new DateTimeImmutable('@' . strtotime($cookie['expires'])); - } - - $cookies[] = new Cookie( - $name, - $cookie['value'], - $expires, - $cookie['path'], - $cookie['domain'], - $cookie['secure'], - $cookie['httponly'] - ); - } - - return $cookies; - } - /** * Remove expired cookies from the collection. * @@ -388,16 +356,18 @@ protected static function parseSetCookieHeader($values) * @param string $path The path to check for expired cookies on. * @return void */ - protected function removeExpiredCookies($host, $path) + protected function removeExpiredCookies(string $host, string $path): void { $time = new DateTimeImmutable('now', new DateTimeZone('UTC')); $hostPattern = '/' . preg_quote($host, '/') . '$/'; foreach ($this->cookies as $i => $cookie) { - $expired = $cookie->isExpired($time); - $pathMatches = strpos($path, $cookie->getPath()) === 0; + if (!$cookie->isExpired($time)) { + continue; + } + $pathMatches = str_starts_with($path, $cookie->getPath()); $hostMatches = preg_match($hostPattern, $cookie->getDomain()); - if ($pathMatches && $hostMatches && $expired) { + if ($pathMatches && $hostMatches) { unset($this->cookies[$i]); } } diff --git a/src/Http/Cookie/CookieInterface.php b/src/Http/Cookie/CookieInterface.php index 3d752812f5b..c32ff0fc46d 100644 --- a/src/Http/Cookie/CookieInterface.php +++ b/src/Http/Cookie/CookieInterface.php @@ -1,30 +1,65 @@ + */ + public const SAMESITE_VALUES = [ + self::SAMESITE_LAX, + self::SAMESITE_STRICT, + self::SAMESITE_NONE, + ]; /** * Sets the cookie name @@ -32,38 +67,38 @@ interface CookieInterface * @param string $name Name of the cookie * @return static */ - public function withName($name); + public function withName(string $name): static; /** * Gets the cookie name * * @return string */ - public function getName(); + public function getName(): string; /** * Gets the cookie value * - * @return string|array + * @return array|string */ - public function getValue(); + public function getValue(): array|string; /** - * Gets the cookie value as a string. + * Gets the cookie value as scalar. * * This will collapse any complex data in the cookie with json_encode() * * @return string */ - public function getStringValue(); + public function getScalarValue(): string; /** * Create a cookie with an updated value. * - * @param string|array $value Value of the cookie to set + * @param array|string|float|int|bool $value Value of the cookie to set * @return static */ - public function withValue($value); + public function withValue(array|string|float|int|bool $value): static; /** * Get the id for a cookie @@ -72,14 +107,14 @@ public function withValue($value); * * @return string */ - public function getId(); + public function getId(): string; /** * Get the path attribute. * * @return string */ - public function getPath(); + public function getPath(): string; /** * Create a new cookie with an updated path @@ -87,14 +122,14 @@ public function getPath(); * @param string $path Sets the path * @return static */ - public function withPath($path); + public function withPath(string $path): static; /** * Get the domain attribute. * * @return string */ - public function getDomain(); + public function getDomain(): string; /** * Create a cookie with an updated domain @@ -102,46 +137,43 @@ public function getDomain(); * @param string $domain Domain to set * @return static */ - public function withDomain($domain); + public function withDomain(string $domain): static; /** * Get the current expiry time * - * @return \DateTime|\DateTimeImmutable|null Timestamp of expiry or null + * @return \DateTimeInterface|null Timestamp of expiry or null */ - public function getExpiry(); + public function getExpiry(): ?DateTimeInterface; /** * Get the timestamp from the expiration time * - * Timestamps are strings as large timestamps can overflow MAX_INT - * in 32bit systems. - * - * @return string|null The expiry time as a string timestamp. + * @return int|null The expiry time as an integer. */ - public function getExpiresTimestamp(); + public function getExpiresTimestamp(): ?int; /** * Builds the expiration value part of the header string * * @return string */ - public function getFormattedExpires(); + public function getFormattedExpires(): string; /** * Create a cookie with an updated expiration date * - * @param \DateTime|\DateTimeImmutable $dateTime Date time object + * @param \DateTimeInterface $dateTime Date time object * @return static */ - public function withExpiry($dateTime); + public function withExpiry(DateTimeInterface $dateTime): static; /** * Create a new cookie that will virtually never expire. * * @return static */ - public function withNeverExpire(); + public function withNeverExpire(): static; /** * Create a new cookie that will expire/delete the cookie from the browser. @@ -150,24 +182,24 @@ public function withNeverExpire(); * * @return static */ - public function withExpired(); + public function withExpired(): static; /** * Check if a cookie is expired when compared to $time * * Cookies without an expiration date always return false. * - * @param \DateTime|\DateTimeImmutable $time The time to test against. Defaults to 'now' in UTC. + * @param \DateTimeInterface|null $time The time to test against. Defaults to 'now' in UTC. * @return bool */ - public function isExpired($time = null); + public function isExpired(?DateTimeInterface $time = null): bool; /** * Check if the cookie is HTTP only * * @return bool */ - public function isHttpOnly(); + public function isHttpOnly(): bool; /** * Create a cookie with HTTP Only updated @@ -175,14 +207,14 @@ public function isHttpOnly(); * @param bool $httpOnly HTTP Only * @return static */ - public function withHttpOnly($httpOnly); + public function withHttpOnly(bool $httpOnly): static; /** * Check if the cookie is secure * * @return bool */ - public function isSecure(); + public function isSecure(): bool; /** * Create a cookie with Secure updated @@ -190,12 +222,41 @@ public function isSecure(); * @param bool $secure Secure attribute value * @return static */ - public function withSecure($secure); + public function withSecure(bool $secure): static; + + /** + * Get the SameSite attribute. + * + * @return \Cake\Http\Cookie\SameSiteEnum|null + */ + public function getSameSite(): ?SameSiteEnum; + + /** + * Create a cookie with an updated SameSite option. + * + * @param \Cake\Http\Cookie\SameSiteEnum|string|null $sameSite Value for to set for Samesite option. + * @return static + */ + public function withSameSite(SameSiteEnum|string|null $sameSite): static; + + /** + * Get cookie options + * + * @return array + */ + public function getOptions(): array; + + /** + * Get cookie data as array. + * + * @return array With keys `name`, `value`, `expires` etc. options. + */ + public function toArray(): array; /** * Returns the cookie as header value * * @return string */ - public function toHeaderValue(); + public function toHeaderValue(): string; } diff --git a/src/Http/Cookie/SameSiteEnum.php b/src/Http/Cookie/SameSiteEnum.php new file mode 100644 index 00000000000..79bc59e0541 --- /dev/null +++ b/src/Http/Cookie/SameSiteEnum.php @@ -0,0 +1,23 @@ + + */ + protected array $_headers = []; + + /** + * Constructor. + * + * @param \Psr\Http\Message\ResponseInterface $response The response object to add headers onto. + * @param string $origin The request's Origin header. + * @param bool $isSsl Whether the request was over SSL. + */ + public function __construct(ResponseInterface $response, string $origin, bool $isSsl = false) + { + $this->_origin = $origin; + $this->_isSsl = $isSsl; + $this->_response = $response; + } + + /** + * Apply the queued headers to the response. + * + * If the builder has no Origin, or if there are no allowed domains, + * or if the allowed domains do not match the Origin header no headers will be applied. + * + * @return \Psr\Http\Message\ResponseInterface A new instance of the response with new headers. + */ + public function build(): ResponseInterface + { + $response = $this->_response; + if (empty($this->_origin)) { + return $response; + } + + if (isset($this->_headers['Access-Control-Allow-Origin'])) { + foreach ($this->_headers as $key => $value) { + $response = $response->withHeader($key, $value); + } + } + + return $response; + } + + /** + * Set the list of allowed domains. + * + * Accepts a string or an array of domains that have CORS enabled. + * You can use `*.example.com` wildcards to accept subdomains, or `*` to allow all domains + * + * @param array|string $domains The allowed domains + * @return $this + */ + public function allowOrigin(array|string $domains) + { + $allowed = $this->_normalizeDomains((array)$domains); + foreach ($allowed as $domain) { + if (!preg_match($domain['preg'], $this->_origin)) { + continue; + } + $value = $domain['original'] === '*' ? '*' : $this->_origin; + $this->_headers['Access-Control-Allow-Origin'] = $value; + break; + } + + return $this; + } + + /** + * Normalize the origin to regular expressions and put in an array format + * + * @param array $domains Domain names to normalize. + * @return array> + */ + protected function _normalizeDomains(array $domains): array + { + $result = []; + foreach ($domains as $domain) { + if ($domain === '*') { + $result[] = ['preg' => '@.@', 'original' => '*']; + continue; + } + $original = $domain; + $preg = $domain; + if (!str_contains($domain, '://')) { + $preg = ($this->_isSsl ? 'https://' : 'http://') . $domain; + } + $preg = '@^' . str_replace('\*', '.*', preg_quote($preg, '@')) . '$@'; + $result[] = compact('original', 'preg'); + } + + return $result; + } + + /** + * Set the list of allowed HTTP Methods. + * + * @param array $methods The allowed HTTP methods + * @return $this + */ + public function allowMethods(array $methods) + { + $this->_headers['Access-Control-Allow-Methods'] = implode(', ', $methods); + + return $this; + } + + /** + * Enable cookies to be sent in CORS requests. + * + * @return $this + */ + public function allowCredentials() + { + $this->_headers['Access-Control-Allow-Credentials'] = 'true'; + + return $this; + } + + /** + * Allowed headers that can be sent in CORS requests. + * + * @param array $headers The list of headers to accept in CORS requests. + * @return $this + */ + public function allowHeaders(array $headers) + { + $this->_headers['Access-Control-Allow-Headers'] = implode(', ', $headers); + + return $this; + } + + /** + * Define the headers a client library/browser can expose to scripting + * + * @param array $headers The list of headers to expose CORS responses + * @return $this + */ + public function exposeHeaders(array $headers) + { + $this->_headers['Access-Control-Expose-Headers'] = implode(', ', $headers); + + return $this; + } + + /** + * Define the max-age preflight OPTIONS requests are valid for. + * + * @param string|int $age The max-age for OPTIONS requests in seconds + * @return $this + */ + public function maxAge(string|int $age) + { + $this->_headers['Access-Control-Max-Age'] = $age; + + return $this; + } +} diff --git a/src/Http/Exception/BadRequestException.php b/src/Http/Exception/BadRequestException.php new file mode 100644 index 00000000000..8f86982e859 --- /dev/null +++ b/src/Http/Exception/BadRequestException.php @@ -0,0 +1,43 @@ +|string> + */ + protected array $headers = []; + + /** + * Set a single HTTP response header. + * + * @param non-empty-string $header Header name + * @param array|string|null $value Header value + * @return void + */ + public function setHeader(string $header, array|string|null $value = null): void + { + $this->headers[$header] = $value ?? ''; + } + + /** + * Sets HTTP response headers. + * + * @param array|string> $headers Array of header name and value pairs. + * @return void + */ + public function setHeaders(array $headers): void + { + $this->headers = $headers; + } + + /** + * Returns array of response headers. + * + * @return array|string> + */ + public function getHeaders(): array + { + return $this->headers; + } +} diff --git a/src/Http/Exception/InternalErrorException.php b/src/Http/Exception/InternalErrorException.php new file mode 100644 index 00000000000..43d2816ec7a --- /dev/null +++ b/src/Http/Exception/InternalErrorException.php @@ -0,0 +1,38 @@ +|string> $headers The headers that should be sent in the unauthorized challenge response. + */ + public function __construct(string $target, int $code = 302, array $headers = []) + { + parent::__construct($target, $code); + + foreach ($headers as $key => $value) { + $this->setHeader($key, (array)$value); + } + } +} diff --git a/src/Http/Exception/ServiceUnavailableException.php b/src/Http/Exception/ServiceUnavailableException.php new file mode 100644 index 00000000000..0287fc96ffa --- /dev/null +++ b/src/Http/Exception/ServiceUnavailableException.php @@ -0,0 +1,43 @@ + + */ + protected array $_defaultConfig = [ + 'key' => 'flash', + 'element' => 'default', + 'plugin' => null, + 'params' => [], + 'clear' => false, + 'duplicate' => true, + ]; + + /** + * @var \Cake\Http\Session + */ + protected Session $session; + + /** + * Constructor + * + * @param \Cake\Http\Session $session Session instance. + * @param array $config Config array. + * @see FlashMessage::set() For list of valid config keys. + */ + public function __construct(Session $session, array $config = []) + { + $this->session = $session; + $this->setConfig($config); + } + + /** + * Store flash messages that can be output in the view. + * + * If you make consecutive calls to this method, the messages will stack + * (if they are set with the same flash key) + * + * ### Options: + * + * - `key` The key to set under the session's Flash key. + * - `element` The element used to render the flash message. You can use + * `'SomePlugin.name'` style value for flash elements from a plugin. + * - `plugin` Plugin name to use element from. + * - `params` An array of variables to be made available to the element. + * - `clear` A bool stating if the current stack should be cleared to start a new one. + * - `escape` Set to false to allow templates to print out HTML content. + * + * @param string $message Message to be flashed. + * @param array $options An array of options + * @return void + * @see FlashMessage::$_defaultConfig For default values for the options. + */ + public function set(string $message, array $options = []): void + { + $options += (array)$this->getConfig(); + + if (isset($options['escape']) && !isset($options['params']['escape'])) { + $options['params']['escape'] = $options['escape']; + } + + [$plugin, $element] = pluginSplit($options['element']); + if ($options['plugin']) { + $plugin = $options['plugin']; + } + + if ($plugin) { + $options['element'] = $plugin . '.flash/' . $element; + } else { + $options['element'] = 'flash/' . $element; + } + + $messages = []; + if (!$options['clear']) { + $messages = (array)$this->session->read('Flash.' . $options['key']); + } + + if (!$options['duplicate']) { + foreach ($messages as $existingMessage) { + if ($existingMessage['message'] === $message) { + return; + } + } + } + + $messages[] = [ + 'message' => $message, + 'key' => $options['key'], + 'element' => $options['element'], + 'params' => $options['params'], + ]; + + $this->session->write('Flash.' . $options['key'], $messages); + } + + /** + * Set an exception's message as flash message. + * + * The following options will be set by default if unset: + * ``` + * 'element' => 'error', + * `params' => ['code' => $exception->getCode()] + * ``` + * + * @param \Throwable $exception Exception instance. + * @param array $options An array of options. + * @return void + * @see FlashMessage::set() For list of valid options + */ + public function setExceptionMessage(Throwable $exception, array $options = []): void + { + $options['element'] ??= 'error'; + $options['params']['code'] ??= $exception->getCode(); + + $message = $exception->getMessage(); + $this->set($message, $options); + } + + /** + * Get the messages for given key and remove from session. + * + * @param string $key The key for get messages for. + * @return array|null + */ + public function consume(string $key): ?array + { + return $this->session->consume("Flash.{$key}"); + } + + /** + * Set a success message. + * + * The `'element'` option will be set to `'success'`. + * + * @param string $message Message to flash. + * @param array $options An array of options. + * @return void + * @see FlashMessage::set() For list of valid options + */ + public function success(string $message, array $options = []): void + { + $options['element'] = 'success'; + $this->set($message, $options); + } + + /** + * Set a success message. + * + * The `'element'` option will be set to `'error'`. + * + * @param string $message Message to flash. + * @param array $options An array of options. + * @return void + * @see FlashMessage::set() For list of valid options + */ + public function error(string $message, array $options = []): void + { + $options['element'] = 'error'; + $this->set($message, $options); + } + + /** + * Set a warning message. + * + * The `'element'` option will be set to `'warning'`. + * + * @param string $message Message to flash. + * @param array $options An array of options. + * @return void + * @see FlashMessage::set() For list of valid options + */ + public function warning(string $message, array $options = []): void + { + $options['element'] = 'warning'; + $this->set($message, $options); + } + + /** + * Set an info message. + * + * The `'element'` option will be set to `'info'`. + * + * @param string $message Message to flash. + * @param array $options An array of options. + * @return void + * @see FlashMessage::set() For list of valid options + */ + public function info(string $message, array $options = []): void + { + $options['element'] = 'info'; + $this->set($message, $options); + } +} diff --git a/src/Http/HeaderUtility.php b/src/Http/HeaderUtility.php new file mode 100644 index 00000000000..1b845f0f4a5 --- /dev/null +++ b/src/Http/HeaderUtility.php @@ -0,0 +1,131 @@ + + */ + protected static function parseLinkItem(string $value): array + { + preg_match('/<(.*)>[; ]?[; ]?(.*)?/i', $value, $matches); + + if ($matches === []) { + return []; + } + + $url = $matches[1]; + $parsedParams = ['link' => $url]; + + $params = $matches[2] ?? null; + if (!$params) { + return $parsedParams; + } + + $explodedParams = explode(';', $params); + foreach ($explodedParams as $param) { + $explodedParam = explode('=', $param); + $trimmedKey = trim($explodedParam[0]); + $trimmedValue = trim($explodedParam[1], '"'); + if ($trimmedKey === 'title*') { + // See https://www.rfc-editor.org/rfc/rfc8187#section-3.2.3 + preg_match("/(.*)'(.*)'(.*)/i", $trimmedValue, $matches); + assert(!empty($matches[1]) && !empty($matches[2]) && !empty($matches[3])); + $trimmedValue = [ + 'language' => $matches[2], + 'encoding' => $matches[1], + 'value' => urldecode($matches[3]), + ]; + } + $parsedParams[$trimmedKey] = $trimmedValue; + } + + return $parsedParams; + } + + /** + * Parse the Accept header value into weight => value mapping. + * + * @param string $header The header value to parse + * @return array> + */ + public static function parseAccept(string $header): array + { + $accept = []; + if (!$header) { + return $accept; + } + + $headers = explode(',', $header); + foreach (array_filter($headers) as $value) { + $prefValue = '1.0'; + $value = trim($value); + + $semiPos = strpos($value, ';'); + if ($semiPos !== false) { + $params = explode(';', $value); + $value = trim($params[0]); + foreach ($params as $param) { + $qPos = strpos($param, 'q='); + if ($qPos !== false) { + $prefValue = substr($param, $qPos + 2); + } + } + } + + $accept[$prefValue] ??= []; + if ($prefValue) { + $accept[$prefValue][] = $value; + } + } + krsort($accept); + + return $accept; + } + + /** + * @param string $value The WWW-Authenticate header + * @return array + */ + public static function parseWwwAuthenticate(string $value): array + { + preg_match_all( + '@(\w+)=(?:(?:")([^"]+)"|([^\s,$]+))@', + $value, + $matches, + PREG_SET_ORDER, + ); + + $return = []; + foreach ($matches as $match) { + /** @phpstan-ignore-next-line */ + $return[$match[1]] = $match[3] ?? $match[2]; + } + + return $return; + } +} diff --git a/src/Http/LICENSE.txt b/src/Http/LICENSE.txt new file mode 100644 index 00000000000..b938c9e8ed3 --- /dev/null +++ b/src/Http/LICENSE.txt @@ -0,0 +1,22 @@ +The MIT License (MIT) + +CakePHP(tm) : The Rapid Development PHP Framework (https://cakephp.org) +Copyright (c) 2005-2020, Cake Software Foundation, Inc. (https://cakefoundation.org) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/Http/Link/Link.php b/src/Http/Link/Link.php new file mode 100644 index 00000000000..0d748ce0ca8 --- /dev/null +++ b/src/Http/Link/Link.php @@ -0,0 +1,158 @@ +withRel('collection'); + * $link = $link->withAttribute('type', 'application/json'); + * ``` + */ +class Link implements EvolvableLinkInterface +{ + /** + * The link relations. + * + * @var array + */ + private array $rels; + + /** + * The link attributes. + * + * @var array> + */ + private array $attributes; + + /** + * Constructor. + * + * @param string $href The link URI. + * @param array|string $rels The link relation(s). + * @param array> $attributes Additional attributes. + */ + public function __construct( + private string $href = '', + string|array $rels = [], + array $attributes = [], + ) { + $this->rels = is_string($rels) ? [$rels] : $rels; + $this->attributes = $attributes; + } + + /** + * @inheritDoc + */ + public function getHref(): string + { + return $this->href; + } + + /** + * @inheritDoc + */ + public function isTemplated(): bool + { + return str_contains($this->href, '{') && str_contains($this->href, '}'); + } + + /** + * @inheritDoc + */ + public function getRels(): array + { + return $this->rels; + } + + /** + * @inheritDoc + */ + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * @inheritDoc + */ + public function withHref(string|Stringable $href): static + { + $new = clone $this; + $new->href = (string)$href; + + return $new; + } + + /** + * @inheritDoc + */ + public function withRel(string $rel): static + { + $new = clone $this; + if (!in_array($rel, $new->rels, true)) { + $new->rels[] = $rel; + } + + return $new; + } + + /** + * @inheritDoc + */ + public function withoutRel(string $rel): static + { + $new = clone $this; + $new->rels = array_values(array_filter( + $new->rels, + fn(string $r): bool => $r !== $rel, + )); + + return $new; + } + + /** + * @inheritDoc + */ + public function withAttribute(string $attribute, string|Stringable|int|float|bool|array $value): static + { + $new = clone $this; + $new->attributes[$attribute] = $value instanceof Stringable ? (string)$value : $value; + + return $new; + } + + /** + * @inheritDoc + */ + public function withoutAttribute(string $attribute): static + { + $new = clone $this; + unset($new->attributes[$attribute]); + + return $new; + } +} diff --git a/src/Http/Link/LinkProvider.php b/src/Http/Link/LinkProvider.php new file mode 100644 index 00000000000..f11804f6294 --- /dev/null +++ b/src/Http/Link/LinkProvider.php @@ -0,0 +1,113 @@ +getLinks() as $link) { + * echo $link->getHref(); + * } + * ``` + */ +class LinkProvider implements EvolvableLinkProviderInterface +{ + /** + * The links. + * + * @var array<\Psr\Link\LinkInterface> + */ + private array $links; + + /** + * Constructor. + * + * @param iterable<\Psr\Link\LinkInterface> $links Initial links. + */ + public function __construct(iterable $links = []) + { + $this->links = $links instanceof Traversable + ? iterator_to_array($links) + : $links; + } + + /** + * @inheritDoc + */ + public function getLinks(): iterable + { + return $this->links; + } + + /** + * @inheritDoc + */ + public function getLinksByRel(string $rel): iterable + { + return array_filter( + $this->links, + fn(LinkInterface $link): bool => in_array($rel, $link->getRels(), true), + ); + } + + /** + * @inheritDoc + */ + public function withLink(LinkInterface $link): static + { + $new = clone $this; + + // Check if link already exists (by reference) + foreach ($new->links as $existing) { + if ($existing === $link) { + return $new; + } + } + + $new->links[] = $link; + + return $new; + } + + /** + * @inheritDoc + */ + public function withoutLink(LinkInterface $link): static + { + $new = clone $this; + $new->links = array_values(array_filter( + $new->links, + fn(LinkInterface $l): bool => $l !== $link, + )); + + return $new; + } +} diff --git a/src/Http/Middleware/BodyParserMiddleware.php b/src/Http/Middleware/BodyParserMiddleware.php new file mode 100644 index 00000000000..6d2ca272ac1 --- /dev/null +++ b/src/Http/Middleware/BodyParserMiddleware.php @@ -0,0 +1,216 @@ + + */ + protected array $parsers = []; + + /** + * The HTTP methods to parse data on. + * + * @var array + */ + protected array $methods = ['PUT', 'POST', 'PATCH', 'DELETE']; + + /** + * Constructor + * + * ### Options + * + * - `json` Set to false to disable JSON body parsing. + * - `xml` Set to true to enable XML parsing. Defaults to false, as XML + * handling requires more care than JSON does. + * - `methods` The HTTP methods to parse on. Defaults to PUT, POST, PATCH DELETE. + * + * @param array $options The options to use. See above. + */ + public function __construct(array $options = []) + { + $options += ['json' => true, 'xml' => false, 'methods' => null]; + if ($options['json']) { + $this->addParser( + ['application/json', 'text/json'], + $this->decodeJson(...), + ); + } + if ($options['xml']) { + $this->addParser( + ['application/xml', 'text/xml'], + $this->decodeXml(...), + ); + } + if ($options['methods']) { + $this->setMethods($options['methods']); + } + } + + /** + * Set the HTTP methods to parse request bodies on. + * + * @param array $methods The methods to parse data on. + * @return $this + */ + public function setMethods(array $methods) + { + $this->methods = $methods; + + return $this; + } + + /** + * Get the HTTP methods to parse request bodies on. + * + * @return array + */ + public function getMethods(): array + { + return $this->methods; + } + + /** + * Add a parser. + * + * Map a set of content-type header values to be parsed by the $parser. + * + * ### Example + * + * An naive CSV request body parser could be built like so: + * + * ``` + * $parser->addParser(['text/csv'], function ($body) { + * return str_getcsv($body); + * }); + * ``` + * + * @param array $types An array of content-type header values to match. eg. application/json + * @param \Closure $parser The parser function. Must return an array of data to be inserted + * into the request. + * @return $this + */ + public function addParser(array $types, Closure $parser) + { + foreach ($types as $type) { + $type = strtolower($type); + $this->parsers[$type] = $parser; + } + + return $this; + } + + /** + * Get the current parsers + * + * @return array<\Closure> + */ + public function getParsers(): array + { + return $this->parsers; + } + + /** + * Apply the middleware. + * + * Will modify the request adding a parsed body if the content-type is known. + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request. + * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler. + * @return \Psr\Http\Message\ResponseInterface A response. + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if (!in_array($request->getMethod(), $this->methods, true)) { + return $handler->handle($request); + } + [$type] = explode(';', $request->getHeaderLine('Content-Type')); + $type = strtolower($type); + if (!isset($this->parsers[$type])) { + return $handler->handle($request); + } + + $parser = $this->parsers[$type]; + $result = $parser($request->getBody()->getContents()); + if (!is_array($result)) { + throw new BadRequestException(); + } + $request = $request->withParsedBody($result); + + return $handler->handle($request); + } + + /** + * Decode JSON into an array. + * + * @param string $body The request body to decode + * @return array|null + */ + protected function decodeJson(string $body): ?array + { + if ($body === '') { + return []; + } + $decoded = json_decode($body, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return null; + } + + return (array)$decoded; + } + + /** + * Decode XML into an array. + * + * @param string $body The request body to decode + * @return array + */ + protected function decodeXml(string $body): array + { + try { + $xml = Xml::build($body, ['return' => 'domdocument', 'readFile' => false]); + // We might not get child nodes if there are nested inline entities. + /** @var \DOMNodeList<\DOMNode> $domNodeList */ + $domNodeList = $xml->childNodes; + if ((int)$domNodeList->length > 0) { + return Xml::toArray($xml); + } + + return []; + } catch (XmlException) { + return []; + } + } +} diff --git a/src/Http/Middleware/ClosureDecoratorMiddleware.php b/src/Http/Middleware/ClosureDecoratorMiddleware.php new file mode 100644 index 00000000000..9d024b61d49 --- /dev/null +++ b/src/Http/Middleware/ClosureDecoratorMiddleware.php @@ -0,0 +1,81 @@ +callable = $callable; + } + + /** + * Run the callable to process an incoming server request. + * + * @param \Psr\Http\Message\ServerRequestInterface $request Request instance. + * @param \Psr\Http\Server\RequestHandlerInterface $handler Request handler instance. + * @return \Psr\Http\Message\ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return ($this->callable)( + $request, + $handler, + ); + } + + /** + * @internal + * @return \Closure + */ + public function getCallable(): Closure + { + return $this->callable; + } +} diff --git a/src/Http/Middleware/CspMiddleware.php b/src/Http/Middleware/CspMiddleware.php new file mode 100644 index 00000000000..1b15a2c4440 --- /dev/null +++ b/src/Http/Middleware/CspMiddleware.php @@ -0,0 +1,97 @@ + + */ + protected array $_defaultConfig = [ + 'scriptNonce' => false, + 'styleNonce' => false, + ]; + + /** + * Constructor + * + * @param \ParagonIE\CSPBuilder\CSPBuilder|array $csp CSP object or config array + * @param array $config Configuration options. + */ + public function __construct(CSPBuilder|array $csp, array $config = []) + { + if (!class_exists(CSPBuilder::class)) { + throw new CakeException('You must install paragonie/csp-builder to use CspMiddleware'); + } + $this->setConfig($config); + + if (!$csp instanceof CSPBuilder) { + $csp = new CSPBuilder($csp); + } + + $this->csp = $csp; + } + + /** + * Add nonces (if enabled) to the request and apply the CSP header to the response. + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request. + * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler. + * @return \Psr\Http\Message\ResponseInterface A response. + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($this->getConfig('scriptNonce')) { + $request = $request->withAttribute('cspScriptNonce', $this->csp->nonce('script-src')); + } + if ($this->getConfig('styleNonce')) { + $request = $request->withAttribute('cspStyleNonce', $this->csp->nonce('style-src')); + } + $response = $handler->handle($request); + + /** @var \Psr\Http\Message\ResponseInterface */ + return $this->csp->injectCSPHeader($response); + } +} diff --git a/src/Http/Middleware/CsrfProtectionMiddleware.php b/src/Http/Middleware/CsrfProtectionMiddleware.php index 70a4d18afcc..17875c2acd9 100644 --- a/src/Http/Middleware/CsrfProtectionMiddleware.php +++ b/src/Http/Middleware/CsrfProtectionMiddleware.php @@ -1,32 +1,42 @@ Form->create(...)` is used in a view. + * + * @see https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie */ -class CsrfProtectionMiddleware +class CsrfProtectionMiddleware implements MiddlewareInterface { /** - * Default config for the CSRF handling. + * Config for the CSRF handling. * - * - `cookieName` = The name of the cookie to send. - * - `expiry` = How long the CSRF token should last. Defaults to browser session. - * - `secure` = Whether or not the cookie will be set with the Secure flag. Defaults to false. - * - `httpOnly` = Whether or not the cookie will be set with the HttpOnly flag. Defaults to false. - * - `field` = The form field to check. Changing this will also require configuring + * - `cookieName` The name of the cookie to send. + * - `expiry` A strtotime compatible value of how long the CSRF token should last. + * Defaults to browser session. + * - `secure` Whether the cookie will be set with the Secure flag. Defaults to false. + * - `httponly` Whether the cookie will be set with the HttpOnly flag. Defaults to false. + * - `samesite` "SameSite" attribute for cookies. Defaults to `null`. + * Valid values: `CookieInterface::SAMESITE_LAX`, `CookieInterface::SAMESITE_STRICT`, + * `CookieInterface::SAMESITE_NONE` or `null`. + * - `field` The form field to check. Changing this will also require configuring * FormHelper. * - * @var array + * @var array */ - protected $_defaultConfig = [ + protected array $_config = [ 'cookieName' => 'csrfToken', 'expiry' => 0, 'secure' => false, - 'httpOnly' => false, + 'httponly' => false, + 'samesite' => null, 'field' => '_csrfToken', ]; /** - * Configuration + * Callback for deciding whether to skip the token check for particular request. + * + * CSRF protection token check will be skipped if the callback returns `true`. + * + * @var callable|null + */ + protected $skipCheckCallback; + + /** + * @var int + */ + public const TOKEN_VALUE_LENGTH = 16; + + /** + * Tokens have an hmac generated so we can ensure + * that tokens were generated by our application. + * + * Should be TOKEN_VALUE_LENGTH + strlen(hmac) + * + * We are currently using sha1 for the hmac which + * creates 40 bytes. * - * @var array + * @var int */ - protected $_config = []; + public const TOKEN_WITH_CHECKSUM_LENGTH = 56; /** * Constructor * - * @param array $config Config options. See $_defaultConfig for valid keys. + * @param array $config Config options. See $_config for valid keys. */ public function __construct(array $config = []) { - $this->_config = $config + $this->_defaultConfig; + $this->_config = $config + $this->_config; } /** * Checks and sets the CSRF token depending on the HTTP verb. * - * @param \Cake\Http\ServerRequest $request The request. - * @param \Cake\Http\Response $response The response. - * @param callable $next Callback to invoke the next middleware. - * @return \Cake\Http\Response A response + * @param \Psr\Http\Message\ServerRequestInterface $request The request. + * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler. + * @return \Psr\Http\Message\ResponseInterface A response. */ - public function __invoke(ServerRequest $request, Response $response, $next) + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { + $method = $request->getMethod(); + $hasData = in_array($method, ['PUT', 'POST', 'DELETE', 'PATCH'], true) + || $request->getParsedBody(); + + if ( + $hasData + && $this->skipCheckCallback !== null + && call_user_func($this->skipCheckCallback, $request) === true + ) { + $request = $this->_unsetTokenField($request); + + return $handler->handle($request); + } + if ($request->getAttribute('csrfToken')) { + throw new CakeException( + 'A CSRF token is already set in the request.' . + "\n" . + 'Ensure you do not have the CSRF middleware applied more than once. ' . + 'Check both your `Application::middleware()` method and `config/routes.php`.', + ); + } + $cookies = $request->getCookieParams(); $cookieData = Hash::get($cookies, $this->_config['cookieName']); - if (strlen($cookieData) > 0) { - $params = $request->getAttribute('params'); - $params['_csrfToken'] = $cookieData; - $request = $request->withAttribute('params', $params); + if (is_string($cookieData) && $cookieData !== '') { + try { + $request = $request->withAttribute('csrfToken', $this->saltToken($cookieData)); + } catch (InvalidArgumentException) { + $cookieData = null; + } } - $method = $request->getMethod(); if ($method === 'GET' && $cookieData === null) { - $token = $this->_createToken(); - $request = $this->_addTokenToRequest($token, $request); - $response = $this->_addTokenCookie($token, $request, $response); + $token = $this->createToken(); + $request = $request->withAttribute('csrfToken', $this->saltToken($token)); + $response = $handler->handle($request); - return $next($request, $response); + return $this->_addTokenCookie($token, $request, $response); + } + + if ($hasData) { + $this->_validateToken($request); + $request = $this->_unsetTokenField($request); } - $request = $this->_validateAndUnsetTokenField($request); - return $next($request, $response); + return $handler->handle($request); } /** - * Checks if the request is POST, PUT, DELETE or PATCH and validates the CSRF token + * Set callback for allowing to skip token check for particular request. * - * @param \Cake\Http\ServerRequest $request The request object. - * @return \Cake\Http\ServerRequest + * The callback will receive request instance as argument and must return + * `true` if you want to skip token check for the current request. + * + * @param callable $callback A callable. + * @return $this */ - protected function _validateAndUnsetTokenField(ServerRequest $request) + public function skipCheckCallback(callable $callback) { - if (in_array($request->getMethod(), ['PUT', 'POST', 'DELETE', 'PATCH']) || $request->getData()) { - $this->_validateToken($request); - $body = $request->getParsedBody(); - if (is_array($body)) { - unset($body[$this->_config['field']]); - $request = $request->withParsedBody($body); - } + $this->skipCheckCallback = $callback; + + return $this; + } + + /** + * Remove CSRF protection token from request data. + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request object. + * @return \Psr\Http\Message\ServerRequestInterface + */ + protected function _unsetTokenField(ServerRequestInterface $request): ServerRequestInterface + { + $body = $request->getParsedBody(); + if (is_array($body)) { + unset($body[$this->_config['field']]); + $request = $request->withParsedBody($body); } return $request; } + /** + * Test if the token predates salted tokens. + * + * These tokens are hexadecimal values and equal + * to the token with checksum length. While they are vulnerable + * to BREACH they should rotate over time and support will be dropped + * in 5.x. + * + * @param string $token The token to test. + * @return bool + */ + protected function isHexadecimalToken(string $token): bool + { + return preg_match('/^[a-f0-9]{' . static::TOKEN_WITH_CHECKSUM_LENGTH . '}$/', $token) === 1; + } + /** * Create a new token to be used for CSRF protection * * @return string */ - protected function _createToken() + public function createToken(): string { - return hash('sha512', Security::randomBytes(16), false); + $value = Security::randomBytes(static::TOKEN_VALUE_LENGTH); + + return base64_encode($value . hash_hmac('sha1', $value, Security::getSalt())); } /** - * Add a CSRF token to the request parameters. + * Apply entropy to a CSRF token * - * @param string $token The token to add. - * @param \Cake\Http\ServerRequest $request The request to augment - * @return \Cake\Http\ServerRequest Modified request + * To avoid BREACH apply a random salt value to a token + * When the token is compared to the session the token needs + * to be unsalted. + * + * @param string $token The token to salt. + * @return string The salted token with the salt appended. + */ + public function saltToken(string $token): string + { + if ($this->isHexadecimalToken($token)) { + return $token; + } + $decoded = base64_decode($token, true); + if ($decoded === false) { + throw new InvalidArgumentException('Invalid token data.'); + } + + $length = strlen($decoded); + $salt = Security::randomBytes($length); + $salted = ''; + for ($i = 0; $i < $length; $i++) { + // XOR the token and salt together so that we can reverse it later. + $salted .= chr(ord($decoded[$i]) ^ ord($salt[$i])); + } + + return base64_encode($salted . $salt); + } + + /** + * Remove the salt from a CSRF token. + * + * If the token is not TOKEN_VALUE_LENGTH * 2 it is an old + * unsalted value that is supported for backwards compatibility. + * + * @param string $token The token that could be salty. + * @return string An unsalted token. */ - protected function _addTokenToRequest($token, ServerRequest $request) + public function unsaltToken(string $token): string { - $params = $request->getAttribute('params'); - $params['_csrfToken'] = $token; + if ($this->isHexadecimalToken($token)) { + return $token; + } + $decoded = base64_decode($token, true); + if ($decoded === false || strlen($decoded) !== static::TOKEN_WITH_CHECKSUM_LENGTH * 2) { + return $token; + } + $salted = substr($decoded, 0, static::TOKEN_WITH_CHECKSUM_LENGTH); + $salt = substr($decoded, static::TOKEN_WITH_CHECKSUM_LENGTH); - return $request->withAttribute('params', $params); + $unsalted = ''; + for ($i = 0; $i < static::TOKEN_WITH_CHECKSUM_LENGTH; $i++) { + // Reverse the XOR to desalt. + $unsalted .= chr(ord($salted[$i]) ^ ord($salt[$i])); + } + + return base64_encode($unsalted); + } + + /** + * Verify that CSRF token was originally generated by the receiving application. + * + * @param string $token The CSRF token. + * @return bool + */ + protected function _verifyToken(string $token): bool + { + // If we have a hexadecimal value we're in a compatibility mode from before + // tokens were salted on each request. + if ($this->isHexadecimalToken($token)) { + $decoded = $token; + } else { + $decoded = base64_decode($token, true); + } + if (!$decoded || strlen($decoded) <= static::TOKEN_VALUE_LENGTH) { + return false; + } + + $key = substr($decoded, 0, static::TOKEN_VALUE_LENGTH); + $hmac = substr($decoded, static::TOKEN_VALUE_LENGTH); + + $expectedHmac = hash_hmac('sha1', $key, Security::getSalt()); + + return hash_equals($hmac, $expectedHmac); } /** * Add a CSRF token to the response cookies. * * @param string $token The token to add. - * @param \Cake\Http\ServerRequest $request The request to validate against. - * @param \Cake\Http\Response $response The response. - * @return \Cake\Http\Response $response Modified response. + * @param \Psr\Http\Message\ServerRequestInterface $request The request to validate against. + * @param \Psr\Http\Message\ResponseInterface $response The response. + * @return \Psr\Http\Message\ResponseInterface $response Modified response. */ - protected function _addTokenCookie($token, ServerRequest $request, Response $response) - { - $expiry = new Time($this->_config['expiry']); - - return $response->withCookie($this->_config['cookieName'], [ - 'value' => $token, - 'expire' => $expiry->format('U'), - 'path' => $request->getAttribute('webroot'), - 'secure' => $this->_config['secure'], - 'httpOnly' => $this->_config['httpOnly'], - ]); + protected function _addTokenCookie( + string $token, + ServerRequestInterface $request, + ResponseInterface $response, + ): ResponseInterface { + $cookie = $this->_createCookie($token, $request); + if ($response instanceof Response) { + return $response->withCookie($cookie); + } + + return $response->withAddedHeader('Set-Cookie', $cookie->toHeaderValue()); } /** * Validate the request data against the cookie token. * - * @param \Cake\Http\ServerRequest $request The request to validate against. + * @param \Psr\Http\Message\ServerRequestInterface $request The request to validate against. * @return void - * @throws \Cake\Network\Exception\InvalidCsrfTokenException When the CSRF token is invalid or missing. + * @throws \Cake\Http\Exception\InvalidCsrfTokenException When the CSRF token is invalid or missing. */ - protected function _validateToken(ServerRequest $request) + protected function _validateToken(ServerRequestInterface $request): void { - $cookies = $request->getCookieParams(); - $cookie = Hash::get($cookies, $this->_config['cookieName']); - $post = Hash::get($request->getParsedBody(), $this->_config['field']); - $header = $request->getHeaderLine('X-CSRF-Token'); + $cookie = Hash::get($request->getCookieParams(), $this->_config['cookieName']); + + if (!$cookie || !is_string($cookie)) { + throw new InvalidCsrfTokenException(__d('cake', 'Missing or incorrect CSRF cookie type.')); + } + + if (!$this->_verifyToken($cookie)) { + $exception = new InvalidCsrfTokenException(__d('cake', 'Missing or invalid CSRF cookie.')); + + $expiredCookie = $this->_createCookie('', $request)->withExpired(); + $exception->setHeader('Set-Cookie', $expiredCookie->toHeaderValue()); - if (!$cookie) { - throw new InvalidCsrfTokenException(__d('cake', 'Missing CSRF token cookie')); + throw $exception; } - if ($post !== $cookie && $header !== $cookie) { - throw new InvalidCsrfTokenException(__d('cake', 'CSRF token mismatch.')); + $body = $request->getParsedBody(); + if (is_array($body) || $body instanceof ArrayAccess) { + $post = (string)Hash::get($body, $this->_config['field']); + $post = $this->unsaltToken($post); + if (hash_equals($post, $cookie)) { + return; + } + } + + $header = $request->getHeaderLine('X-CSRF-Token'); + $header = $this->unsaltToken($header); + if (hash_equals($header, $cookie)) { + return; } + + throw new InvalidCsrfTokenException(__d( + 'cake', + 'CSRF token from either the request body or request headers did not match or is missing.', + )); + } + + /** + * Create response cookie + * + * @param string $value Cookie value + * @param \Psr\Http\Message\ServerRequestInterface $request The request object. + * @return \Cake\Http\Cookie\CookieInterface + */ + protected function _createCookie(string $value, ServerRequestInterface $request): CookieInterface + { + return Cookie::create( + $this->_config['cookieName'], + $value, + [ + 'expires' => $this->_config['expiry'] ?: null, + 'path' => $request->getAttribute('webroot'), + 'secure' => $this->_config['secure'], + 'httponly' => $this->_config['httponly'], + 'samesite' => $this->_config['samesite'], + ], + ); } } diff --git a/src/Http/Middleware/EncryptedCookieMiddleware.php b/src/Http/Middleware/EncryptedCookieMiddleware.php index 6555cfd258e..fcd63f35c9c 100644 --- a/src/Http/Middleware/EncryptedCookieMiddleware.php +++ b/src/Http/Middleware/EncryptedCookieMiddleware.php @@ -1,16 +1,18 @@ */ - protected $cookieNames; + protected array $cookieNames; /** - * Encrpytion key to use. + * Encryption key to use. * * @var string */ - protected $key; + protected string $key; /** * Encryption type. * * @var string */ - protected $cipherType; + protected string $cipherType; /** * Constructor * - * @param array $cookieNames The list of cookie names that should have their values encrypted. + * @param array $cookieNames The list of cookie names that should have their values encrypted. * @param string $key The encryption key to use. - * @param string $cipherType The cipher type to use. Defaults to 'aes', but can also be 'rijndael' for - * backwards compatibility. + * @param string $cipherType The cipher type to use. Defaults to 'aes'. */ - public function __construct(array $cookieNames, $key, $cipherType = 'aes') + public function __construct(array $cookieNames, string $key, string $cipherType = 'aes') { $this->cookieNames = $cookieNames; $this->key = $key; @@ -77,21 +81,21 @@ public function __construct(array $cookieNames, $key, $cipherType = 'aes') * Apply cookie encryption/decryption. * * @param \Psr\Http\Message\ServerRequestInterface $request The request. - * @param \Psr\Http\Message\ResponseInterface $response The response. - * @param callable $next The next middleware to call. + * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler. * @return \Psr\Http\Message\ResponseInterface A response. */ - public function __invoke(ServerRequestInterface $request, ResponseInterface $response, $next) + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { if ($request->getCookieParams()) { $request = $this->decodeCookies($request); } - $response = $next($request, $response); + + $response = $handler->handle($request); if ($response->hasHeader('Set-Cookie')) { $response = $this->encodeSetCookieHeader($response); } if ($response instanceof Response) { - $response = $this->encodeCookies($response); + return $this->encodeCookies($response); } return $response; @@ -104,7 +108,7 @@ public function __invoke(ServerRequestInterface $request, ResponseInterface $res * * @return string */ - protected function _getCookieEncryptionKey() + protected function _getCookieEncryptionKey(): string { return $this->key; } @@ -115,7 +119,7 @@ protected function _getCookieEncryptionKey() * @param \Psr\Http\Message\ServerRequestInterface $request The request to decode cookies from. * @return \Psr\Http\Message\ServerRequestInterface Updated request with decoded cookies. */ - protected function decodeCookies(ServerRequestInterface $request) + protected function decodeCookies(ServerRequestInterface $request): ServerRequestInterface { $cookies = $request->getCookieParams(); foreach ($this->cookieNames as $name) { @@ -133,10 +137,9 @@ protected function decodeCookies(ServerRequestInterface $request) * @param \Cake\Http\Response $response The response to encode cookies in. * @return \Cake\Http\Response Updated response with encoded cookies. */ - protected function encodeCookies(Response $response) + protected function encodeCookies(Response $response): Response { - $cookies = $response->getCookieCollection(); - foreach ($cookies as $cookie) { + foreach ($response->getCookieCollection() as $cookie) { if (in_array($cookie->getName(), $this->cookieNames, true)) { $value = $this->_encrypt($cookie->getValue(), $this->cipherType); $response = $response->withCookie($cookie->withValue($value)); @@ -152,7 +155,7 @@ protected function encodeCookies(Response $response) * @param \Psr\Http\Message\ResponseInterface $response The response to encode cookies in. * @return \Psr\Http\Message\ResponseInterface Updated response with encoded cookies. */ - protected function encodeSetCookieHeader(ResponseInterface $response) + protected function encodeSetCookieHeader(ResponseInterface $response): ResponseInterface { $cookies = CookieCollection::createFromHeader($response->getHeader('Set-Cookie')); $header = []; diff --git a/src/Http/Middleware/HttpsEnforcerMiddleware.php b/src/Http/Middleware/HttpsEnforcerMiddleware.php new file mode 100644 index 00000000000..93c78582baf --- /dev/null +++ b/src/Http/Middleware/HttpsEnforcerMiddleware.php @@ -0,0 +1,145 @@ + + */ + protected array $config = [ + 'redirect' => true, + 'statusCode' => 301, + 'headers' => [], + 'disableOnDebug' => true, + 'trustedProxies' => null, + 'hsts' => null, + ]; + + /** + * Constructor + * + * @param array $config The options to use. + * @see \Cake\Http\Middleware\HttpsEnforcerMiddleware::$config + */ + public function __construct(array $config = []) + { + $this->config = $config + $this->config; + } + + /** + * Check whether request has been made using HTTPS. + * + * Depending on the configuration and request method, either redirects to + * same URL with https or throws an exception. + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request. + * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler. + * @return \Psr\Http\Message\ResponseInterface A response. + * @throws \Cake\Http\Exception\BadRequestException + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($request instanceof ServerRequest && is_array($this->config['trustedProxies'])) { + $request->setTrustedProxies($this->config['trustedProxies']); + } + + if ( + $request->getUri()->getScheme() === 'https' + || ($this->config['disableOnDebug'] + && Configure::read('debug')) + ) { + $response = $handler->handle($request); + if ($this->config['hsts']) { + return $this->addHsts($response); + } + + return $response; + } + + if ($this->config['redirect'] && $request->getMethod() === 'GET') { + $uri = $request->getUri()->withScheme('https'); + $base = $request->getAttribute('base'); + if ($base) { + $uri = $uri->withPath($base . $uri->getPath()); + } + + return new RedirectResponse( + $uri, + $this->config['statusCode'], + $this->config['headers'], + ); + } + + throw new BadRequestException( + 'Requests to this URL must be made with HTTPS.', + ); + } + + /** + * Adds Strict-Transport-Security header to response. + * + * @param \Psr\Http\Message\ResponseInterface $response Response + * @return \Psr\Http\Message\ResponseInterface + */ + protected function addHsts(ResponseInterface $response): ResponseInterface + { + $config = $this->config['hsts']; + if (!is_array($config)) { + throw new UnexpectedValueException('The `hsts` config must be an array.'); + } + + $value = 'max-age=' . $config['maxAge']; + if ($config['includeSubDomains'] ?? false) { + $value .= '; includeSubDomains'; + } + if ($config['preload'] ?? false) { + $value .= '; preload'; + } + + return $response->withHeader('strict-transport-security', $value); + } +} diff --git a/src/Http/Middleware/RateLimitMiddleware.php b/src/Http/Middleware/RateLimitMiddleware.php new file mode 100644 index 00000000000..fc5460455e2 --- /dev/null +++ b/src/Http/Middleware/RateLimitMiddleware.php @@ -0,0 +1,467 @@ + + */ + protected array $defaultConfig = [ + 'limit' => 60, + 'window' => 60, + 'identifier' => self::IDENTIFIER_IP, + 'strategy' => self::STRATEGY_SLIDING_WINDOW, + 'strategyClass' => null, + 'cache' => 'default', + 'headers' => true, + 'message' => 'Rate limit exceeded. Please try again later.', + 'skipCheck' => null, + 'costCallback' => null, + 'identifierCallback' => null, + 'limitCallback' => null, + 'ipHeader' => 'remote_addr', + 'includeRetryAfter' => true, + 'keyGenerator' => null, + 'tokenHeaders' => ['Authorization', 'X-API-Key'], + 'limiters' => [], + 'limiterResolver' => null, + ]; + + /** + * Configuration + * + * @var array + */ + protected array $config; + + /** + * Constructor + * + * @param array $config Configuration options + */ + public function __construct(array $config = []) + { + $this->config = $config + $this->defaultConfig; + } + + /** + * Process the request and add rate limiting + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request + * @param \Psr\Http\Server\RequestHandlerInterface $handler The handler + * @return \Psr\Http\Message\ResponseInterface + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($this->shouldSkip($request)) { + return $handler->handle($request); + } + + $limiterConfig = $this->resolveLimiterConfig($request); + $identifier = $this->getIdentifier($request); + $limit = $limiterConfig['limit'] ?? $this->getLimit($request, $identifier); + $window = $limiterConfig['window'] ?? $this->config['window']; + $cost = $this->getCost($request); + $key = $this->generateKey($identifier, $request); + + $rateLimiter = $this->getRateLimiter($limiterConfig); + $result = $rateLimiter->attempt($key, $limit, $window, $cost); + + if (!$result['allowed']) { + $message = $limiterConfig['message'] ?? $this->config['message']; + $exception = new TooManyRequestsException($message); + if ($this->config['includeRetryAfter'] && isset($result['reset'])) { + $retryAfter = max(1, $result['reset'] - time()); + $exception->setHeader('Retry-After', (string)$retryAfter); + } + throw $exception; + } + + $response = $handler->handle($request); + + if ($this->config['headers']) { + return $this->addRateLimitHeaders($response, $result); + } + + return $response; + } + + /** + * Resolve limiter configuration for the current request + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request + * @return array + */ + protected function resolveLimiterConfig(ServerRequestInterface $request): array + { + $resolver = $this->config['limiterResolver']; + if ($resolver instanceof Closure) { + $name = $resolver($request); + if ($name && isset($this->config['limiters'][$name])) { + return $this->config['limiters'][$name]; + } + } + + $params = $request->getAttribute('params', []); + if (isset($params['_rateLimiter']) && isset($this->config['limiters'][$params['_rateLimiter']])) { + return $this->config['limiters'][$params['_rateLimiter']]; + } + + return []; + } + + /** + * Check if rate limiting should be skipped for this request + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request + * @return bool + */ + protected function shouldSkip(ServerRequestInterface $request): bool + { + $skipCheck = $this->config['skipCheck']; + if ($skipCheck instanceof Closure) { + return (bool)$skipCheck($request); + } + + return false; + } + + /** + * Get the identifier for rate limiting + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request + * @return string + */ + protected function getIdentifier(ServerRequestInterface $request): string + { + $callback = $this->config['identifierCallback']; + if ($callback instanceof Closure) { + return (string)$callback($request); + } + + $identifier = $this->config['identifier']; + + if (is_array($identifier)) { + $parts = []; + foreach ($identifier as $type) { + $parts[] = $this->getIdentifierByType($type, $request); + } + + return implode('_', $parts); + } + + return $this->getIdentifierByType($identifier, $request); + } + + /** + * Get identifier by type + * + * @param string $type The identifier type + * @param \Psr\Http\Message\ServerRequestInterface $request The request + * @return string + */ + protected function getIdentifierByType(string $type, ServerRequestInterface $request): string + { + return match ($type) { + self::IDENTIFIER_IP => $this->getClientIp($request), + self::IDENTIFIER_USER => $this->getUserIdentifier($request), + self::IDENTIFIER_ROUTE => $this->getRouteIdentifier($request), + self::IDENTIFIER_API_KEY, self::IDENTIFIER_TOKEN => $this->getApiKeyIdentifier($request), + default => $this->getClientIp($request), + }; + } + + /** + * Get client IP address + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request + * @return string + */ + protected function getClientIp(ServerRequestInterface $request): string + { + $params = $request->getServerParams(); + + foreach ((array)$this->config['ipHeader'] as $header) { + // 'remote_addr' is a sentinel for the connecting IP, not an HTTP header. + if (strtolower($header) === 'remote_addr') { + if (!empty($params['REMOTE_ADDR'])) { + return $params['REMOTE_ADDR']; + } + + continue; + } + + $value = $request->getHeaderLine($header); + if ($value !== '') { + // Forwarded headers may carry a chain "client, proxy1, proxy2"; + // the left-most entry is the originating client. + $ips = explode(',', $value); + + return trim($ips[0]); + } + } + + return $params['REMOTE_ADDR'] ?? 'unknown'; + } + + /** + * Get user identifier + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request + * @return string + */ + protected function getUserIdentifier(ServerRequestInterface $request): string + { + $user = $request->getAttribute('identity'); + if ($user) { + if (interface_exists(IdentityInterface::class) && $user instanceof IdentityInterface) { + return 'user_' . $user->getIdentifier(); + } + if (isset($user->id)) { + return 'user_' . $user->id; + } + } + + return $this->getClientIp($request); + } + + /** + * Get route identifier + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request + * @return string + */ + protected function getRouteIdentifier(ServerRequestInterface $request): string + { + $params = $request->getAttribute('params', []); + $route = sprintf( + '%s::%s.%s', + $params['plugin'] ?? 'app', + $params['controller'] ?? 'unknown', + $params['action'] ?? 'unknown', + ); + + return $route . '_' . $this->getClientIp($request); + } + + /** + * Generate cache key for rate limiting + * + * @param string $identifier The identifier + * @param \Psr\Http\Message\ServerRequestInterface $request The request + * @return string + */ + protected function generateKey(string $identifier, ServerRequestInterface $request): string + { + $generator = $this->config['keyGenerator']; + if ($generator instanceof Closure) { + return (string)$generator($identifier, $request); + } + + return 'rate_limit_' . hash('xxh3', $identifier); + } + + /** + * Get API key/token identifier + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request + * @return string + */ + protected function getApiKeyIdentifier(ServerRequestInterface $request): string + { + foreach ($this->config['tokenHeaders'] as $header) { + $value = $request->getHeaderLine($header); + if ($value) { + if ($header === 'Authorization') { + $parts = explode(' ', $value, 2); + if (count($parts) === 2) { + $scheme = strtolower($parts[0]); + $token = $parts[1]; + + return sprintf('%s_%s', $scheme, hash('xxh3', $token)); + } + } + + return 'token_' . hash('xxh3', $value); + } + } + + return $this->getClientIp($request); + } + + /** + * Get rate limit for the request + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request + * @param string $identifier The identifier + * @return int + */ + protected function getLimit(ServerRequestInterface $request, string $identifier): int + { + $callback = $this->config['limitCallback']; + if ($callback instanceof Closure) { + return (int)$callback($request, $identifier); + } + + return (int)$this->config['limit']; + } + + /** + * Get the cost of the request + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request + * @return int + */ + protected function getCost(ServerRequestInterface $request): int + { + $callback = $this->config['costCallback']; + if ($callback instanceof Closure) { + return (int)$callback($request); + } + + return 1; + } + + /** + * Get rate limiter instance based on strategy + * + * @param array $limiterConfig Optional limiter configuration override + * @return \Cake\Http\RateLimit\RateLimiterInterface + */ + protected function getRateLimiter(array $limiterConfig = []): RateLimiterInterface + { + $cache = Cache::pool($this->config['cache']); + + // Check if strategyClass is provided (takes precedence) + /** @var class-string<\Cake\Http\RateLimit\RateLimiterInterface>|null $strategyClass */ + $strategyClass = $limiterConfig['strategyClass'] ?? $this->config['strategyClass']; + if ($strategyClass !== null && class_exists($strategyClass)) { + return new $strategyClass($cache); + } + + // Fall back to strategy string mapping for backward compatibility + $strategy = $limiterConfig['strategy'] ?? $this->config['strategy']; + + return match ($strategy) { + self::STRATEGY_TOKEN_BUCKET => new TokenBucketRateLimiter($cache), + self::STRATEGY_FIXED_WINDOW => new FixedWindowRateLimiter($cache), + self::STRATEGY_SLIDING_WINDOW => new SlidingWindowRateLimiter($cache), + default => new SlidingWindowRateLimiter($cache), + }; + } + + /** + * Add rate limit headers to response + * + * @param \Psr\Http\Message\ResponseInterface $response The response + * @param array $result Rate limit result + * @return \Psr\Http\Message\ResponseInterface + */ + protected function addRateLimitHeaders(ResponseInterface $response, array $result): ResponseInterface + { + return $response + ->withHeader('X-RateLimit-Limit', (string)$result['limit']) + ->withHeader('X-RateLimit-Remaining', (string)$result['remaining']) + ->withHeader('X-RateLimit-Reset', (string)$result['reset']) + ->withHeader('X-RateLimit-Reset-Date', date('c', $result['reset'])); + } +} diff --git a/src/Http/Middleware/SecurityHeadersMiddleware.php b/src/Http/Middleware/SecurityHeadersMiddleware.php index 2c29f2907c5..26816621b12 100644 --- a/src/Http/Middleware/SecurityHeadersMiddleware.php +++ b/src/Http/Middleware/SecurityHeadersMiddleware.php @@ -1,35 +1,106 @@ */ - protected $headers = []; + protected array $headers = []; /** * X-Content-Type-Options @@ -41,7 +112,7 @@ class SecurityHeadersMiddleware */ public function noSniff() { - $this->headers['x-content-type-options'] = 'nosniff'; + $this->headers['x-content-type-options'] = self::NOSNIFF; return $this; } @@ -56,7 +127,7 @@ public function noSniff() */ public function noOpen() { - $this->headers['x-download-options'] = 'noopen'; + $this->headers['x-download-options'] = self::NOOPEN; return $this; } @@ -65,17 +136,21 @@ public function noOpen() * Referrer-Policy * * @link https://w3c.github.io/webappsec-referrer-policy - * @param string $policy Policy value. Available Value: 'no-referrer', 'no-referrer-when-downgrade', 'origin', 'origin-when-cross-origin', - * 'same-origin', 'strict-origin', 'strict-origin-when-cross-origin', 'unsafe-url' + * @param string $policy Policy value. Available Value: 'no-referrer', 'no-referrer-when-downgrade', 'origin', + * 'origin-when-cross-origin', 'same-origin', 'strict-origin', 'strict-origin-when-cross-origin', 'unsafe-url' * @return $this */ - public function setReferrerPolicy($policy = 'same-origin') + public function setReferrerPolicy(string $policy = self::SAME_ORIGIN) { $available = [ - 'no-referrer', 'no-referrer-when-downgrade', 'origin', - 'origin-when-cross-origin', - 'same-origin', 'strict-origin', 'strict-origin-when-cross-origin', - 'unsafe-url' + self::NO_REFERRER, + self::NO_REFERRER_WHEN_DOWNGRADE, + self::ORIGIN, + self::ORIGIN_WHEN_CROSS_ORIGIN, + self::SAME_ORIGIN, + self::STRICT_ORIGIN, + self::STRICT_ORIGIN_WHEN_CROSS_ORIGIN, + self::UNSAFE_URL, ]; $this->checkValues($policy, $available); @@ -89,15 +164,15 @@ public function setReferrerPolicy($policy = 'same-origin') * * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options * @param string $option Option value. Available Values: 'deny', 'sameorigin', 'allow-from ' - * @param string $url URL if mode is `allow-from` + * @param string|null $url URL if mode is `allow-from` * @return $this */ - public function setXFrameOptions($option = 'sameorigin', $url = null) + public function setXFrameOptions(string $option = self::SAMEORIGIN, ?string $url = null) { - $this->checkValues($option, ['deny', 'sameorigin', 'allow-from']); + $this->checkValues($option, [self::DENY, self::SAMEORIGIN, self::ALLOW_FROM]); - if ($option === 'allow-from') { - if (empty($url)) { + if ($option === self::ALLOW_FROM) { + if (!$url) { throw new InvalidArgumentException('The 2nd arg $url can not be empty when `allow-from` is used'); } $option .= ' ' . $url; @@ -109,21 +184,21 @@ public function setXFrameOptions($option = 'sameorigin', $url = null) } /** - * X-XSS-Protection + * X-XSS-Protection. It's a non standard feature and outdated. For modern browsers + * use a strong Content-Security-Policy that disables the use of inline JavaScript + * via 'unsafe-inline' option. * - * @link https://blogs.msdn.microsoft.com/ieinternals/2011/01/31/controlling-the-xss-filter + * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection * @param string $mode Mode value. Available Values: '1', '0', 'block' * @return $this */ - public function setXssProtection($mode = 'block') + public function setXssProtection(string $mode = self::XSS_BLOCK) { - $mode = (string)$mode; - - if ($mode === 'block') { - $mode = '1; mode=block'; + if ($mode === self::XSS_BLOCK) { + $mode = self::XSS_ENABLED_BLOCK; } - $this->checkValues($mode, ['1', '0', '1; mode=block']); + $this->checkValues($mode, [self::XSS_ENABLED, self::XSS_DISABLED, self::XSS_ENABLED_BLOCK]); $this->headers['x-xss-protection'] = $mode; return $this; @@ -132,33 +207,57 @@ public function setXssProtection($mode = 'block') /** * X-Permitted-Cross-Domain-Policies * - * @link https://www.adobe.com/devnet/adobe-media-server/articles/cross-domain-xml-for-streaming.html - * @param string $policy Policy value. Available Values: 'all', 'none', 'master-only', 'by-content-type', 'by-ftp-filename' + * @link https://web.archive.org/web/20170607190356/https://www.adobe.com/devnet/adobe-media-server/articles/cross-domain-xml-for-streaming.html + * @param string $policy Policy value. Available Values: 'all', 'none', 'master-only', 'by-content-type', + * 'by-ftp-filename' * @return $this */ - public function setCrossDomainPolicy($policy = 'all') + public function setCrossDomainPolicy(string $policy = self::ALL) { - $this->checkValues($policy, ['all', 'none', 'master-only', 'by-content-type', 'by-ftp-filename']); + $this->checkValues($policy, [ + self::ALL, + self::NONE, + self::MASTER_ONLY, + self::BY_CONTENT_TYPE, + self::BY_FTP_FILENAME, + ]); $this->headers['x-permitted-cross-domain-policies'] = $policy; return $this; } + /** + * Permissions Policy + * + * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Permissions_Policy + * @link https://www.w3.org/TR/permissions-policy/ + * @param string $policy Policy value. + * @return $this + * @since 5.1.0 + */ + public function setPermissionsPolicy(string $policy) + { + $this->headers['permissions-policy'] = $policy; + + return $this; + } + /** * Convenience method to check if a value is in the list of allowed args * * @throws \InvalidArgumentException Thrown when a value is invalid. * @param string $value Value to check - * @param array $allowed List of allowed values + * @param array $allowed List of allowed values * @return void */ - protected function checkValues($value, array $allowed) + protected function checkValues(string $value, array $allowed): void { - if (!in_array($value, $allowed)) { + if (!in_array($value, $allowed, true)) { + array_walk($allowed, fn(string &$x) => $x = "`{$x}`"); throw new InvalidArgumentException(sprintf( - 'Invalid arg `%s`, use one of these: %s', + 'Invalid arg `%s`, use one of these: %s.', $value, - implode(', ', $allowed) + implode(', ', $allowed), )); } } @@ -167,13 +266,12 @@ protected function checkValues($value, array $allowed) * Serve assets if the path matches one. * * @param \Psr\Http\Message\ServerRequestInterface $request The request. - * @param \Psr\Http\Message\ResponseInterface $response The response. - * @param callable $next Callback to invoke the next middleware. - * @return \Psr\Http\Message\ResponseInterface A response + * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler. + * @return \Psr\Http\Message\ResponseInterface A response. */ - public function __invoke(ServerRequestInterface $request, ResponseInterface $response, $next) + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { - $response = $next($request, $response); + $response = $handler->handle($request); foreach ($this->headers as $header => $value) { $response = $response->withHeader($header, $value); } diff --git a/src/Http/Middleware/SessionCsrfProtectionMiddleware.php b/src/Http/Middleware/SessionCsrfProtectionMiddleware.php new file mode 100644 index 00000000000..49eeac9c2e4 --- /dev/null +++ b/src/Http/Middleware/SessionCsrfProtectionMiddleware.php @@ -0,0 +1,292 @@ +Form->create(...)` is used in a view. + * + * If you use this middleware *do not* also use CsrfProtectionMiddleware. + * + * @see https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#synchronizer-token-pattern + */ +class SessionCsrfProtectionMiddleware implements MiddlewareInterface +{ + /** + * Config for the CSRF handling. + * + * - `key` The session key to use. Defaults to `csrfToken` + * - `field` The form field to check. Changing this will also require configuring + * FormHelper. + * + * @var array + */ + protected array $_config = [ + 'key' => 'csrfToken', + 'field' => '_csrfToken', + ]; + + /** + * Callback for deciding whether to skip the token check for particular request. + * + * CSRF protection token check will be skipped if the callback returns `true`. + * + * @var callable|null + */ + protected $skipCheckCallback; + + /** + * @var int + */ + public const TOKEN_VALUE_LENGTH = 32; + + /** + * Constructor + * + * @param array $config Config options. See $_config for valid keys. + */ + public function __construct(array $config = []) + { + $this->_config = $config + $this->_config; + } + + /** + * Checks and sets the CSRF token depending on the HTTP verb. + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request. + * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler. + * @return \Psr\Http\Message\ResponseInterface A response. + */ + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $method = $request->getMethod(); + $hasData = in_array($method, ['PUT', 'POST', 'DELETE', 'PATCH'], true) + || $request->getParsedBody(); + + if ( + $hasData + && $this->skipCheckCallback !== null + && call_user_func($this->skipCheckCallback, $request) === true + ) { + $request = $this->unsetTokenField($request); + + return $handler->handle($request); + } + + $session = $request->getAttribute('session'); + if (!($session instanceof Session)) { + throw new CakeException('You must have a `session` attribute to use session based CSRF tokens'); + } + + $token = $session->read($this->_config['key']); + if ($token === null) { + $token = $this->createToken(); + $session->write($this->_config['key'], $token); + } + $request = $request->withAttribute('csrfToken', $this->saltToken($token)); + + if ($method === 'GET') { + return $handler->handle($request); + } + + if ($hasData) { + $this->validateToken($request, $session); + $request = $this->unsetTokenField($request); + } + + return $handler->handle($request); + } + + /** + * Set callback for allowing to skip token check for particular request. + * + * The callback will receive request instance as argument and must return + * `true` if you want to skip token check for the current request. + * + * @param callable $callback A callable. + * @return $this + */ + public function skipCheckCallback(callable $callback) + { + $this->skipCheckCallback = $callback; + + return $this; + } + + /** + * Apply entropy to a CSRF token + * + * To avoid BREACH apply a random salt value to a token + * When the token is compared to the session the token needs + * to be unsalted. + * + * @param string $token The token to salt. + * @return string The salted token with the salt appended. + */ + public function saltToken(string $token): string + { + $decoded = base64_decode($token); + $length = strlen($decoded); + $salt = Security::randomBytes($length); + $salted = ''; + for ($i = 0; $i < $length; $i++) { + // XOR the token and salt together so that we can reverse it later. + $salted .= chr(ord($decoded[$i]) ^ ord($salt[$i])); + } + + return base64_encode($salted . $salt); + } + + /** + * Remove the salt from a CSRF token. + * + * If the token is not TOKEN_VALUE_LENGTH * 2 it is an old + * unsalted value that is supported for backwards compatibility. + * + * @param string $token The token that could be salty. + * @return string An unsalted token. + */ + protected function unsaltToken(string $token): string + { + $decoded = base64_decode($token, true); + if ($decoded === false || strlen($decoded) !== static::TOKEN_VALUE_LENGTH * 2) { + return $token; + } + $salted = substr($decoded, 0, static::TOKEN_VALUE_LENGTH); + $salt = substr($decoded, static::TOKEN_VALUE_LENGTH); + + $unsalted = ''; + for ($i = 0; $i < static::TOKEN_VALUE_LENGTH; $i++) { + // Reverse the XOR to desalt. + $unsalted .= chr(ord($salted[$i]) ^ ord($salt[$i])); + } + + return base64_encode($unsalted); + } + + /** + * Remove CSRF protection token from request data. + * + * This ensures that the token does not cause failures during + * form tampering protection. + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request object. + * @return \Psr\Http\Message\ServerRequestInterface + */ + protected function unsetTokenField(ServerRequestInterface $request): ServerRequestInterface + { + $body = $request->getParsedBody(); + if (is_array($body)) { + unset($body[$this->_config['field']]); + $request = $request->withParsedBody($body); + } + + return $request; + } + + /** + * Create a new token to be used for CSRF protection + * + * This token is a simple unique random value as the compare + * value is stored in the session where it cannot be tampered with. + * + * @return string + */ + public function createToken(): string + { + return base64_encode(Security::randomBytes(static::TOKEN_VALUE_LENGTH)); + } + + /** + * Validate the request data against the cookie token. + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request to validate against. + * @param \Cake\Http\Session $session The session instance. + * @return void + * @throws \Cake\Http\Exception\InvalidCsrfTokenException When the CSRF token is invalid or missing. + */ + protected function validateToken(ServerRequestInterface $request, Session $session): void + { + $token = $session->read($this->_config['key']); + if (!$token || !is_string($token)) { + throw new InvalidCsrfTokenException(__d('cake', 'Missing or incorrect CSRF session key')); + } + + $body = $request->getParsedBody(); + if (is_array($body) || $body instanceof ArrayAccess) { + $post = (string)Hash::get($body, $this->_config['field']); + $post = $this->unsaltToken($post); + if (hash_equals($post, $token)) { + return; + } + } + + $header = $request->getHeaderLine('X-CSRF-Token'); + $header = $this->unsaltToken($header); + if (hash_equals($header, $token)) { + return; + } + + throw new InvalidCsrfTokenException(__d( + 'cake', + 'CSRF token from either the request body or request headers did not match or is missing.', + )); + } + + /** + * Replace the token in the provided request. + * + * Replace the token in the session and request attribute. Replacing + * tokens is a good idea during privilege escalation or privilege reduction. + * + * @param \Cake\Http\ServerRequest $request The request to update + * @param string $key The session key/attribute to set. + * @return \Cake\Http\ServerRequest An updated request. + */ + public static function replaceToken(ServerRequest $request, string $key = 'csrfToken'): ServerRequest + { + $middleware = new SessionCsrfProtectionMiddleware(['key' => $key]); + + $token = $middleware->createToken(); + $request->getSession()->write($key, $token); + + return $request->withAttribute($key, $middleware->saltToken($token)); + } +} diff --git a/src/Http/MiddlewareApplication.php b/src/Http/MiddlewareApplication.php new file mode 100644 index 00000000000..5605125f701 --- /dev/null +++ b/src/Http/MiddlewareApplication.php @@ -0,0 +1,56 @@ + 'Not found', 'status' => 404]); + } +} diff --git a/src/Http/MiddlewareQueue.php b/src/Http/MiddlewareQueue.php index 7f12d3bb1ee..e565fed18d6 100644 --- a/src/Http/MiddlewareQueue.php +++ b/src/Http/MiddlewareQueue.php @@ -1,4 +1,6 @@ */ -class MiddlewareQueue implements Countable +class MiddlewareQueue implements Countable, SeekableIterator { /** - * The queue of middlewares. + * Internal position for iterator. * - * @var array + * @var int */ - protected $queue = []; + protected int $position = 0; /** - * The queue of middleware callables. + * The queue of middlewares. * - * @var callable[] + * @var array */ - protected $callables = []; + protected array $queue = []; /** - * Constructor - * - * @param array $middleware The list of middleware to append. + * @var \Cake\Core\ContainerInterface|null */ - public function __construct(array $middleware = []) - { - $this->queue = $middleware; - } + protected ?ContainerInterface $container; /** - * Get the middleware at the provided index. + * Constructor * - * @param int $index The index to fetch. - * @return callable|null Either the callable middleware or null - * if the index is undefined. + * @param array $middleware The list of middleware to append. + * @param \Cake\Core\ContainerInterface|null $container Container instance. */ - public function get($index) + public function __construct(array $middleware = [], ?ContainerInterface $container = null) { - if (isset($this->callables[$index])) { - return $this->callables[$index]; - } - - return $this->resolve($index); + $this->container = $container; + $this->queue = $middleware; } /** - * Resolve middleware name to callable. + * Resolve middleware name to a PSR 15 compliant middleware instance. * - * @param int $index The index to fetch. - * @return callable|null Either the callable middleware or null - * if the index is undefined. + * @param \Psr\Http\Server\MiddlewareInterface|\Closure|string $middleware The middleware to resolve. + * @return \Psr\Http\Server\MiddlewareInterface + * @throws \InvalidArgumentException If Middleware not found. */ - protected function resolve($index) + protected function resolve(MiddlewareInterface|Closure|string $middleware): MiddlewareInterface { - if (!isset($this->queue[$index])) { - return null; + if (is_string($middleware)) { + if ($this->container && $this->container->has($middleware)) { + $middleware = $this->container->get($middleware); + } else { + /** @var class-string<\Psr\Http\Server\MiddlewareInterface>|null $className */ + $className = App::className($middleware, 'Middleware', 'Middleware'); + if ($className === null) { + throw new InvalidArgumentException(sprintf( + 'Middleware `%s` was not found.', + $middleware, + )); + } + $middleware = new $className(); + } } - if (is_string($this->queue[$index])) { - $class = $this->queue[$index]; - $className = App::className($class, 'Middleware', 'Middleware'); - if (!$className || !class_exists($className)) { - throw new RuntimeException(sprintf( - 'Middleware "%s" was not found.', - $class - )); - } - $callable = new $className; - } else { - $callable = $this->queue[$index]; + if ($middleware instanceof MiddlewareInterface) { + return $middleware; } - return $this->callables[$index] = $callable; + return new ClosureDecoratorMiddleware($middleware); } /** - * Append a middleware callable to the end of the queue. + * Append a middleware to the end of the queue. * - * @param callable|string|array $middleware The middleware(s) to append. + * @param \Psr\Http\Server\MiddlewareInterface|\Closure|array|string $middleware The middleware(s) to append. * @return $this */ - public function add($middleware) + public function add(MiddlewareInterface|Closure|array|string $middleware) { if (is_array($middleware)) { $this->queue = array_merge($this->queue, $middleware); @@ -116,11 +119,11 @@ public function add($middleware) /** * Alias for MiddlewareQueue::add(). * - * @param callable|string|array $middleware The middleware(s) to append. + * @param \Psr\Http\Server\MiddlewareInterface|\Closure|array|string $middleware The middleware(s) to append. * @return $this * @see MiddlewareQueue::add() */ - public function push($middleware) + public function push(MiddlewareInterface|Closure|array|string $middleware) { return $this->add($middleware); } @@ -128,10 +131,10 @@ public function push($middleware) /** * Prepend a middleware to the start of the queue. * - * @param callable|string|array $middleware The middleware(s) to prepend. + * @param \Psr\Http\Server\MiddlewareInterface|\Closure|array|string $middleware The middleware(s) to prepend. * @return $this */ - public function prepend($middleware) + public function prepend(MiddlewareInterface|Closure|array|string $middleware) { if (is_array($middleware)) { $this->queue = array_merge($middleware, $this->queue); @@ -144,16 +147,16 @@ public function prepend($middleware) } /** - * Insert a middleware callable at a specific index. + * Insert a middleware at a specific index. * - * If the index already exists, the new callable will be inserted, + * If the index already exists, the new middleware will be inserted, * and the existing element will be shifted one index greater. * * @param int $index The index to insert at. - * @param callable|string $middleware The middleware to insert. + * @param \Psr\Http\Server\MiddlewareInterface|\Closure|string $middleware The middleware to insert. * @return $this */ - public function insertAt($index, $middleware) + public function insertAt(int $index, MiddlewareInterface|Closure|string $middleware) { array_splice($this->queue, $index, 0, [$middleware]); @@ -161,22 +164,26 @@ public function insertAt($index, $middleware) } /** - * Insert a middleware object before the first matching class. + * Insert a middleware before the first matching class. * * Finds the index of the first middleware that matches the provided class, - * and inserts the supplied callable before it. + * and inserts the supplied middleware before it. * * @param string $class The classname to insert the middleware before. - * @param callable|string $middleware The middleware to insert. + * @param \Psr\Http\Server\MiddlewareInterface|\Closure|string $middleware The middleware to insert. * @return $this * @throws \LogicException If middleware to insert before is not found. */ - public function insertBefore($class, $middleware) + public function insertBefore(string $class, MiddlewareInterface|Closure|string $middleware) { $found = false; - $i = null; + $i = 0; foreach ($this->queue as $i => $object) { - if ((is_string($object) && $object === $class) + if ( + ( + is_string($object) + && $object === $class + ) || is_a($object, $class) ) { $found = true; @@ -186,26 +193,30 @@ public function insertBefore($class, $middleware) if ($found) { return $this->insertAt($i, $middleware); } - throw new LogicException(sprintf("No middleware matching '%s' could be found.", $class)); + throw new LogicException(sprintf('No middleware matching `%s` could be found.', $class)); } /** * Insert a middleware object after the first matching class. * * Finds the index of the first middleware that matches the provided class, - * and inserts the supplied callable after it. If the class is not found, + * and inserts the supplied middleware after it. If the class is not found, * this method will behave like add(). * * @param string $class The classname to insert the middleware before. - * @param callable|string $middleware The middleware to insert. + * @param \Psr\Http\Server\MiddlewareInterface|\Closure|string $middleware The middleware to insert. * @return $this */ - public function insertAfter($class, $middleware) + public function insertAfter(string $class, MiddlewareInterface|Closure|string $middleware) { $found = false; - $i = null; + $i = 0; foreach ($this->queue as $i => $object) { - if ((is_string($object) && $object === $class) + if ( + ( + is_string($object) + && $object === $class + ) || is_a($object, $class) ) { $found = true; @@ -226,8 +237,87 @@ public function insertAfter($class, $middleware) * * @return int */ - public function count() + public function count(): int { return count($this->queue); } + + /** + * Seeks to a given position in the queue. + * + * @param int $position The position to seek to. + * @return void + * @see \SeekableIterator::seek() + */ + public function seek(int $position): void + { + if (!isset($this->queue[$position])) { + throw new OutOfBoundsException(sprintf('Invalid seek position (%s).', $position)); + } + + $this->position = $position; + } + + /** + * Rewinds back to the first element of the queue. + * + * @return void + * @see \Iterator::rewind() + */ + public function rewind(): void + { + $this->position = 0; + } + + /** + * Returns the current middleware. + * + * @return \Psr\Http\Server\MiddlewareInterface + * @see \Iterator::current() + */ + public function current(): MiddlewareInterface + { + if (!isset($this->queue[$this->position])) { + throw new OutOfBoundsException(sprintf('Invalid current position (%s).', $this->position)); + } + + if ($this->queue[$this->position] instanceof MiddlewareInterface) { + return $this->queue[$this->position]; + } + + return $this->queue[$this->position] = $this->resolve($this->queue[$this->position]); + } + + /** + * Return the key of the middleware. + * + * @return int + * @see \Iterator::key() + */ + public function key(): int + { + return $this->position; + } + + /** + * Moves the current position to the next middleware. + * + * @return void + * @see \Iterator::next() + */ + public function next(): void + { + ++$this->position; + } + + /** + * Checks if current position is valid. + * + * @return bool + * @see \Iterator::valid() + */ + public function valid(): bool + { + return isset($this->queue[$this->position]); + } } diff --git a/src/Http/MimeType.php b/src/Http/MimeType.php new file mode 100644 index 00000000000..687ed219a49 --- /dev/null +++ b/src/Http/MimeType.php @@ -0,0 +1,390 @@ +> + */ + protected static array $mimeTypes = [ + 'html' => ['text/html', '*/*'], + 'json' => ['application/json'], + 'xml' => ['application/xml', 'text/xml'], + 'xhtml' => ['application/xhtml+xml', 'application/xhtml', 'text/xhtml'], + 'webp' => ['image/webp'], + 'rss' => ['application/rss+xml'], + 'ai' => ['application/postscript'], + 'bcpio' => ['application/x-bcpio'], + 'bin' => ['application/octet-stream'], + 'ccad' => ['application/clariscad'], + 'cdf' => ['application/x-netcdf'], + 'class' => ['application/octet-stream'], + 'cpio' => ['application/x-cpio'], + 'cpt' => ['application/mac-compactpro'], + 'csh' => ['application/x-csh'], + 'csv' => ['text/csv', 'application/vnd.ms-excel'], + 'dcr' => ['application/x-director'], + 'dir' => ['application/x-director'], + 'dms' => ['application/octet-stream'], + 'doc' => ['application/msword'], + 'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + 'drw' => ['application/drafting'], + 'dvi' => ['application/x-dvi'], + 'dwg' => ['application/acad'], + 'dxf' => ['application/dxf'], + 'dxr' => ['application/x-director'], + 'eot' => ['application/vnd.ms-fontobject'], + 'eps' => ['application/postscript'], + 'exe' => ['application/octet-stream'], + 'ez' => ['application/andrew-inset'], + 'flv' => ['video/x-flv'], + 'gtar' => ['application/x-gtar'], + 'gz' => ['application/x-gzip'], + 'bz2' => ['application/x-bzip'], + '7z' => ['application/x-7z-compressed'], + 'hal' => ['application/hal+xml', 'application/vnd.hal+xml'], + 'haljson' => ['application/hal+json', 'application/vnd.hal+json'], + 'halxml' => ['application/hal+xml', 'application/vnd.hal+xml'], + 'hdf' => ['application/x-hdf'], + 'hqx' => ['application/mac-binhex40'], + 'ico' => ['image/x-icon'], + 'ips' => ['application/x-ipscript'], + 'ipx' => ['application/x-ipix'], + 'js' => ['application/javascript'], + 'cjs' => ['application/javascript'], + 'mjs' => ['application/javascript'], + 'jsonapi' => ['application/vnd.api+json'], + 'latex' => ['application/x-latex'], + 'jsonld' => ['application/ld+json'], + 'kml' => ['application/vnd.google-earth.kml+xml'], + 'kmz' => ['application/vnd.google-earth.kmz'], + 'lha' => ['application/octet-stream'], + 'lsp' => ['application/x-lisp'], + 'lzh' => ['application/octet-stream'], + 'man' => ['application/x-troff-man'], + 'me' => ['application/x-troff-me'], + 'mif' => ['application/vnd.mif'], + 'ms' => ['application/x-troff-ms'], + 'nc' => ['application/x-netcdf'], + 'oda' => ['application/oda'], + 'otf' => ['font/otf'], + 'pdf' => ['application/pdf'], + 'pgn' => ['application/x-chess-pgn'], + 'pot' => ['application/vnd.ms-powerpoint'], + 'pps' => ['application/vnd.ms-powerpoint'], + 'ppt' => ['application/vnd.ms-powerpoint'], + 'pptx' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation'], + 'ppz' => ['application/vnd.ms-powerpoint'], + 'pre' => ['application/x-freelance'], + 'prt' => ['application/pro_eng'], + 'ps' => ['application/postscript'], + 'roff' => ['application/x-troff'], + 'scm' => ['application/x-lotusscreencam'], + 'set' => ['application/set'], + 'sh' => ['application/x-sh'], + 'shar' => ['application/x-shar'], + 'sit' => ['application/x-stuffit'], + 'skd' => ['application/x-koan'], + 'skm' => ['application/x-koan'], + 'skp' => ['application/x-koan'], + 'skt' => ['application/x-koan'], + 'smi' => ['application/smil'], + 'smil' => ['application/smil'], + 'sol' => ['application/solids'], + 'spl' => ['application/x-futuresplash'], + 'src' => ['application/x-wais-source'], + 'step' => ['application/STEP'], + 'stl' => ['application/SLA'], + 'stp' => ['application/STEP'], + 'sv4cpio' => ['application/x-sv4cpio'], + 'sv4crc' => ['application/x-sv4crc'], + 'svg' => ['image/svg+xml'], + 'svgz' => ['image/svg+xml'], + 'swf' => ['application/x-shockwave-flash'], + 't' => ['application/x-troff'], + 'tar' => ['application/x-tar'], + 'tcl' => ['application/x-tcl'], + 'tex' => ['application/x-tex'], + 'texi' => ['application/x-texinfo'], + 'texinfo' => ['application/x-texinfo'], + 'tr' => ['application/x-troff'], + 'tsp' => ['application/dsptype'], + 'ttc' => ['font/ttf'], + 'ttf' => ['font/ttf'], + 'unv' => ['application/i-deas'], + 'ustar' => ['application/x-ustar'], + 'vcd' => ['application/x-cdlink'], + 'vda' => ['application/vda'], + 'xlc' => ['application/vnd.ms-excel'], + 'xll' => ['application/vnd.ms-excel'], + 'xlm' => ['application/vnd.ms-excel'], + 'xls' => ['application/vnd.ms-excel'], + 'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], + 'xlsm' => ['application/vnd.ms-excel.sheet.macroEnabled.12'], + 'xlw' => ['application/vnd.ms-excel'], + 'zip' => ['application/zip'], + 'aif' => ['audio/x-aiff'], + 'aifc' => ['audio/x-aiff'], + 'aiff' => ['audio/x-aiff'], + 'au' => ['audio/basic'], + 'kar' => ['audio/midi'], + 'mid' => ['audio/midi'], + 'midi' => ['audio/midi'], + 'mp2' => ['audio/mpeg'], + 'mp3' => ['audio/mpeg'], + 'mpga' => ['audio/mpeg'], + 'ogg' => ['audio/ogg'], + 'oga' => ['audio/ogg'], + 'spx' => ['audio/ogg'], + 'ra' => ['audio/x-realaudio'], + 'ram' => ['audio/x-pn-realaudio'], + 'rm' => ['audio/x-pn-realaudio'], + 'rpm' => ['audio/x-pn-realaudio-plugin'], + 'snd' => ['audio/basic'], + 'tsi' => ['audio/TSP-audio'], + 'wav' => ['audio/x-wav'], + 'aac' => ['audio/aac'], + 'asc' => ['text/plain'], + 'c' => ['text/plain'], + 'cc' => ['text/plain'], + 'css' => ['text/css'], + 'etx' => ['text/x-setext'], + 'f' => ['text/plain'], + 'f90' => ['text/plain'], + 'h' => ['text/plain'], + 'hh' => ['text/plain'], + 'htm' => ['text/html', '*/*'], + 'ics' => ['text/calendar'], + 'm' => ['text/plain'], + 'rtf' => ['text/rtf'], + 'rtx' => ['text/richtext'], + 'sgm' => ['text/sgml'], + 'sgml' => ['text/sgml'], + 'tsv' => ['text/tab-separated-values'], + 'tpl' => ['text/template'], + 'txt' => ['text/plain'], + 'text' => ['text/plain'], + 'avi' => ['video/x-msvideo'], + 'fli' => ['video/x-fli'], + 'mov' => ['video/quicktime'], + 'movie' => ['video/x-sgi-movie'], + 'mpe' => ['video/mpeg'], + 'mpeg' => ['video/mpeg'], + 'mpg' => ['video/mpeg'], + 'qt' => ['video/quicktime'], + 'viv' => ['video/vnd.vivo'], + 'vivo' => ['video/vnd.vivo'], + 'ogv' => ['video/ogg'], + 'webm' => ['video/webm'], + 'mp4' => ['video/mp4'], + 'm4v' => ['video/mp4'], + 'f4v' => ['video/mp4'], + 'f4p' => ['video/mp4'], + 'm4a' => ['audio/mp4'], + 'f4a' => ['audio/mp4'], + 'f4b' => ['audio/mp4'], + 'gif' => ['image/gif'], + 'ief' => ['image/ief'], + 'jpg' => ['image/jpeg'], + 'jpeg' => ['image/jpeg'], + 'jpe' => ['image/jpeg'], + 'pbm' => ['image/x-portable-bitmap'], + 'pgm' => ['image/x-portable-graymap'], + 'png' => ['image/png'], + 'pnm' => ['image/x-portable-anymap'], + 'ppm' => ['image/x-portable-pixmap'], + 'ras' => ['image/cmu-raster'], + 'rgb' => ['image/x-rgb'], + 'tif' => ['image/tiff'], + 'tiff' => ['image/tiff'], + 'xbm' => ['image/x-xbitmap'], + 'xpm' => ['image/x-xpixmap'], + 'xwd' => ['image/x-xwindowdump'], + 'psd' => [ + 'application/photoshop', + 'application/psd', + 'image/psd', + 'image/x-photoshop', + 'image/photoshop', + 'zz-application/zz-winassoc-psd', + ], + 'ice' => ['x-conference/x-cooltalk'], + 'iges' => ['model/iges'], + 'igs' => ['model/iges'], + 'mesh' => ['model/mesh'], + 'msh' => ['model/mesh'], + 'silo' => ['model/mesh'], + 'vrml' => ['model/vrml'], + 'wrl' => ['model/vrml'], + 'mime' => ['www/mime'], + 'pdb' => ['chemical/x-pdb'], + 'xyz' => ['chemical/x-pdb'], + 'javascript' => ['application/javascript'], + 'form' => ['application/x-www-form-urlencoded'], + 'file' => ['multipart/form-data'], + 'xhtml-mobile' => ['application/vnd.wap.xhtml+xml'], + 'atom' => ['application/atom+xml'], + 'amf' => ['application/x-amf'], + 'wap' => ['text/vnd.wap.wml', 'text/vnd.wap.wmlscript', 'image/vnd.wap.wbmp'], + 'wml' => ['text/vnd.wap.wml'], + 'wmlscript' => ['text/vnd.wap.wmlscript'], + 'wbmp' => ['image/vnd.wap.wbmp'], + 'woff' => ['application/x-font-woff'], + 'appcache' => ['text/cache-manifest'], + 'manifest' => ['text/cache-manifest'], + 'htc' => ['text/x-component'], + 'rdf' => ['application/xml'], + 'crx' => ['application/x-chrome-extension'], + 'oex' => ['application/x-opera-extension'], + 'xpi' => ['application/x-xpinstall'], + 'safariextz' => ['application/octet-stream'], + 'webapp' => ['application/x-web-app-manifest+json'], + 'vcf' => ['text/x-vcard'], + 'vtt' => ['text/vtt'], + 'mkv' => ['video/x-matroska'], + 'pkpass' => ['application/vnd.apple.pkpass'], + 'ajax' => ['text/html'], + 'bmp' => ['image/bmp'], + ]; + + /** + * Get the MIME types associated with a given file extension. + * + * @param string|null $ext The file extension to look up. Use null to return the full list. + * @return array|null An array of MIME types if found, or null if no MIME types are associated with the extension. + */ + public static function getMimeTypes(?string $ext = null): ?array + { + if ($ext === null) { + return static::$mimeTypes; + } + + return static::$mimeTypes[$ext] ?? null; + } + + /** + * Get the MIME type based on the file extension. + * + * @param string $ext The file extension. + * @param string|null $default The default MIME type to return if the extension is not found. Defaults to null. + * @return string|null The MIME type corresponding to the file extension, or the default MIME type if not found. + */ + public static function getMimeType(string $ext, ?string $default = null): ?string + { + return isset(static::$mimeTypes[$ext]) ? static::$mimeTypes[$ext][0] : $default; + } + + /** + * Add new mime types for a given file extension. + * + * If the file extension already exists, the new mime types will be merged with the existing ones. + * + * @param string $ext The file extension to associate with the mime types. + * @param array|string $mimeTypes The mime types to associate with the file extension. + * @return void + */ + public static function addMimeTypes(string $ext, array|string $mimeTypes): void + { + if (isset(static::$mimeTypes[$ext])) { + static::$mimeTypes[$ext] = array_merge(static::$mimeTypes[$ext], (array)$mimeTypes); + + return; + } + + static::$mimeTypes[$ext] = (array)$mimeTypes; + } + + /** + * Set MIME types for a given file extension. + * + * This will overwrite any existing MIME types for the file extension. + * + * @param string $ext The file extension. + * @param array|string $mimeTypes The MIME types to associate with the file extension. + * @return void + */ + public static function setMimeTypes(string $ext, array|string $mimeTypes): void + { + static::$mimeTypes[$ext] = (array)$mimeTypes; + } + + /** + * Get the file extension associated with a given MIME type. + * + * @param string $mimeType The MIME type for which to get the file extension. + * @return string|null The file extension associated with the MIME type, or null if no association is found. + */ + public static function getExtension(string $mimeType): ?string + { + foreach (static::$mimeTypes as $ext => $types) { + if (in_array($mimeType, $types, true)) { + return $ext; + } + } + + return null; + } + + /** + * Get the MIME type for a given file path. + * + * If the MIME type is not mapped to an extension then it will attempt to determine the MIME type of the file using + * the fileinfo extension. + * + * @param string $path The file path for which to get the MIME type. + * @param string $default The default MIME type to return if the MIME type cannot be determined. + * @return string The MIME type of the file, or the default MIME type if it cannot be determined. + */ + public static function getMimeTypeForFile(string $path, string $default = 'application/octet-stream'): string + { + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + if (isset(static::$mimeTypes[$ext])) { + return static::$mimeTypes[$ext][0]; + } + + $finfo = new finfo(FILEINFO_MIME); + $mimeType = $finfo->file($path); + + return $mimeType === false ? $default : $mimeType; + } +} diff --git a/src/Http/README.md b/src/Http/README.md new file mode 100644 index 00000000000..8d6bc777d4e --- /dev/null +++ b/src/Http/README.md @@ -0,0 +1,112 @@ +[![Total Downloads](https://img.shields.io/packagist/dt/cakephp/http.svg?style=flat-square)](https://packagist.org/packages/cakephp/http) +[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE.txt) + +# CakePHP Http Library + +This library provides a PSR-15 Http middleware server, PSR-7 Request and +Response objects, PSR-17 HTTP Factories, and PSR-18 Http Client. Together these +classes let you handle incoming server requests and send outgoing HTTP requests. + +## Using the Http Client + +Sending requests is straight forward. Doing a GET request looks like: + +```php +use Cake\Http\Client; + +$http = new Client(); + +// Simple get +$response = $http->get('http://example.com/test.html'); + +// Simple get with querystring +$response = $http->get('http://example.com/search', ['q' => 'widget']); + +// Simple get with querystring & additional headers +$response = $http->get('http://example.com/search', ['q' => 'widget'], [ + 'headers' => ['X-Requested-With' => 'XMLHttpRequest'], +]); +``` + +To learn more read the [Http Client documentation](https://book.cakephp.org/5/en/core-libraries/httpclient.html). + +## Using the Http Server + +The Http Server allows an `HttpApplicationInterface` to process requests and +emit responses. To get started first implement the +`Cake\Http\HttpApplicationInterface` A minimal example could look like: + +```php +namespace App; + +use Cake\Core\HttpApplicationInterface; +use Cake\Http\MiddlewareQueue; +use Cake\Http\Response; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; + +class Application implements HttpApplicationInterface +{ + /** + * Load all the application configuration and bootstrap logic. + * + * @return void + */ + public function bootstrap(): void + { + // Load configuration here. This is the first + // method Cake\Http\Server will call on your application. + } + + /** + * Define the HTTP middleware layers for an application. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to set in your App Class + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + // Add middleware for your application. + return $middlewareQueue; + } + + /** + * Handle incoming server request and return a response. + * + * @param \Psr\Http\Message\ServerRequestInterface $request The request + * @return \Psr\Http\Message\ResponseInterface + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + return new Response(['body'=>'Hello World!']); + } +} +``` + +Once you have an application with some middleware. You can start accepting +requests. In your application's webroot, you can add an `index.php` and process +requests: + +```php +emit($server->run()); +``` + +You can then run your application using PHP's built in webserver: + +```bash +php -S localhost:8765 -t ./webroot ./webroot/index.php +``` + +For more information on middleware, [consult the +documentation](https://book.cakephp.org/5/en/controllers/middleware.html) diff --git a/src/Http/RateLimit/FixedWindowRateLimiter.php b/src/Http/RateLimit/FixedWindowRateLimiter.php new file mode 100644 index 00000000000..0f7610bbd6e --- /dev/null +++ b/src/Http/RateLimit/FixedWindowRateLimiter.php @@ -0,0 +1,79 @@ +cache = $cache; + } + + /** + * @inheritDoc + */ + public function attempt(string $identifier, int $limit, int $window, int $cost = 1): array + { + $now = time(); + $windowStart = (int)($now / $window) * $window; + $key = $identifier . '_' . $windowStart; + + $count = (int)$this->cache->get($key, 0); + $allowed = $count + $cost <= $limit; + + if ($allowed) { + $count += $cost; + $ttl = $windowStart + $window - $now; + $this->cache->set($key, $count, $ttl); + } + + return [ + 'allowed' => $allowed, + 'limit' => $limit, + 'remaining' => max(0, $limit - $count), + 'reset' => $windowStart + $window, + ]; + } + + /** + * @inheritDoc + */ + public function reset(string $identifier): void + { + $now = time(); + $window = 3600; // Assume max window of 1 hour for reset + $windowStart = (int)($now / $window) * $window; + $this->cache->delete($identifier . '_' . $windowStart); + } +} diff --git a/src/Http/RateLimit/RateLimiterInterface.php b/src/Http/RateLimit/RateLimiterInterface.php new file mode 100644 index 00000000000..adb661342cb --- /dev/null +++ b/src/Http/RateLimit/RateLimiterInterface.php @@ -0,0 +1,57 @@ +reset($key); + * ``` + * + * @param string $identifier The identifier to reset + * @return void + */ + public function reset(string $identifier): void; +} diff --git a/src/Http/RateLimit/SlidingWindowRateLimiter.php b/src/Http/RateLimit/SlidingWindowRateLimiter.php new file mode 100644 index 00000000000..3fdf3f94b36 --- /dev/null +++ b/src/Http/RateLimit/SlidingWindowRateLimiter.php @@ -0,0 +1,91 @@ +cache = $cache; + } + + /** + * @inheritDoc + */ + public function attempt(string $identifier, int $limit, int $window, int $cost = 1): array + { + $now = time(); + $key = $identifier; + + $data = $this->cache->get($key, [ + 'count' => 0, + 'reset' => $now + $window, + 'window_start' => $now, + ]); + + $elapsed = $now - $data['window_start']; + if ($elapsed >= $window) { + $data = [ + 'count' => 0, + 'reset' => $now + $window, + 'window_start' => $now, + ]; + } else { + $weight = 1 - ($elapsed / $window); + $data['count'] = (int)ceil($data['count'] * $weight); + } + + $allowed = $data['count'] + $cost <= $limit; + + if ($allowed) { + $data['count'] += $cost; + $this->cache->set($key, $data, $window); + } + + return [ + 'allowed' => $allowed, + 'limit' => $limit, + 'remaining' => max(0, $limit - (int)$data['count']), + 'reset' => $data['reset'], + ]; + } + + /** + * @inheritDoc + */ + public function reset(string $identifier): void + { + $this->cache->delete($identifier); + } +} diff --git a/src/Http/RateLimit/TokenBucketRateLimiter.php b/src/Http/RateLimit/TokenBucketRateLimiter.php new file mode 100644 index 00000000000..9702e4926f8 --- /dev/null +++ b/src/Http/RateLimit/TokenBucketRateLimiter.php @@ -0,0 +1,92 @@ +cache = $cache; + } + + /** + * @inheritDoc + */ + public function attempt(string $identifier, int $limit, int $window, int $cost = 1): array + { + $now = microtime(true); + $key = $identifier; + + $data = $this->cache->get($identifier, [ + 'tokens' => $limit, + 'last_update' => $now, + ]); + + // Refill tokens based on time elapsed + $elapsed = $now - $data['last_update']; + $refillRate = $limit / $window; + $tokensToAdd = $elapsed * $refillRate; + + $data['tokens'] = min($limit, $data['tokens'] + $tokensToAdd); + $data['last_update'] = $now; + + $allowed = $data['tokens'] >= $cost; + + if ($allowed) { + $data['tokens'] -= $cost; + } + + $this->cache->set($key, $data, $window); + + // Calculate when bucket will be full + $tokensNeeded = $limit - $data['tokens']; + $secondsToFull = $tokensNeeded / $refillRate; + $reset = (int)($now + $secondsToFull); + + return [ + 'allowed' => $allowed, + 'limit' => $limit, + 'remaining' => (int)$data['tokens'], + 'reset' => $reset, + ]; + } + + /** + * @inheritDoc + */ + public function reset(string $identifier): void + { + $this->cache->delete($identifier); + } +} diff --git a/src/Http/RequestFactory.php b/src/Http/RequestFactory.php new file mode 100644 index 00000000000..6ded4d7e124 --- /dev/null +++ b/src/Http/RequestFactory.php @@ -0,0 +1,40 @@ +getParsedBody(); - $headers = []; - foreach ($request->getHeaders() as $k => $value) { - $name = sprintf('HTTP_%s', strtoupper(str_replace('-', '_', $k))); - $headers[$name] = implode(',', $value); - } - $server = $headers + $request->getServerParams(); - - $files = static::getFiles($request); - if (!empty($files)) { - $post = Hash::merge($post, $files); - } - - $input = $request->getBody()->getContents(); - $input = $input === '' ? null : $input; - - return new ServerRequest([ - 'query' => $request->getQueryParams(), - 'post' => $post, - 'cookies' => $request->getCookieParams(), - 'environment' => $server, - 'params' => static::getParams($request), - 'url' => $request->getUri()->getPath(), - 'base' => $request->getAttribute('base', ''), - 'webroot' => $request->getAttribute('webroot', '/'), - 'session' => $request->getAttribute('session', null), - 'input' => $input, - ]); - } - - /** - * Extract the routing parameters out of the request object. - * - * @param \Psr\Http\Message\ServerRequestInterface $request The request to extract params from. - * @return array The routing parameters. - */ - protected static function getParams(PsrRequest $request) - { - $params = (array)$request->getAttribute('params', []); - $params += [ - 'plugin' => null, - 'controller' => null, - 'action' => null, - '_ext' => null, - 'pass' => [] - ]; - - return $params; - } - - /** - * Extract the uploaded files out of the request object. - * - * CakePHP expects to get arrays of file information and - * not the parsed objects that PSR7 requests contain. Downsample the data here. - * - * @param \Psr\Http\Message\ServerRequestInterface $request The request to extract files from. - * @return array The routing parameters. - */ - protected static function getFiles($request) - { - return static::convertFiles([], $request->getUploadedFiles()); - } - - /** - * Convert a nested array of files to arrays. - * - * @param array $data The data to add files to. - * @param array $files The file objects to convert. - * @param string $path The current array path. - * @return array Converted file data - */ - protected static function convertFiles($data, $files, $path = '') - { - foreach ($files as $key => $file) { - $newPath = $path; - if ($newPath === '') { - $newPath = $key; - } - if ($newPath !== $key) { - $newPath .= '.' . $key; - } - - if (is_array($file)) { - $data = static::convertFiles($data, $file, $newPath); - } else { - $data = Hash::insert($data, $newPath, static::convertFile($file)); - } - } - - return $data; - } - - /** - * Convert a single file back into an array. - * - * @param \Psr\Http\Message\UploadedFileInterface $file The file to convert. - * @return array - */ - protected static function convertFile($file) - { - $error = $file->getError(); - $tmpName = ''; - if ($error === UPLOAD_ERR_OK) { - $tmpName = $file->getStream()->getMetadata('uri'); - } - - return [ - 'name' => $file->getClientFilename(), - 'type' => $file->getClientMediaType(), - 'tmp_name' => $tmpName, - 'error' => $error, - 'size' => $file->getSize(), - ]; - } -} diff --git a/src/Http/Response.php b/src/Http/Response.php index 4cf9120cb8e..987a2226dad 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -1,4 +1,6 @@ */ - protected $_statusCodes = [ + protected array $_statusCodes = [ 100 => 'Continue', 101 => 'Switching Protocols', 102 => 'Processing', @@ -84,7 +106,7 @@ class Response implements ResponseInterface 415 => 'Unsupported Media Type', 416 => 'Requested range not satisfiable', 417 => 'Expectation Failed', - 418 => 'I\'m a teapot', + 418 => "I'm a teapot", 421 => 'Misdirected Request', 422 => 'Unprocessable Entity', 423 => 'Locked', @@ -111,332 +133,92 @@ class Response implements ResponseInterface 599 => 'Network Connect Timeout Error', ]; - /** - * Holds type key to mime type mappings for known mime types. - * - * @var array - */ - protected $_mimeTypes = [ - 'html' => ['text/html', '*/*'], - 'json' => 'application/json', - 'xml' => ['application/xml', 'text/xml'], - 'xhtml' => ['application/xhtml+xml', 'application/xhtml', 'text/xhtml'], - 'webp' => 'image/webp', - 'rss' => 'application/rss+xml', - 'ai' => 'application/postscript', - 'bcpio' => 'application/x-bcpio', - 'bin' => 'application/octet-stream', - 'ccad' => 'application/clariscad', - 'cdf' => 'application/x-netcdf', - 'class' => 'application/octet-stream', - 'cpio' => 'application/x-cpio', - 'cpt' => 'application/mac-compactpro', - 'csh' => 'application/x-csh', - 'csv' => ['text/csv', 'application/vnd.ms-excel'], - 'dcr' => 'application/x-director', - 'dir' => 'application/x-director', - 'dms' => 'application/octet-stream', - 'doc' => 'application/msword', - 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'drw' => 'application/drafting', - 'dvi' => 'application/x-dvi', - 'dwg' => 'application/acad', - 'dxf' => 'application/dxf', - 'dxr' => 'application/x-director', - 'eot' => 'application/vnd.ms-fontobject', - 'eps' => 'application/postscript', - 'exe' => 'application/octet-stream', - 'ez' => 'application/andrew-inset', - 'flv' => 'video/x-flv', - 'gtar' => 'application/x-gtar', - 'gz' => 'application/x-gzip', - 'bz2' => 'application/x-bzip', - '7z' => 'application/x-7z-compressed', - 'hdf' => 'application/x-hdf', - 'hqx' => 'application/mac-binhex40', - 'ico' => 'image/x-icon', - 'ips' => 'application/x-ipscript', - 'ipx' => 'application/x-ipix', - 'js' => 'application/javascript', - 'jsonapi' => 'application/vnd.api+json', - 'latex' => 'application/x-latex', - 'lha' => 'application/octet-stream', - 'lsp' => 'application/x-lisp', - 'lzh' => 'application/octet-stream', - 'man' => 'application/x-troff-man', - 'me' => 'application/x-troff-me', - 'mif' => 'application/vnd.mif', - 'ms' => 'application/x-troff-ms', - 'nc' => 'application/x-netcdf', - 'oda' => 'application/oda', - 'otf' => 'font/otf', - 'pdf' => 'application/pdf', - 'pgn' => 'application/x-chess-pgn', - 'pot' => 'application/vnd.ms-powerpoint', - 'pps' => 'application/vnd.ms-powerpoint', - 'ppt' => 'application/vnd.ms-powerpoint', - 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'ppz' => 'application/vnd.ms-powerpoint', - 'pre' => 'application/x-freelance', - 'prt' => 'application/pro_eng', - 'ps' => 'application/postscript', - 'roff' => 'application/x-troff', - 'scm' => 'application/x-lotusscreencam', - 'set' => 'application/set', - 'sh' => 'application/x-sh', - 'shar' => 'application/x-shar', - 'sit' => 'application/x-stuffit', - 'skd' => 'application/x-koan', - 'skm' => 'application/x-koan', - 'skp' => 'application/x-koan', - 'skt' => 'application/x-koan', - 'smi' => 'application/smil', - 'smil' => 'application/smil', - 'sol' => 'application/solids', - 'spl' => 'application/x-futuresplash', - 'src' => 'application/x-wais-source', - 'step' => 'application/STEP', - 'stl' => 'application/SLA', - 'stp' => 'application/STEP', - 'sv4cpio' => 'application/x-sv4cpio', - 'sv4crc' => 'application/x-sv4crc', - 'svg' => 'image/svg+xml', - 'svgz' => 'image/svg+xml', - 'swf' => 'application/x-shockwave-flash', - 't' => 'application/x-troff', - 'tar' => 'application/x-tar', - 'tcl' => 'application/x-tcl', - 'tex' => 'application/x-tex', - 'texi' => 'application/x-texinfo', - 'texinfo' => 'application/x-texinfo', - 'tr' => 'application/x-troff', - 'tsp' => 'application/dsptype', - 'ttc' => 'font/ttf', - 'ttf' => 'font/ttf', - 'unv' => 'application/i-deas', - 'ustar' => 'application/x-ustar', - 'vcd' => 'application/x-cdlink', - 'vda' => 'application/vda', - 'xlc' => 'application/vnd.ms-excel', - 'xll' => 'application/vnd.ms-excel', - 'xlm' => 'application/vnd.ms-excel', - 'xls' => 'application/vnd.ms-excel', - 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'xlw' => 'application/vnd.ms-excel', - 'zip' => 'application/zip', - 'aif' => 'audio/x-aiff', - 'aifc' => 'audio/x-aiff', - 'aiff' => 'audio/x-aiff', - 'au' => 'audio/basic', - 'kar' => 'audio/midi', - 'mid' => 'audio/midi', - 'midi' => 'audio/midi', - 'mp2' => 'audio/mpeg', - 'mp3' => 'audio/mpeg', - 'mpga' => 'audio/mpeg', - 'ogg' => 'audio/ogg', - 'oga' => 'audio/ogg', - 'spx' => 'audio/ogg', - 'ra' => 'audio/x-realaudio', - 'ram' => 'audio/x-pn-realaudio', - 'rm' => 'audio/x-pn-realaudio', - 'rpm' => 'audio/x-pn-realaudio-plugin', - 'snd' => 'audio/basic', - 'tsi' => 'audio/TSP-audio', - 'wav' => 'audio/x-wav', - 'aac' => 'audio/aac', - 'asc' => 'text/plain', - 'c' => 'text/plain', - 'cc' => 'text/plain', - 'css' => 'text/css', - 'etx' => 'text/x-setext', - 'f' => 'text/plain', - 'f90' => 'text/plain', - 'h' => 'text/plain', - 'hh' => 'text/plain', - 'htm' => ['text/html', '*/*'], - 'ics' => 'text/calendar', - 'm' => 'text/plain', - 'rtf' => 'text/rtf', - 'rtx' => 'text/richtext', - 'sgm' => 'text/sgml', - 'sgml' => 'text/sgml', - 'tsv' => 'text/tab-separated-values', - 'tpl' => 'text/template', - 'txt' => 'text/plain', - 'text' => 'text/plain', - 'avi' => 'video/x-msvideo', - 'fli' => 'video/x-fli', - 'mov' => 'video/quicktime', - 'movie' => 'video/x-sgi-movie', - 'mpe' => 'video/mpeg', - 'mpeg' => 'video/mpeg', - 'mpg' => 'video/mpeg', - 'qt' => 'video/quicktime', - 'viv' => 'video/vnd.vivo', - 'vivo' => 'video/vnd.vivo', - 'ogv' => 'video/ogg', - 'webm' => 'video/webm', - 'mp4' => 'video/mp4', - 'm4v' => 'video/mp4', - 'f4v' => 'video/mp4', - 'f4p' => 'video/mp4', - 'm4a' => 'audio/mp4', - 'f4a' => 'audio/mp4', - 'f4b' => 'audio/mp4', - 'gif' => 'image/gif', - 'ief' => 'image/ief', - 'jpg' => 'image/jpeg', - 'jpeg' => 'image/jpeg', - 'jpe' => 'image/jpeg', - 'pbm' => 'image/x-portable-bitmap', - 'pgm' => 'image/x-portable-graymap', - 'png' => 'image/png', - 'pnm' => 'image/x-portable-anymap', - 'ppm' => 'image/x-portable-pixmap', - 'ras' => 'image/cmu-raster', - 'rgb' => 'image/x-rgb', - 'tif' => 'image/tiff', - 'tiff' => 'image/tiff', - 'xbm' => 'image/x-xbitmap', - 'xpm' => 'image/x-xpixmap', - 'xwd' => 'image/x-xwindowdump', - 'psd' => ['application/photoshop', 'application/psd', 'image/psd', 'image/x-photoshop', 'image/photoshop', 'zz-application/zz-winassoc-psd'], - 'ice' => 'x-conference/x-cooltalk', - 'iges' => 'model/iges', - 'igs' => 'model/iges', - 'mesh' => 'model/mesh', - 'msh' => 'model/mesh', - 'silo' => 'model/mesh', - 'vrml' => 'model/vrml', - 'wrl' => 'model/vrml', - 'mime' => 'www/mime', - 'pdb' => 'chemical/x-pdb', - 'xyz' => 'chemical/x-pdb', - 'javascript' => 'application/javascript', - 'form' => 'application/x-www-form-urlencoded', - 'file' => 'multipart/form-data', - 'xhtml-mobile' => 'application/vnd.wap.xhtml+xml', - 'atom' => 'application/atom+xml', - 'amf' => 'application/x-amf', - 'wap' => ['text/vnd.wap.wml', 'text/vnd.wap.wmlscript', 'image/vnd.wap.wbmp'], - 'wml' => 'text/vnd.wap.wml', - 'wmlscript' => 'text/vnd.wap.wmlscript', - 'wbmp' => 'image/vnd.wap.wbmp', - 'woff' => 'application/x-font-woff', - 'appcache' => 'text/cache-manifest', - 'manifest' => 'text/cache-manifest', - 'htc' => 'text/x-component', - 'rdf' => 'application/xml', - 'crx' => 'application/x-chrome-extension', - 'oex' => 'application/x-opera-extension', - 'xpi' => 'application/x-xpinstall', - 'safariextz' => 'application/octet-stream', - 'webapp' => 'application/x-web-app-manifest+json', - 'vcf' => 'text/x-vcard', - 'vtt' => 'text/vtt', - 'mkv' => 'video/x-matroska', - 'pkpass' => 'application/vnd.apple.pkpass', - 'ajax' => 'text/html' - ]; - - /** - * Protocol header to send to the client - * - * @var string - */ - protected $_protocol = 'HTTP/1.1'; - /** * Status code to send to the client * * @var int */ - protected $_status = 200; - - /** - * Content type to send. This can be an 'extension' that will be transformed using the $_mimetypes array - * or a complete mime-type - * - * @var string - */ - protected $_contentType = 'text/html'; + protected int $_status = 200; /** * File object for file to be read out as response * - * @var \Cake\Filesystem\File|null + * @var \SplFileInfo|null */ - protected $_file; + protected ?SplFileInfo $_file = null; /** * File range. Used for requesting ranges of files. * - * @var array + * @var array */ - protected $_fileRange = []; + protected array $_fileRange = []; /** * The charset the response body is encoded with * * @var string */ - protected $_charset = 'UTF-8'; + protected string $_charset = 'UTF-8'; /** * Holds all the cache directives that will be converted - * into headers when sending the request + * into headers when sending the response * - * @var array + * @var array */ - protected $_cacheDirectives = []; + protected array $_cacheDirectives = []; /** * Collection of cookies to send to the client * * @var \Cake\Http\Cookie\CookieCollection */ - protected $_cookies = null; + protected CookieCollection $_cookies; + + /** + * Collection of hypermedia links (PSR-13). + * + * @var \Psr\Link\EvolvableLinkProviderInterface + */ + protected EvolvableLinkProviderInterface $links; /** * Reason Phrase * * @var string */ - protected $_reasonPhrase = 'OK'; + protected string $_reasonPhrase = 'OK'; /** * Stream mode options. * * @var string */ - protected $_streamMode = 'wb+'; + protected string $_streamMode = 'wb+'; /** * Stream target or resource object. * - * @var string|resource + * @var resource|string */ protected $_streamTarget = 'php://memory'; /** * Constructor * - * @param array $options list of parameters to setup the response. Possible values are: + * @param array $options list of parameters to setup the response. Possible values are: + * * - body: the response text that should be sent to the client - * - statusCodes: additional allowable response codes * - status: the HTTP status code to respond with * - type: a complete mime-type string or an extension mapped in this class * - charset: the charset for the response body + * @throws \InvalidArgumentException */ public function __construct(array $options = []) { - if (isset($options['streamTarget'])) { - $this->_streamTarget = $options['streamTarget']; - } - if (isset($options['streamMode'])) { - $this->_streamMode = $options['streamMode']; - } + $this->_streamTarget = $options['streamTarget'] ?? $this->_streamTarget; + $this->_streamMode = $options['streamMode'] ?? $this->_streamMode; if (isset($options['stream'])) { if (!$options['stream'] instanceof StreamInterface) { throw new InvalidArgumentException('Stream option must be an object that implements StreamInterface'); @@ -446,23 +228,20 @@ public function __construct(array $options = []) $this->_createStream(); } if (isset($options['body'])) { - $this->body($options['body']); - } - if (isset($options['statusCodes'])) { - $this->httpCodes($options['statusCodes']); + $this->stream->write($options['body']); } if (isset($options['status'])) { - $this->statusCode($options['status']); - } - if (!isset($options['charset'])) { - $options['charset'] = Configure::read('App.encoding'); + $this->_setStatus($options['status']); } + $options['charset'] ??= Configure::read('App.encoding'); $this->_charset = $options['charset']; + $type = 'text/html'; if (isset($options['type'])) { - $this->_contentType = $this->resolveType($options['type']); + $type = $this->resolveType($options['type']); } - $this->_setContentType(); + $this->_setContentType($type); $this->_cookies = new CookieCollection(); + $this->links = new LinkProvider(); } /** @@ -470,290 +249,48 @@ public function __construct(array $options = []) * * @return void */ - protected function _createStream() + protected function _createStream(): void { $this->stream = new Stream($this->_streamTarget, $this->_streamMode); } - /** - * Sends the complete response to the client including headers and message body. - * Will echo out the content in the response body. - * - * @return void - * @deprecated 3.4.0 Will be removed in 4.0.0 - */ - public function send() - { - if ($this->hasHeader('Location') && $this->_status === 200) { - $this->statusCode(302); - } - - $this->_setContent(); - $this->sendHeaders(); - - if ($this->_file) { - $this->_sendFile($this->_file, $this->_fileRange); - $this->_file = null; - $this->_fileRange = []; - } else { - $this->_sendContent($this->body()); - } - - if (function_exists('fastcgi_finish_request')) { - fastcgi_finish_request(); - } - } - - /** - * Sends the HTTP headers and cookies. - * - * @return void - * @deprecated 3.4.0 Will be removed in 4.0.0 - */ - public function sendHeaders() - { - $file = $line = null; - if (headers_sent($file, $line)) { - Log::warning("Headers already sent in {$file}:{$line}"); - - return; - } - - $codeMessage = $this->_statusCodes[$this->_status]; - $this->_setCookies(); - $this->_sendHeader("{$this->_protocol} {$this->_status} {$codeMessage}"); - $this->_setContentType(); - - foreach ($this->headers as $header => $values) { - foreach ((array)$values as $value) { - $this->_sendHeader($header, $value); - } - } - } - - /** - * Sets the cookies that have been added via Cake\Http\Response::cookie() before any - * other output is sent to the client. Will set the cookies in the order they - * have been set. - * - * @return void - * @deprecated 3.4.0 Will be removed in 4.0.0 - */ - protected function _setCookies() - { - foreach ($this->_cookies as $cookie) { - setcookie( - $cookie->getName(), - $cookie->getValue(), - $cookie->getExpiresTimestamp(), - $cookie->getPath(), - $cookie->getDomain(), - $cookie->isSecure(), - $cookie->isHttpOnly() - ); - } - } - /** * Formats the Content-Type header based on the configured contentType and charset * the charset will only be set in the header if the response is of type text/* * + * Note: Content-Type header will be cleared for 304 and 204 status codes as these + * status codes must not have a Content-Type header. + * + * @param string $type The type to set. * @return void */ - protected function _setContentType() + protected function _setContentType(string $type): void { - if (in_array($this->_status, [304, 204])) { + if (in_array($this->_status, [304, 204], true)) { $this->_clearHeader('Content-Type'); return; } - $whitelist = [ - 'application/javascript', 'application/json', 'application/xml', 'application/rss+xml' + $allowed = [ + 'application/javascript', 'application/xml', 'application/rss+xml', ]; $charset = false; - if ($this->_charset && - (strpos($this->_contentType, 'text/') === 0 || in_array($this->_contentType, $whitelist)) + if ( + $this->_charset && + ( + str_starts_with($type, 'text/') || + in_array($type, $allowed, true) + ) ) { $charset = true; } - if ($charset) { - $this->_setHeader('Content-Type', "{$this->_contentType}; charset={$this->_charset}"); - } else { - $this->_setHeader('Content-Type', "{$this->_contentType}"); - } - } - - /** - * Sets the response body to an empty text if the status code is 204 or 304 - * - * @return void - * @deprecated 3.4.0 Will be removed in 4.0.0 - */ - protected function _setContent() - { - if (in_array($this->_status, [304, 204])) { - $this->body(''); - } - } - - /** - * Sends a header to the client. - * - * @param string $name the header name - * @param string|null $value the header value - * @return void - * @deprecated 3.4.0 Will be removed in 4.0.0 - */ - protected function _sendHeader($name, $value = null) - { - if ($value === null) { - header($name); + if ($charset && !str_contains($type, ';')) { + $this->_setHeader('Content-Type', "{$type}; charset={$this->_charset}"); } else { - header("{$name}: {$value}"); - } - } - - /** - * Sends a content string to the client. - * - * If the content is a callable, it is invoked. The callable should either - * return a string or output content directly and have no return value. - * - * @param string|callable $content String to send as response body or callable - * which returns/outputs content. - * @return void - * @deprecated 3.4.0 Will be removed in 4.0.0 - */ - protected function _sendContent($content) - { - if (!is_string($content) && is_callable($content)) { - $content = $content(); + $this->_setHeader('Content-Type', $type); } - - echo $content; - } - - /** - * Buffers a header string to be sent - * Returns the complete list of buffered headers - * - * ### Single header - * ``` - * header('Location', 'http://example.com'); - * ``` - * - * ### Multiple headers - * ``` - * header(['Location' => 'http://example.com', 'X-Extra' => 'My header']); - * ``` - * - * ### String header - * ``` - * header('WWW-Authenticate: Negotiate'); - * ``` - * - * ### Array of string headers - * ``` - * header(['WWW-Authenticate: Negotiate', 'Content-type: application/pdf']); - * ``` - * - * Multiple calls for setting the same header name will have the same effect as setting the header once - * with the last value sent for it - * ``` - * header('WWW-Authenticate: Negotiate'); - * header('WWW-Authenticate: Not-Negotiate'); - * ``` - * will have the same effect as only doing - * ``` - * header('WWW-Authenticate: Not-Negotiate'); - * ``` - * - * @param string|array|null $header An array of header strings or a single header string - * - an associative array of "header name" => "header value" is also accepted - * - an array of string headers is also accepted - * @param string|array|null $value The header value(s) - * @return array List of headers to be sent - * @deprecated 3.4.0 Use `withHeader()`, `getHeaderLine()` and `getHeaders()` instead. - */ - public function header($header = null, $value = null) - { - if ($header === null) { - return $this->getSimpleHeaders(); - } - - $headers = is_array($header) ? $header : [$header => $value]; - foreach ($headers as $header => $value) { - if (is_numeric($header)) { - list($header, $value) = [$value, null]; - } - if ($value === null) { - list($header, $value) = explode(':', $header, 2); - } - - $lower = strtolower($header); - if (array_key_exists($lower, $this->headerNames)) { - $header = $this->headerNames[$lower]; - } else { - $this->headerNames[$lower] = $header; - } - - $this->headers[$header] = is_array($value) ? array_map('trim', $value) : [trim($value)]; - } - - return $this->getSimpleHeaders(); - } - - /** - * Backwards compatibility helper for getting flattened headers. - * - * Previously CakePHP would store headers as a simple dictionary, now that - * we're supporting PSR7, the internal storage has each header as an array. - * - * @return array - */ - protected function getSimpleHeaders() - { - $out = []; - foreach ($this->headers as $key => $values) { - $header = $this->headerNames[strtolower($key)]; - if (count($values) === 1) { - $values = $values[0]; - } - $out[$header] = $values; - } - - return $out; - } - - /** - * Accessor for the location header. - * - * Get/Set the Location header value. - * - * @param null|string $url Either null to get the current location, or a string to set one. - * @return string|null When setting the location null will be returned. When reading the location - * a string of the current location header value (if any) will be returned. - * @deprecated 3.4.0 Mutable responses are deprecated. Use `withLocation()` and `getHeaderLine()` - * instead. - */ - public function location($url = null) - { - if ($url === null) { - $result = $this->getHeaderLine('Location'); - if (!$result) { - return null; - } - - return $result; - } - if ($this->_status === 200) { - $this->_status = 302; - } - $this->_setHeader('Location', $url); - - return null; } /** @@ -765,7 +302,7 @@ public function location($url = null) * @param string $url The location to redirect to. * @return static A new response with the Location header set. */ - public function withLocation($url) + public function withLocation(string $url): static { $new = $this->withHeader('Location', $url); if ($new->_status === 200) { @@ -778,11 +315,11 @@ public function withLocation($url) /** * Sets a header. * - * @param string $header Header key. + * @param non-empty-string $header Header key. * @param string $value Header value. * @return void */ - protected function _setHeader($header, $value) + protected function _setHeader(string $header, string $value): void { $normalized = strtolower($header); $this->headerNames[$normalized] = $header; @@ -792,10 +329,10 @@ protected function _setHeader($header, $value) /** * Clear header * - * @param string $header Header key. + * @param non-empty-string $header Header key. * @return void */ - protected function _clearHeader($header) + protected function _clearHeader(string $header): void { $normalized = strtolower($header); if (!isset($this->headerNames[$normalized])) { @@ -805,88 +342,6 @@ protected function _clearHeader($header) unset($this->headerNames[$normalized], $this->headers[$original]); } - /** - * Buffers the response message to be sent - * if $content is null the current buffer is returned - * - * @param string|callable|null $content the string or callable message to be sent - * @return string|null Current message buffer if $content param is passed as null - * @deprecated 3.4.0 Mutable response methods are deprecated. Use `withBody()`/`withStringBody()` and `getBody()` instead. - */ - public function body($content = null) - { - if ($content === null) { - if ($this->stream->isSeekable()) { - $this->stream->rewind(); - } - $result = $this->stream->getContents(); - if (strlen($result) === 0) { - return null; - } - - return $result; - } - - // Compatibility with closure/streaming responses - if (!is_string($content) && is_callable($content)) { - $this->stream = new CallbackStream($content); - } else { - $this->_createStream(); - $this->stream->write($content); - } - - return $content; - } - - /** - * Handles the callable body for backward compatibility reasons. - * - * @param callable $content Callable content. - * @return string - */ - protected function _handleCallableBody(callable $content) - { - ob_start(); - $result1 = $content(); - $result2 = ob_get_contents(); - ob_get_clean(); - - if ($result1) { - return $result1; - } - - return $result2; - } - - /** - * Sets the HTTP status code to be sent - * if $code is null the current code is returned - * - * If the status code is 304 or 204, the existing Content-Type header - * will be cleared, as these response codes have no body. - * - * @param int|null $code the HTTP status code - * @return int Current status code - * @throws \InvalidArgumentException When an unknown status code is reached. - * @deprecated 3.4.0 Use `getStatusCode()` and `withStatus()` instead. - */ - public function statusCode($code = null) - { - if ($code === null) { - return $this->_status; - } - if (!isset($this->_statusCodes[$code])) { - throw new InvalidArgumentException('Unknown status code'); - } - if (isset($this->_statusCodes[$code])) { - $this->_reasonPhrase = $this->_statusCodes[$code]; - } - $this->_status = $code; - $this->_setContentType(); - - return $code; - } - /** * Gets the response status code. * @@ -895,7 +350,7 @@ public function statusCode($code = null) * * @return int Status code. */ - public function getStatusCode() + public function getStatusCode(): int { return $this->_status; } @@ -914,28 +369,58 @@ public function getStatusCode() * If the status code is 304 or 204, the existing Content-Type header * will be cleared, as these response codes have no body. * + * There are external packages such as `fig/http-message-util` that provide HTTP + * status code constants. These can be used with any method that accepts or + * returns a status code integer. However, keep in mind that these constants + * might include status codes that are not allowed which will throw an + * `\InvalidArgumentException`. + * * @link https://tools.ietf.org/html/rfc7231#section-6 * @link https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml - * @param int $code The 3-digit integer result code to set. + * @param int $code The 3-digit integer status code to set. * @param string $reasonPhrase The reason phrase to use with the * provided status code; if none is provided, implementations MAY * use the defaults as suggested in the HTTP specification. * @return static * @throws \InvalidArgumentException For invalid status code arguments. */ - public function withStatus($code, $reasonPhrase = '') + public function withStatus(int $code, string $reasonPhrase = ''): static { $new = clone $this; - $new->_status = $code; - if (empty($reasonPhrase) && isset($new->_statusCodes[$code])) { - $reasonPhrase = $new->_statusCodes[$code]; - } - $new->_reasonPhrase = $reasonPhrase; - $new->_setContentType(); + $new->_setStatus($code, $reasonPhrase); return $new; } + /** + * Modifier for response status + * + * @param int $code The status code to set. + * @param string $reasonPhrase The response reason phrase. + * @return void + * @throws \InvalidArgumentException For invalid status code arguments. + */ + protected function _setStatus(int $code, string $reasonPhrase = ''): void + { + if ($code < static::STATUS_CODE_MIN || $code > static::STATUS_CODE_MAX) { + throw new InvalidArgumentException(sprintf( + 'Invalid status code: %s. Use a valid HTTP status code in range 1xx - 5xx.', + $code, + )); + } + + $this->_status = $code; + if ($reasonPhrase === '' && isset($this->_statusCodes[$code])) { + $reasonPhrase = $this->_statusCodes[$code]; + } + $this->_reasonPhrase = $reasonPhrase; + + // These status codes don't have bodies and can't have content-types. + if (in_array($code, [304, 204], true)) { + $this->_clearHeader('Content-Type'); + } + } + /** * Gets the response reason phrase associated with the status code. * @@ -946,128 +431,28 @@ public function withStatus($code, $reasonPhrase = '') * status code. * * @link https://tools.ietf.org/html/rfc7231#section-6 - * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * @link https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml * @return string Reason phrase; must return an empty string if none present. */ - public function getReasonPhrase() + public function getReasonPhrase(): string { return $this->_reasonPhrase; } /** - * Queries & sets valid HTTP response codes & messages. - * - * @param int|array|null $code If $code is an integer, then the corresponding code/message is - * returned if it exists, null if it does not exist. If $code is an array, then the - * keys are used as codes and the values as messages to add to the default HTTP - * codes. The codes must be integers greater than 99 and less than 1000. Keep in - * mind that the HTTP specification outlines that status codes begin with a digit - * between 1 and 5, which defines the class of response the client is to expect. - * Example: - * - * httpCodes(404); // returns [404 => 'Not Found'] + * Sets a content type definition into the map. * - * httpCodes([ - * 381 => 'Unicorn Moved', - * 555 => 'Unexpected Minotaur' - * ]); // sets these new values, and returns true - * - * httpCodes([ - * 0 => 'Nothing Here', - * -1 => 'Reverse Infinity', - * 12345 => 'Universal Password', - * 'Hello' => 'World' - * ]); // throws an exception due to invalid codes - * - * For more on HTTP status codes see: http://www.w3.org/Protocols/rfc2616/rfc2616-sec6.html#sec6.1 - * - * @return mixed Associative array of the HTTP codes as keys, and the message - * strings as values, or null of the given $code does not exist. - * @throws \InvalidArgumentException If an attempt is made to add an invalid status code - * @deprecated 3.4.0 Will be removed in 4.0.0 - */ - public function httpCodes($code = null) - { - if (empty($code)) { - return $this->_statusCodes; - } - if (is_array($code)) { - $codes = array_keys($code); - $min = min($codes); - if (!is_int($min) || $min < 100 || max($codes) > 999) { - throw new InvalidArgumentException('Invalid status code'); - } - $this->_statusCodes = $code + $this->_statusCodes; - - return true; - } - if (!isset($this->_statusCodes[$code])) { - return null; - } - - return [$code => $this->_statusCodes[$code]]; - } - - /** - * Sets the response content type. It can be either a file extension - * which will be mapped internally to a mime-type or a string representing a mime-type - * if $contentType is null the current content type is returned - * if $contentType is an associative array, content type definitions will be stored/replaced - * - * ### Setting the content type - * - * ``` - * type('jpg'); - * ``` - * - * If you attempt to set the type on a 304 or 204 status code response, the - * content type will not take effect as these status codes do not have content-types. - * - * ### Returning the current content type - * - * ``` - * type(); - * ``` - * - * ### Storing content type definitions - * - * ``` - * type(['keynote' => 'application/keynote', 'bat' => 'application/bat']); - * ``` + * E.g.: setTypeMap('xhtml', ['application/xhtml+xml', 'application/xhtml']) * - * ### Replacing a content type definition + * This is needed for RequestHandlerComponent and recognition of types. * - * ``` - * type(['jpg' => 'text/plain']); - * ``` - * - * @param string|null $contentType Content type key. - * @return mixed Current content type or false if supplied an invalid content type. - * @deprecated 3.5.5 Use getType() or withType() instead. + * @param string $type Content type. + * @param array|string $mimeType Definition of the mime type. + * @return void */ - public function type($contentType = null) + public function setTypeMap(string $type, array|string $mimeType): void { - if ($contentType === null) { - return $this->getType(); - } - if (is_array($contentType)) { - foreach ($contentType as $type => $definition) { - $this->_mimeTypes[$type] = $definition; - } - - return $this->getType(); - } - if (isset($this->_mimeTypes[$contentType])) { - $contentType = $this->_mimeTypes[$contentType]; - $contentType = is_array($contentType) ? current($contentType) : $contentType; - } - if (strpos($contentType, '/') === false) { - return false; - } - $this->_contentType = $contentType; - $this->_setContentType(); - - return $contentType; + MimeType::setMimeTypes($type, $mimeType); } /** @@ -1075,9 +460,14 @@ public function type($contentType = null) * * @return string */ - public function getType() + public function getType(): string { - return $this->_contentType; + $header = $this->getHeaderLine('Content-Type'); + if (str_contains($header, ';')) { + return explode(';', $header)[0]; + } + + return $header; } /** @@ -1089,12 +479,11 @@ public function getType() * @param string $contentType Either a file extension which will be mapped to a mime-type or a concrete mime-type. * @return static */ - public function withType($contentType) + public function withType(string $contentType): static { $mappedType = $this->resolveType($contentType); $new = clone $this; - $new->_contentType = $mappedType; - $new->_setContentType(); + $new->_setContentType($mappedType); return $new; } @@ -1106,17 +495,18 @@ public function withType($contentType) * @return string The resolved content-type * @throws \InvalidArgumentException When an invalid content-type or alias is used. */ - protected function resolveType($contentType) + protected function resolveType(string $contentType): string { - $mapped = $this->getMimeType($contentType); - if ($mapped) { - return is_array($mapped) ? current($mapped) : $mapped; + if (str_contains($contentType, '/')) { + return $contentType; } - if (strpos($contentType, '/') === false) { - throw new InvalidArgumentException(sprintf('"%s" is an invalid content type.', $contentType)); + + $mimeType = MimeType::getMimeType($contentType); + if ($mimeType === null) { + throw new InvalidArgumentException(sprintf('`%s` is an invalid content type.', $contentType)); } - return $contentType; + return $mimeType; } /** @@ -1125,15 +515,17 @@ protected function resolveType($contentType) * e.g `getMimeType('pdf'); // returns 'application/pdf'` * * @param string $alias the content type alias to map - * @return mixed String mapped mime type or false if $alias is not mapped + * @return array|string|false String mapped mime type or false if $alias is not mapped */ - public function getMimeType($alias) + public function getMimeType(string $alias): array|string|false { - if (isset($this->_mimeTypes[$alias])) { - return $this->_mimeTypes[$alias]; + $mimeTypes = MimeType::getMimeTypes($alias); + + if ($mimeTypes === null) { + return false; } - return false; + return count($mimeTypes) === 1 ? $mimeTypes[0] : $mimeTypes; } /** @@ -1141,41 +533,16 @@ public function getMimeType($alias) * * e.g `mapType('application/pdf'); // returns 'pdf'` * - * @param string|array $ctype Either a string content type to map, or an array of types. - * @return string|array|null Aliases for the types provided. + * @param array|string $ctype Either a string content type to map, or an array of types. + * @return array|string|null Aliases for the types provided. */ - public function mapType($ctype) + public function mapType(array|string $ctype): array|string|null { if (is_array($ctype)) { - return array_map([$this, 'mapType'], $ctype); - } - - foreach ($this->_mimeTypes as $alias => $types) { - if (in_array($ctype, (array)$types)) { - return $alias; - } - } - - return null; - } - - /** - * Sets the response charset - * if $charset is null the current charset is returned - * - * @param string|null $charset Character set string. - * @return string Current charset - * @deprecated 3.5.0 Use getCharset()/withCharset() instead. - */ - public function charset($charset = null) - { - if ($charset === null) { - return $this->_charset; + return array_map($this->mapType(...), $ctype); } - $this->_charset = $charset; - $this->_setContentType(); - return $this->_charset; + return MimeType::getExtension($ctype); } /** @@ -1183,7 +550,7 @@ public function charset($charset = null) * * @return string */ - public function getCharset() + public function getCharset(): string { return $this->_charset; } @@ -1194,76 +561,46 @@ public function getCharset() * @param string $charset Character set string. * @return static */ - public function withCharset($charset) + public function withCharset(string $charset): static { $new = clone $this; $new->_charset = $charset; - $new->_setContentType(); + $new->_setContentType($this->getType()); return $new; } - /** - * Sets the correct headers to instruct the client to not cache the response - * - * @return void - * @deprecated 3.4.0 Use withDisabledCache() instead. - */ - public function disableCache() - { - $this->_setHeader('Expires', 'Mon, 26 Jul 1997 05:00:00 GMT'); - $this->_setHeader('Last-Modified', gmdate('D, d M Y H:i:s') . ' GMT'); - $this->_setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0'); - } - /** * Create a new instance with headers to instruct the client to not cache the response * * @return static */ - public function withDisabledCache() + public function withDisabledCache(): static { return $this->withHeader('Expires', 'Mon, 26 Jul 1997 05:00:00 GMT') - ->withHeader('Last-Modified', gmdate('D, d M Y H:i:s') . ' GMT') + ->withHeader('Last-Modified', CakeDateTime::parse(time())->toRfc7231String()) ->withHeader('Cache-Control', 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0'); } - /** - * Sets the correct headers to instruct the client to cache the response. - * - * @param string $since a valid time since the response text has not been modified - * @param string $time a valid time for cache expiry - * @return void - * @deprecated 3.4.0 Use withCache() instead. - */ - public function cache($since, $time = '+1 day') - { - if (!is_int($time)) { - $time = strtotime($time); - } - - $this->_setHeader('Date', gmdate('D, j M Y G:i:s ', time()) . 'GMT'); - - $this->modified($since); - $this->expires($time); - $this->sharable(true); - $this->maxAge($time - time()); - } - /** * Create a new instance with the headers to enable client caching. * - * @param string $since a valid time since the response text has not been modified - * @param string $time a valid time for cache expiry + * @param string|int $since a valid time since the response text has not been modified + * @param string|int $time a valid time for cache expiry * @return static */ - public function withCache($since, $time = '+1 day') + public function withCache(string|int $since, string|int $time = '+1 day'): static { if (!is_int($time)) { $time = strtotime($time); + if ($time === false) { + throw new InvalidArgumentException( + 'Invalid time parameter. Ensure your time value can be parsed by strtotime', + ); + } } - return $this->withHeader('Date', gmdate('D, j M Y G:i:s ', time()) . 'GMT') + return $this->withHeader('Date', CakeDateTime::parse(time())->toRfc7231String()) ->withModified($since) ->withExpires($time) ->withSharable(true) @@ -1271,53 +608,14 @@ public function withCache($since, $time = '+1 day') } /** - * Sets whether a response is eligible to be cached by intermediate proxies - * This method controls the `public` or `private` directive in the Cache-Control - * header - * - * @param bool|null $public If set to true, the Cache-Control header will be set as public - * if set to false, the response will be set to private - * if no value is provided, it will return whether the response is sharable or not - * @param int|null $time time in seconds after which the response should no longer be considered fresh - * @return bool|null - */ - public function sharable($public = null, $time = null) - { - if ($public === null) { - $public = array_key_exists('public', $this->_cacheDirectives); - $private = array_key_exists('private', $this->_cacheDirectives); - $noCache = array_key_exists('no-cache', $this->_cacheDirectives); - if (!$public && !$private && !$noCache) { - return null; - } - - return $public || !($private || $noCache); - } - if ($public) { - $this->_cacheDirectives['public'] = true; - unset($this->_cacheDirectives['private']); - } else { - $this->_cacheDirectives['private'] = true; - unset($this->_cacheDirectives['public']); - } - - $this->maxAge($time); - if (!$time) { - $this->_setCacheControl(); - } - - return (bool)$public; - } - - /** - * Create a new instace with the public/private Cache-Control directive set. + * Create a new instance with the public/private Cache-Control directive set. * * @param bool $public If set to true, the Cache-Control header will be set as public * if set to false, the response will be set to private. * @param int|null $time time in seconds after which the response should no longer be considered fresh. * @return static */ - public function withSharable($public, $time = null) + public function withSharable(bool $public, ?int $time = null): static { $new = clone $this; unset($new->_cacheDirectives['private'], $new->_cacheDirectives['public']); @@ -1333,29 +631,6 @@ public function withSharable($public, $time = null) return $new; } - /** - * Sets the Cache-Control s-maxage directive. - * - * The max-age is the number of seconds after which the response should no longer be considered - * a good candidate to be fetched from a shared cache (like in a proxy server). - * If called with no parameters, this function will return the current max-age value if any - * - * @param int|null $seconds if null, the method will return the current s-maxage value - * @return int|null - */ - public function sharedMaxAge($seconds = null) - { - if ($seconds !== null) { - $this->_cacheDirectives['s-maxage'] = $seconds; - $this->_setCacheControl(); - } - if (isset($this->_cacheDirectives['s-maxage'])) { - return $this->_cacheDirectives['s-maxage']; - } - - return null; - } - /** * Create a new instance with the Cache-Control s-maxage directive. * @@ -1365,7 +640,7 @@ public function sharedMaxAge($seconds = null) * @param int $seconds The number of seconds for shared max-age * @return static */ - public function withSharedMaxAge($seconds) + public function withSharedMaxAge(int $seconds): static { $new = clone $this; $new->_cacheDirectives['s-maxage'] = $seconds; @@ -1374,28 +649,6 @@ public function withSharedMaxAge($seconds) return $new; } - /** - * Sets the Cache-Control max-age directive. - * The max-age is the number of seconds after which the response should no longer be considered - * a good candidate to be fetched from the local (client) cache. - * If called with no parameters, this function will return the current max-age value if any - * - * @param int|null $seconds if null, the method will return the current max-age value - * @return int|null - */ - public function maxAge($seconds = null) - { - if ($seconds !== null) { - $this->_cacheDirectives['max-age'] = $seconds; - $this->_setCacheControl(); - } - if (isset($this->_cacheDirectives['max-age'])) { - return $this->_cacheDirectives['max-age']; - } - - return null; - } - /** * Create an instance with Cache-Control max-age directive set. * @@ -1405,7 +658,7 @@ public function maxAge($seconds = null) * @param int $seconds The seconds a cached response can be considered valid * @return static */ - public function withMaxAge($seconds) + public function withMaxAge(int $seconds): static { $new = clone $this; $new->_cacheDirectives['max-age'] = $seconds; @@ -1414,32 +667,6 @@ public function withMaxAge($seconds) return $new; } - /** - * Sets the Cache-Control must-revalidate directive. - * must-revalidate indicates that the response should not be served - * stale by a cache under any circumstance without first revalidating - * with the origin. - * If called with no parameters, this function will return whether must-revalidate is present. - * - * @param bool|null $enable if null, the method will return the current - * must-revalidate value. If boolean sets or unsets the directive. - * @return bool - * @deprecated 3.4.0 Use withMustRevalidate() instead. - */ - public function mustRevalidate($enable = null) - { - if ($enable !== null) { - if ($enable) { - $this->_cacheDirectives['must-revalidate'] = true; - } else { - unset($this->_cacheDirectives['must-revalidate']); - } - $this->_setCacheControl(); - } - - return array_key_exists('must-revalidate', $this->_cacheDirectives); - } - /** * Create an instance with Cache-Control must-revalidate directive set. * @@ -1451,7 +678,7 @@ public function mustRevalidate($enable = null) * @param bool $enable If boolean sets or unsets the directive. * @return static */ - public function withMustRevalidate($enable) + public function withMustRevalidate(bool $enable): static { $new = clone $this; if ($enable) { @@ -1470,7 +697,7 @@ public function withMustRevalidate($enable) * * @return void */ - protected function _setCacheControl() + protected function _setCacheControl(): void { $control = ''; foreach ($this->_cacheDirectives as $key => $val) { @@ -1481,37 +708,12 @@ protected function _setCacheControl() $this->_setHeader('Cache-Control', $control); } - /** - * Sets the Expires header for the response by taking an expiration time - * If called with no parameters it will return the current Expires value - * - * ### Examples: - * - * `$response->expires('now')` Will Expire the response cache now - * `$response->expires(new DateTime('+1 day'))` Will set the expiration in next 24 hours - * `$response->expires()` Will return the current expiration header value - * - * @param string|\DateTime|null $time Valid time string or \DateTime instance. - * @return string|null - * @deprecated 3.4.0 Use withExpires() instead. - */ - public function expires($time = null) - { - if ($time !== null) { - $date = $this->_getUTCDate($time); - $this->_setHeader('Expires', $date->format('D, j M Y H:i:s') . ' GMT'); - } - - if ($this->hasHeader('Expires')) { - return $this->getHeaderLine('Expires'); - } - - return null; - } - /** * Create a new instance with the Expires header set. * + * Strings without an explicit time zone will be converted + * from the default time zone to UTC. + * * ### Examples: * * ``` @@ -1522,91 +724,36 @@ public function expires($time = null) * $response->withExpires(new DateTime('+1 day')) * ``` * - * @param string|\DateTime $time Valid time string or \DateTime instance. + * @param \DateTimeInterface|string|int|null $time Valid time string or \DateTime instance. * @return static */ - public function withExpires($time) - { - $date = $this->_getUTCDate($time); - - return $this->withHeader('Expires', $date->format('D, j M Y H:i:s') . ' GMT'); - } - - /** - * Sets the Last-Modified header for the response by taking a modification time - * If called with no parameters it will return the current Last-Modified value - * - * ### Examples: - * - * `$response->modified('now')` Will set the Last-Modified to the current time - * `$response->modified(new DateTime('+1 day'))` Will set the modification date in the past 24 hours - * `$response->modified()` Will return the current Last-Modified header value - * - * @param string|\DateTime|null $time Valid time string or \DateTime instance. - * @return string|null - * @deprecated 3.4.0 Use withModified() instead. - */ - public function modified($time = null) + public function withExpires(DateTimeInterface|string|int|null $time): static { - if ($time !== null) { - $date = $this->_getUTCDate($time); - $this->_setHeader('Last-Modified', $date->format('D, j M Y H:i:s') . ' GMT'); - } - - if ($this->hasHeader('Last-Modified')) { - return $this->getHeaderLine('Last-Modified'); - } - - return null; + return $this->withHeader('Expires', $this->getRfc7231($time)); } /** * Create a new instance with the Last-Modified header set. * + * Strings without an explicit time zone will be converted + * from the default time zone to UTC. + * * ### Examples: * * ``` * // Will Expire the response cache now * $response->withModified('now') * - * // Will set the expiration in next 24 hours - * $response->withModified(new DateTime('+1 day')) - * ``` - * - * @param string|\DateTime $time Valid time string or \DateTime instance. - * @return static - */ - public function withModified($time) - { - $date = $this->_getUTCDate($time); - - return $this->withHeader('Last-Modified', $date->format('D, j M Y H:i:s') . ' GMT'); - } - - /** - * Sets the response as Not Modified by removing any body contents - * setting the status code to "304 Not Modified" and removing all - * conflicting headers - * - * @return void + * // Will set the expiration in next 24 hours + * $response->withModified(new DateTime('+1 day')) + * ``` + * + * @param \DateTimeInterface|string|int $time Valid time string or \DateTime instance. + * @return static */ - public function notModified() + public function withModified(DateTimeInterface|string|int $time): static { - $this->statusCode(304); - $this->body(''); - - $remove = [ - 'Allow', - 'Content-Encoding', - 'Content-Language', - 'Content-Length', - 'Content-MD5', - 'Content-Type', - 'Last-Modified' - ]; - foreach ($remove as $header) { - unset($this->headers[$header]); - } + return $this->withHeader('Last-Modified', $this->getRfc7231($time)); } /** @@ -1618,7 +765,7 @@ public function notModified() * * @return static */ - public function withNotModified() + public function withNotModified(): static { $new = $this->withStatus(304); $new->_createStream(); @@ -1629,7 +776,7 @@ public function withNotModified() 'Content-Length', 'Content-MD5', 'Content-Type', - 'Last-Modified' + 'Last-Modified', ]; foreach ($remove as $header) { $new = $new->withoutHeader($header); @@ -1638,31 +785,6 @@ public function withNotModified() return $new; } - /** - * Sets the Vary header for the response, if an array is passed, - * values will be imploded into a comma separated string. If no - * parameters are passed, then an array with the current Vary header - * value is returned - * - * @param string|array|null $cacheVariances A single Vary string or an array - * containing the list for variances. - * @return array|null - * @deprecated 3.4.0 Use withVary() instead. - */ - public function vary($cacheVariances = null) - { - if ($cacheVariances !== null) { - $cacheVariances = (array)$cacheVariances; - $this->_setHeader('Vary', implode(', ', $cacheVariances)); - } - - if ($this->hasHeader('Vary')) { - return explode(', ', $this->getHeaderLine('Vary')); - } - - return null; - } - /** * Create a new instance with the Vary header set. * @@ -1670,50 +792,15 @@ public function vary($cacheVariances = null) * separated string. If no parameters are passed, then an * array with the current Vary header value is returned * - * @param string|array $cacheVariances A single Vary string or an array + * @param array|string $cacheVariances A single Vary string or an array * containing the list for variances. * @return static */ - public function withVary($cacheVariances) + public function withVary(array|string $cacheVariances): static { return $this->withHeader('Vary', (array)$cacheVariances); } - /** - * Sets the response Etag, Etags are a strong indicative that a response - * can be cached by a HTTP client. A bad way of generating Etags is - * creating a hash of the response output, instead generate a unique - * hash of the unique components that identifies a request, such as a - * modification time, a resource Id, and anything else you consider it - * makes it unique. - * - * Second parameter is used to instruct clients that the content has - * changed, but semantically, it can be used as the same thing. Think - * for instance of a page with a hit counter, two different page views - * are equivalent, but they differ by a few bytes. This leaves off to - * the Client the decision of using or not the cached page. - * - * If no parameters are passed, current Etag header is returned. - * - * @param string|null $hash The unique hash that identifies this response - * @param bool $weak Whether the response is semantically the same as - * other with the same hash or not - * @return string|null - * @deprecated 3.4.0 Use withEtag() instead. - */ - public function etag($hash = null, $weak = false) - { - if ($hash !== null) { - $this->_setHeader('Etag', sprintf('%s"%s"', $weak ? 'W/' : null, $hash)); - } - - if ($this->hasHeader('Etag')) { - return $this->getHeaderLine('Etag'); - } - - return null; - } - /** * Create a new instance with the Etag header set. * @@ -1735,9 +822,9 @@ public function etag($hash = null, $weak = false) * other with the same hash or not. Defaults to false * @return static */ - public function withEtag($hash, $weak = false) + public function withEtag(string $hash, bool $weak = false): static { - $hash = sprintf('%s"%s"', $weak ? 'W/' : null, $hash); + $hash = sprintf('%s"%s"', $weak ? 'W/' : '', $hash); return $this->withHeader('Etag', $hash); } @@ -1746,59 +833,58 @@ public function withEtag($hash, $weak = false) * Returns a DateTime object initialized at the $time param and using UTC * as timezone * - * @param string|int|\DateTime|null $time Valid time string or \DateTime instance. - * @return \DateTime + * @param \DateTimeInterface|string|int|null $time Valid time string or \DateTimeInterface instance. + * @return \DateTimeInterface */ - protected function _getUTCDate($time = null) + protected function _getUTCDate(DateTimeInterface|string|int|null $time = null): DateTimeInterface { - if ($time instanceof DateTime) { + if ($time instanceof DateTimeInterface) { $result = clone $time; } elseif (is_int($time)) { $result = new DateTime(date('Y-m-d H:i:s', $time)); } else { - $result = new DateTime($time); + $result = new DateTime($time ?? 'now'); } - $result->setTimezone(new DateTimeZone('UTC')); - return $result; + /** @phpstan-ignore-next-line */ + return $result->setTimezone(new DateTimeZone('UTC')); } /** - * Sets the correct output buffering handler to send a compressed response. Responses will - * be compressed with zlib, if the extension is available. + * Converts the time zone to GMT and returns a string in RFC7231 format. + * This replaced the deprecated and broken ``DATE_RFC7231`` formatting constant. * - * @return bool false if client does not accept compressed responses or no handler is available, true otherwise + * @param \DateTimeInterface|string|int|null $time + * @return string */ - public function compress() + protected function getRfc7231(DateTimeInterface|string|int|null $time = null): string { - $compressionEnabled = ini_get('zlib.output_compression') !== '1' && - extension_loaded('zlib') && - (strpos(env('HTTP_ACCEPT_ENCODING'), 'gzip') !== false); - - return $compressionEnabled && ob_start('ob_gzhandler'); + return $this->_getUTCDate($time)->format('D, d M Y H:i:s \G\M\T'); } /** - * Returns whether the resulting output will be compressed by PHP + * Sets the correct output buffering handler to send a compressed response. Responses will + * be compressed with zlib, if the extension is available. * - * @return bool + * @return bool false if client does not accept compressed responses or no handler is available, true otherwise */ - public function outputCompressed() + public function compress(): bool { - return strpos(env('HTTP_ACCEPT_ENCODING'), 'gzip') !== false - && (ini_get('zlib.output_compression') === '1' || in_array('ob_gzhandler', ob_list_handlers())); + return ini_get('zlib.output_compression') !== '1' && + extension_loaded('zlib') && + str_contains((string)env('HTTP_ACCEPT_ENCODING'), 'gzip') && + ob_start('ob_gzhandler'); } /** - * Sets the correct headers to instruct the browser to download the response as a file. + * Returns whether the resulting output will be compressed by PHP * - * @param string $filename The name of the file as the browser will download the response - * @return void - * @deprecated 3.4.0 Use withDownload() instead. + * @return bool */ - public function download($filename) + public function outputCompressed(): bool { - $this->header('Content-Disposition', 'attachment; filename="' . $filename . '"'); + return str_contains((string)env('HTTP_ACCEPT_ENCODING'), 'gzip') + && (ini_get('zlib.output_compression') === '1' || in_array('ob_gzhandler', ob_list_handlers(), true)); } /** @@ -1807,93 +893,90 @@ public function download($filename) * @param string $filename The name of the file as the browser will download the response * @return static */ - public function withDownload($filename) + public function withDownload(string $filename): static { return $this->withHeader('Content-Disposition', 'attachment; filename="' . $filename . '"'); } /** - * Sets the protocol to be used when sending the response. Defaults to HTTP/1.1 - * If called with no arguments, it will return the current configured protocol + * Create a new response with the Content-Length header set. * - * @param string|null $protocol Protocol to be used for sending response. - * @return string Protocol currently set - * @deprecated 3.4.0 Use getProtocolVersion() instead. + * @param string|int $bytes Number of bytes + * @return static */ - public function protocol($protocol = null) + public function withLength(string|int $bytes): static { - if ($protocol !== null) { - $this->_protocol = $protocol; - } - - return $this->_protocol; + return $this->withHeader('Content-Length', (string)$bytes); } /** - * Sets the Content-Length header for the response - * If called with no arguments returns the last Content-Length set + * Create a new response with the Link header set. + * + * ### Examples + * + * ``` + * $response = $response->withAddedLink('http://example.com?page=1', ['rel' => 'prev']) + * ->withAddedLink('http://example.com?page=3', ['rel' => 'next']); + * ``` + * + * Will generate: + * + * ``` + * Link: ; rel="prev" + * Link: ; rel="next" + * ``` * - * @param int|null $bytes Number of bytes - * @return string|null - * @deprecated 3.4.0 Use withLength() to set length instead. + * @param string $url The LinkHeader url. + * @param array $options The LinkHeader params. + * @return static + * @since 3.6.0 */ - public function length($bytes = null) + public function withAddedLink(string $url, array $options = []): static { - if ($bytes !== null) { - $this->_setHeader('Content-Length', $bytes); + $params = []; + foreach ($options as $key => $option) { + $params[] = $key . '="' . $option . '"'; } - if ($this->hasHeader('Content-Length')) { - return $this->getHeaderLine('Content-Length'); + $param = ''; + if ($params) { + $param = '; ' . implode('; ', $params); } - return null; - } - - /** - * Create a new response with the Content-Length header set. - * - * @param int|string $bytes Number of bytes - * @return static - */ - public function withLength($bytes) - { - return $this->withHeader('Content-Length', (string)$bytes); + return $this->withAddedHeader('Link', '<' . $url . '>' . $param); } /** * Checks whether a response has not been modified according to the 'If-None-Match' * (Etags) and 'If-Modified-Since' (last modification date) request - * headers. If the response is detected to be not modified, it - * is marked as so accordingly so the client can be informed of that. + * headers. * - * In order to mark a response as not modified, you need to set at least - * the Last-Modified etag response header before calling this method. Otherwise - * a comparison will not be possible. + * In order to interact with this method you must mark responses as not modified. + * You need to set at least one of the `Last-Modified` or `Etag` response headers + * before calling this method. Otherwise, a comparison will not be possible. * * @param \Cake\Http\ServerRequest $request Request object - * @return bool Whether the response was marked as not modified or not. + * @return bool Whether the response is 'modified' based on cache headers. */ - public function checkNotModified(ServerRequest $request) + public function isNotModified(ServerRequest $request): bool { - $etags = preg_split('/\s*,\s*/', (string)$request->header('If-None-Match'), 0, PREG_SPLIT_NO_EMPTY); - $modifiedSince = $request->header('If-Modified-Since'); - if ($responseTag = $this->etag()) { - $etagMatches = in_array('*', $etags) || in_array($responseTag, $etags); + $etags = preg_split('/\s*,\s*/', $request->getHeaderLine('If-None-Match'), 0, PREG_SPLIT_NO_EMPTY) ?: []; + $responseTag = $this->getHeaderLine('Etag'); + $etagMatches = null; + if ($responseTag) { + $etagMatches = in_array('*', $etags, true) || in_array($responseTag, $etags, true); } - if ($modifiedSince) { - $timeMatches = strtotime($this->modified()) === strtotime($modifiedSince); + + $modifiedSince = $request->getHeaderLine('If-Modified-Since'); + $timeMatches = null; + if ($modifiedSince && $this->hasHeader('Last-Modified')) { + $timeMatches = strtotime($this->getHeaderLine('Last-Modified')) === strtotime($modifiedSince); } - $checks = compact('etagMatches', 'timeMatches'); - if (empty($checks)) { + if ($etagMatches === null && $timeMatches === null) { return false; } - $notModified = !in_array(false, $checks, true); - if ($notModified) { - $this->notModified(); - } - return $notModified; + return $etagMatches !== false && $timeMatches !== false; } /** @@ -1903,148 +986,28 @@ public function checkNotModified(ServerRequest $request) * * @return string */ - public function __toString() + public function __toString(): string { $this->stream->rewind(); - return (string)$this->stream->getContents(); - } - - /** - * Getter/Setter for cookie configs - * - * This method acts as a setter/getter depending on the type of the argument. - * If the method is called with no arguments, it returns all configurations. - * - * If the method is called with a string as argument, it returns either the - * given configuration if it is set, or null, if it's not set. - * - * If the method is called with an array as argument, it will set the cookie - * configuration to the cookie container. - * - * ### Options (when setting a configuration) - * - name: The Cookie name - * - value: Value of the cookie - * - expire: Time the cookie expires in - * - path: Path the cookie applies to - * - domain: Domain the cookie is for. - * - secure: Is the cookie https? - * - httpOnly: Is the cookie available in the client? - * - * ### Examples - * - * ### Getting all cookies - * - * `$this->cookie()` - * - * ### Getting a certain cookie configuration - * - * `$this->cookie('MyCookie')` - * - * ### Setting a cookie configuration - * - * `$this->cookie((array) $options)` - * - * @param array|null $options Either null to get all cookies, string for a specific cookie - * or array to set cookie. - * @return mixed - * @deprecated 3.4.0 Use getCookie(), getCookies() and withCookie() instead. - */ - public function cookie($options = null) - { - if ($options === null) { - return $this->getCookies(); - } - - if (is_string($options)) { - if (!$this->_cookies->has($options)) { - return null; - } - - $cookie = $this->_cookies->get($options); - - return $this->convertCookieToArray($cookie); - } - - $options += [ - 'name' => 'CakeCookie[default]', - 'value' => '', - 'expire' => 0, - 'path' => '/', - 'domain' => '', - 'secure' => false, - 'httpOnly' => false - ]; - $expires = $options['expire'] ? new DateTime('@' . $options['expire']) : null; - $cookie = new Cookie( - $options['name'], - $options['value'], - $expires, - $options['path'], - $options['domain'], - $options['secure'], - $options['httpOnly'] - ); - $this->_cookies = $this->_cookies->add($cookie); + return $this->stream->getContents(); } /** * Create a new response with a cookie set. * - * ### Options - * - * - `value`: Value of the cookie - * - `expire`: Time the cookie expires in - * - `path`: Path the cookie applies to - * - `domain`: Domain the cookie is for. - * - `secure`: Is the cookie https? - * - `httpOnly`: Is the cookie available in the client? - * - * ### Examples + * ### Example * * ``` - * // set scalar value with defaults - * $response = $response->withCookie('remember_me', 1); - * - * // customize cookie attributes - * $response = $response->withCookie('remember_me', ['path' => '/login']); - * * // add a cookie object * $response = $response->withCookie(new Cookie('remember_me', 1)); * ``` * - * @param string|\Cake\Http\Cookie\Cookie $name The name of the cookie to set, or a cookie object - * @param array|string $data Either a string value, or an array of cookie options. + * @param \Cake\Http\Cookie\CookieInterface $cookie cookie object * @return static */ - public function withCookie($name, $data = '') + public function withCookie(CookieInterface $cookie): static { - if ($name instanceof Cookie) { - $cookie = $name; - } else { - if (!is_array($data)) { - $data = ['value' => $data]; - } - $data += [ - 'value' => '', - 'expire' => 0, - 'path' => '/', - 'domain' => '', - 'secure' => false, - 'httpOnly' => false - ]; - $expires = $data['expire'] ? new DateTime('@' . $data['expire']) : null; - $cookie = new Cookie( - $name, - $data['value'], - $expires, - $data['path'], - $data['domain'], - $data['secure'], - $data['httpOnly'] - ); - } - $new = clone $this; $new->_cookies = $new->_cookies->add($cookie); @@ -2054,52 +1017,19 @@ public function withCookie($name, $data = '') /** * Create a new response with an expired cookie set. * - * ### Options - * - * - `path`: Path the cookie applies to - * - `domain`: Domain the cookie is for. - * - `secure`: Is the cookie https? - * - `httpOnly`: Is the cookie available in the client? - * - * ### Examples + * ### Example * * ``` - * // set scalar value with defaults - * $response = $response->withExpiredCookie('remember_me'); - * - * // customize cookie attributes - * $response = $response->withExpiredCookie('remember_me', ['path' => '/login']); - * * // add a cookie object * $response = $response->withExpiredCookie(new Cookie('remember_me')); * ``` * - * @param string|\Cake\Http\Cookie\CookieInterface $name The name of the cookie to expire, or a cookie object - * @param array $options An array of cookie options. + * @param \Cake\Http\Cookie\CookieInterface $cookie cookie object * @return static */ - public function withExpiredCookie($name, $options = []) + public function withExpiredCookie(CookieInterface $cookie): static { - if ($name instanceof CookieInterface) { - $cookie = $name->withExpired(); - } else { - $options += [ - 'path' => '/', - 'domain' => '', - 'secure' => false, - 'httpOnly' => false - ]; - - $cookie = new Cookie( - $name, - '', - DateTime::createFromFormat('U', 1), - $options['path'], - $options['domain'], - $options['secure'], - $options['httpOnly'] - ); - } + $cookie = $cookie->withExpired(); $new = clone $this; $new->_cookies = $new->_cookies->add($cookie); @@ -2116,15 +1046,13 @@ public function withExpiredCookie($name, $options = []) * @param string $name The cookie name you want to read. * @return array|null Either the cookie data or null */ - public function getCookie($name) + public function getCookie(string $name): ?array { if (!$this->_cookies->has($name)) { return null; } - $cookie = $this->_cookies->get($name); - - return $this->convertCookieToArray($cookie); + return $this->_cookies->get($name)->toArray(); } /** @@ -2132,174 +1060,120 @@ public function getCookie($name) * * Returns an associative array of cookie name => cookie data. * - * @return array + * @return array */ - public function getCookies() + public function getCookies(): array { $out = []; foreach ($this->_cookies as $cookie) { - $out[$cookie->getName()] = $this->convertCookieToArray($cookie); + $out[$cookie->getName()] = $cookie->toArray(); } return $out; } /** - * Convert the cookie into an array of its properties. - * - * This method is compatible with the historical behavior of Cake\Http\Response, - * where `httponly` is `httpOnly` and `expires` is `expire` + * Get the CookieCollection from the response * - * @param \Cake\Http\Cookie\CookieInterface $cookie Cookie object. - * @return array + * @return \Cake\Http\Cookie\CookieCollection */ - protected function convertCookieToArray(CookieInterface $cookie) + public function getCookieCollection(): CookieCollection { - return [ - 'name' => $cookie->getName(), - 'value' => $cookie->getStringValue(), - 'path' => $cookie->getPath(), - 'domain' => $cookie->getDomain(), - 'secure' => $cookie->isSecure(), - 'httpOnly' => $cookie->isHttpOnly(), - 'expire' => $cookie->getExpiresTimestamp() - ]; + return $this->_cookies; } /** - * Get the CookieCollection from the response + * Get a new instance with provided cookie collection. * - * @return \Cake\Http\Cookie\CookieCollection + * @param \Cake\Http\Cookie\CookieCollection $cookieCollection Cookie collection to set. + * @return static */ - public function getCookieCollection() + public function withCookieCollection(CookieCollection $cookieCollection): static { - return $this->_cookies; + $new = clone $this; + $new->_cookies = $cookieCollection; + + return $new; } /** - * Setup access for origin and methods on cross origin requests - * - * This method allow multiple ways to setup the domains, see the examples - * - * ### Full URI - * ``` - * cors($request, 'https://www.cakephp.org'); - * ``` - * - * ### URI with wildcard - * ``` - * cors($request, 'https://*.cakephp.org'); - * ``` + * Create a new response with a hypermedia link added. * - * ### Ignoring the requested protocol - * ``` - * cors($request, 'www.cakephp.org'); - * ``` - * - * ### Any URI - * ``` - * cors($request, '*'); - * ``` + * ### Example * - * ### Whitelist of URIs * ``` - * cors($request, ['http://www.cakephp.org', '*.google.com', 'https://myproject.github.io']); + * use Cake\Http\Link\Link; + * + * $response = $response->withLink(new Link('/api/users', 'self')); + * $response = $response->withLink( + * (new Link('/css/app.css')) + * ->withRel('preload') + * ->withAttribute('as', 'style') + * ); * ``` * - * *Note* The `$allowedDomains`, `$allowedMethods`, `$allowedHeaders` parameters are deprecated. - * Instead the builder object should be used. - * - * @param \Cake\Http\ServerRequest $request Request object - * @param string|array $allowedDomains List of allowed domains, see method description for more details - * @param string|array $allowedMethods List of HTTP verbs allowed - * @param string|array $allowedHeaders List of HTTP headers allowed - * @return \Cake\Network\CorsBuilder A builder object the provides a fluent interface for defining - * additional CORS headers. + * @param \Psr\Link\LinkInterface $link The link to add. + * @return static */ - public function cors(ServerRequest $request, $allowedDomains = [], $allowedMethods = [], $allowedHeaders = []) + public function withLink(LinkInterface $link): static { - $origin = $request->header('Origin'); - $ssl = $request->is('ssl'); - $builder = new CorsBuilder($this, $origin, $ssl); - if (!$origin) { - return $builder; - } - if (empty($allowedDomains) && empty($allowedMethods) && empty($allowedHeaders)) { - return $builder; - } - - $builder->allowOrigin($allowedDomains) - ->allowMethods((array)$allowedMethods) - ->allowHeaders((array)$allowedHeaders) - ->build(); + $new = clone $this; + $new->links = $new->links->withLink($link); - return $builder; + return $new; } /** - * Setup for display or download the given file. - * - * If $_SERVER['HTTP_RANGE'] is set a slice of the file will be - * returned instead of the entire file. - * - * ### Options keys - * - * - name: Alternate download name - * - download: If `true` sets download header and forces file to be downloaded rather than displayed in browser + * Create a new response without a specific hypermedia link. * - * @param string $path Path to file. If the path is not an absolute path that resolves - * to a file, `APP` will be prepended to the path. - * @param array $options Options See above. - * @return void - * @throws \Cake\Network\Exception\NotFoundException - * @deprecated 3.4.0 Use withFile() instead. + * @param \Psr\Link\LinkInterface $link The link to remove. + * @return static */ - public function file($path, array $options = []) + public function withoutLink(LinkInterface $link): static { - $file = $this->validateFile($path); - $options += [ - 'name' => null, - 'download' => null - ]; + $new = clone $this; + $new->links = $new->links->withoutLink($link); - $extension = strtolower($file->ext()); - $download = $options['download']; - if ((!$extension || $this->type($extension) === false) && $download === null) { - $download = true; - } + return $new; + } - $fileSize = $file->size(); - if ($download) { - $agent = env('HTTP_USER_AGENT'); + /** + * Get the link provider containing all hypermedia links. + * + * @return \Psr\Link\EvolvableLinkProviderInterface + */ + public function getLinks(): EvolvableLinkProviderInterface + { + return $this->links; + } - if (preg_match('%Opera(/| )([0-9].[0-9]{1,2})%', $agent)) { - $contentType = 'application/octet-stream'; - } elseif (preg_match('/MSIE ([0-9].[0-9]{1,2})/', $agent)) { - $contentType = 'application/force-download'; - } + /** + * Get a new instance with provided link provider. + * + * @param \Psr\Link\EvolvableLinkProviderInterface $links Link provider to set. + * @return static + */ + public function withLinkProvider(EvolvableLinkProviderInterface $links): static + { + $new = clone $this; + $new->links = $links; - if (!empty($contentType)) { - $this->type($contentType); - } - if ($options['name'] === null) { - $name = $file->name; - } else { - $name = $options['name']; - } - $this->download($name); - $this->header('Content-Transfer-Encoding', 'binary'); - } + return $new; + } - $this->header('Accept-Ranges', 'bytes'); - $httpRange = env('HTTP_RANGE'); - if (isset($httpRange)) { - $this->_fileRange($file, $httpRange); - } else { - $this->header('Content-Length', $fileSize); - } + /** + * Get a CorsBuilder instance for defining CORS headers. + * + * @param \Cake\Http\ServerRequest $request Request object + * @return \Cake\Http\CorsBuilder A builder object that provides a fluent interface for defining + * additional CORS headers. + */ + public function cors(ServerRequest $request): CorsBuilder + { + $origin = $request->getHeaderLine('Origin'); + $https = $request->is('https'); - $this->_file = $file; - $this->stream = new Stream($file->path, 'rb'); + return new CorsBuilder($this, $origin, $https); } /** @@ -2316,58 +1190,46 @@ public function file($path, array $options = []) * - download: If `true` sets download header and forces file to * be downloaded rather than displayed inline. * - * @param string $path Path to file. If the path is not an absolute path that resolves - * to a file, `APP` will be prepended to the path. - * @param array $options Options See above. + * @param string $path Absolute path to file. + * @param array $options Options See above. * @return static - * @throws \Cake\Network\Exception\NotFoundException + * @throws \Cake\Http\Exception\NotFoundException */ - public function withFile($path, array $options = []) + public function withFile(string $path, array $options = []): static { $file = $this->validateFile($path); $options += [ 'name' => null, - 'download' => null + 'download' => null, ]; - $extension = strtolower($file->ext()); - $mapped = $this->getMimeType($extension); - if ((!$extension || !$mapped) && $options['download'] === null) { + $extension = $file->getExtension(); + $mapped = MimeType::getMimeTypeForFile($file->getRealPath()); + if ($extension === '' && $options['download'] === null) { $options['download'] = true; } $new = clone $this; if ($mapped) { - $new = $new->withType($extension); + $new = $new->withType($mapped); } - $fileSize = $file->size(); + $fileSize = $file->getSize(); if ($options['download']) { - $agent = env('HTTP_USER_AGENT'); - - if (preg_match('%Opera(/| )([0-9].[0-9]{1,2})%', $agent)) { - $contentType = 'application/octet-stream'; - } elseif (preg_match('/MSIE ([0-9].[0-9]{1,2})/', $agent)) { - $contentType = 'application/force-download'; - } - - if (isset($contentType)) { - $new = $new->withType($contentType); - } - $name = $options['name'] ?: $file->name; + $name = $options['name'] ?: $file->getFileName(); $new = $new->withDownload($name) ->withHeader('Content-Transfer-Encoding', 'binary'); } $new = $new->withHeader('Accept-Ranges', 'bytes'); - $httpRange = env('HTTP_RANGE'); - if (isset($httpRange)) { + $httpRange = (string)env('HTTP_RANGE'); + if ($httpRange) { $new->_fileRange($file, $httpRange); } else { $new = $new->withHeader('Content-Length', (string)$fileSize); } $new->_file = $file; - $new->stream = new Stream($file->path, 'rb'); + $new->stream = new Stream($file->getPathname(), 'rb'); return $new; } @@ -2375,10 +1237,10 @@ public function withFile($path, array $options = []) /** * Convenience method to set a string into the response body * - * @param string $string The string to be sent + * @param string|null $string The string to be sent * @return static */ - public function withStringBody($string) + public function withStringBody(?string $string): static { $new = clone $this; $new->_createStream(); @@ -2391,20 +1253,17 @@ public function withStringBody($string) * Validate a file path is a valid response body. * * @param string $path The path to the file. - * @throws \Cake\Network\Exception\NotFoundException - * @return \Cake\Filesystem\File + * @throws \Cake\Http\Exception\NotFoundException + * @return \SplFileInfo */ - protected function validateFile($path) + protected function validateFile(string $path): SplFileInfo { - if (strpos($path, '../') !== false || strpos($path, '..\\') !== false) { + if (str_contains($path, '../') || str_contains($path, '..\\')) { throw new NotFoundException(__d('cake', 'The requested file contains `..` and will not be read.')); } - if (!is_file($path)) { - $path = APP . $path; - } - $file = new File($path); - if (!$file->exists() || !$file->readable()) { + $file = new SplFileInfo($path); + if (!$file->isFile() || !$file->isReadable()) { if (Configure::read('debug')) { throw new NotFoundException(sprintf('The requested file %s was not found or not readable', $path)); } @@ -2417,9 +1276,9 @@ protected function validateFile($path) /** * Get the current file if one exists. * - * @return \Cake\Filesystem\File|null The file to use in the response or null + * @return \SplFileInfo|null The file to use in the response or null */ - public function getFile() + public function getFile(): ?SplFileInfo { return $this->_file; } @@ -2430,27 +1289,26 @@ public function getFile() * If an invalid range is requested a 416 Status code will be used * in the response. * - * @param \Cake\Filesystem\File $file The file to set a range on. + * @param \SplFileInfo $file The file to set a range on. * @param string $httpRange The range to use. * @return void - * @deprecated 3.4.0 Long term this needs to be refactored to follow immutable paradigms. - * However for now, it is simpler to leave this alone. */ - protected function _fileRange($file, $httpRange) + protected function _fileRange(SplFileInfo $file, string $httpRange): void { - $fileSize = $file->size(); + $fileSize = $file->getSize(); $lastByte = $fileSize - 1; $start = 0; $end = $lastByte; preg_match('/^bytes\s*=\s*(\d+)?\s*-\s*(\d+)?$/', $httpRange, $matches); if ($matches) { + /** @phpstan-ignore offsetAccess.notFound */ $start = $matches[1]; - $end = isset($matches[2]) ? $matches[2] : ''; + $end = $matches[2] ?? ''; } if ($start === '') { - $start = $fileSize - $end; + $start = $fileSize - (int)$end; $end = $lastByte; } if ($end === '') { @@ -2458,141 +1316,40 @@ protected function _fileRange($file, $httpRange) } if ($start > $end || $end > $lastByte || $start > $lastByte) { - $this->statusCode(416); - $this->header([ - 'Content-Range' => 'bytes 0-' . $lastByte . '/' . $fileSize - ]); + $this->_setStatus(416); + $this->_setHeader('Content-Range', 'bytes 0-' . $lastByte . '/' . $fileSize); return; } - $this->header([ - 'Content-Length' => $end - $start + 1, - 'Content-Range' => 'bytes ' . $start . '-' . $end . '/' . $fileSize - ]); - - $this->statusCode(206); + $this->_setHeader('Content-Length', (string)((int)$end - (int)$start + 1)); + $this->_setHeader('Content-Range', 'bytes ' . $start . '-' . $end . '/' . $fileSize); + $this->_setStatus(206); + /** + * @var int $start + * @var int $end + */ $this->_fileRange = [$start, $end]; } - /** - * Reads out a file, and echos the content to the client. - * - * @param \Cake\Filesystem\File $file File object - * @param array $range The range to read out of the file. - * @return bool True is whole file is echoed successfully or false if client connection is lost in between - * @deprecated 3.4.0 Will be removed in 4.0.0 - */ - protected function _sendFile($file, $range) - { - ob_implicit_flush(true); - - $file->open('rb'); - - $end = $start = false; - if ($range) { - list($start, $end) = $range; - } - if ($start !== false) { - $file->offset($start); - } - - $bufferSize = 8192; - set_time_limit(0); - session_write_close(); - while (!feof($file->handle)) { - if (!$this->_isActive()) { - $file->close(); - - return false; - } - $offset = $file->offset(); - if ($end && $offset >= $end) { - break; - } - if ($end && $offset + $bufferSize >= $end) { - $bufferSize = $end - $offset + 1; - } - echo fread($file->handle, $bufferSize); - } - $file->close(); - - return true; - } - - /** - * Returns true if connection is still active - * - * @return bool - * @deprecated 3.4.0 Will be removed in 4.0.0 - */ - protected function _isActive() - { - return connection_status() === CONNECTION_NORMAL && !connection_aborted(); - } - - /** - * Clears the contents of the topmost output buffer and discards them - * - * @return bool - * @deprecated 3.2.4 This function is not needed anymore - */ - protected function _clearBuffer() - { - //@codingStandardsIgnoreStart - return @ob_end_clean(); - //@codingStandardsIgnoreEnd - } - - /** - * Flushes the contents of the output buffer - * - * @return void - * @deprecated 3.2.4 This function is not needed anymore - */ - protected function _flushBuffer() - { - //@codingStandardsIgnoreStart - @flush(); - if (ob_get_level()) { - @ob_flush(); - } - //@codingStandardsIgnoreEnd - } - - /** - * Stop execution of the current script. Wraps exit() making - * testing easier. - * - * @param int|string $status See https://secure.php.net/exit for values - * @return void - * @deprecated 3.4.0 Will be removed in 4.0.0 - */ - public function stop($status = 0) - { - exit($status); - } - /** * Returns an array that can be used to describe the internal state of this * object. * - * @return array + * @return array */ - public function __debugInfo() + public function __debugInfo(): array { return [ 'status' => $this->_status, - 'contentType' => $this->_contentType, + 'contentType' => $this->getType(), 'headers' => $this->headers, 'file' => $this->_file, 'fileRange' => $this->_fileRange, 'cookies' => $this->_cookies, + 'links' => $this->links, 'cacheDirectives' => $this->_cacheDirectives, 'body' => (string)$this->getBody(), ]; } } - -// @deprecated Add backwards compat alias. -class_alias('Cake\Http\Response', 'Cake\Network\Response'); diff --git a/src/Http/Response/AbstractStreamResponse.php b/src/Http/Response/AbstractStreamResponse.php new file mode 100644 index 00000000000..afed3480ed2 --- /dev/null +++ b/src/Http/Response/AbstractStreamResponse.php @@ -0,0 +1,279 @@ + + */ + protected array $_defaultConfig = [ + 'flushEvery' => 1, + ]; + + /** + * Constructor. + * + * @param iterable $data The iterable data to stream (array, generator, ResultSet, etc.). + * @param array $options Streaming options. The base accepts + * `flushEvery` (int >= 1) controlling how many items are buffered before + * flushing. Subclasses add their own keys. + */ + public function __construct(iterable $data, array $options = []) + { + $this->data = $data; + $this->setConfig( + $this->normalizeStreamOptions($options + $this->_defaultConfig, $options), + null, + false, + ); + + $stream = new CallbackStream($this->createStreamCallback()); + parent::__construct(['stream' => $stream]); + + $this->applyStreamingHeaders(); + } + + /** + * Create the streaming callback installed on the response body. + * + * @return \Closure + */ + protected function createStreamCallback(): Closure + { + return function (): void { + $this->streamData(); + }; + } + + /** + * Write the streamed payload using {@see self::output()} / + * {@see self::outputAndFlush()}. + * + * Called from the response body callback. Subclasses own the wire format, + * including any wrapper bytes, item separators and end-of-stream handling. + * + * @return void + */ + abstract protected function streamData(): void; + + /** + * Return the response MIME type without the charset suffix. + * + * Called when applying streaming headers so subclasses can return a + * different value depending on the active stream options (e.g. JSON vs + * NDJSON share one response class but emit different content types). + * + * @return string + */ + abstract protected function contentType(): string; + + /** + * Validate and normalize the streaming options. + * + * Subclasses overriding this method should call `parent::normalizeStreamOptions()` + * so the shared options (currently `flushEvery`) keep their validation. + * + * @param array $options Merged options. + * @param array $originalOptions Original options passed by the caller. + * @return array + */ + protected function normalizeStreamOptions(array $options, array $originalOptions = []): array + { + if (!is_int($options['flushEvery']) || $options['flushEvery'] < 1) { + throw new InvalidArgumentException('`flushEvery` must be an integer greater than or equal to 1'); + } + + return $options; + } + + /** + * Apply headers derived from the active stream options. + * + * Sets the Content-Type (with charset) returned by {@see self::contentType()} + * and `X-Accel-Buffering: no` so reverse proxies do not buffer the body. + * + * @return void + */ + protected function applyStreamingHeaders(): void + { + $charset = Configure::read('App.encoding') ?? 'UTF-8'; + $contentType = $this->contentType() . '; charset=' . $charset; + + $this->_setHeader('Content-Type', $contentType); + $this->_setHeader('X-Accel-Buffering', 'no'); + } + + /** + * Output data without flushing. + * + * Used for structural bytes like wrapper brackets / separators that do not + * need an immediate flush. + * + * @param string $data The data to output. + * @return void + */ + protected function output(string $data): void + { + echo $data; + } + + /** + * Output data and flush to the client subject to the `flushEvery` threshold. + * + * @param string $data The data to output and flush. + * @param bool $force Whether to force an immediate flush regardless of threshold. + * @return void + */ + protected function outputAndFlush(string $data, bool $force = false): void + { + echo $data; + $this->rowsSinceLastFlush++; + + if ($force || $this->rowsSinceLastFlush >= $this->getConfigOrFail('flushEvery')) { + $this->flushOutputBuffers(); + } + } + + /** + * Flush output buffers when it is safe to do so. + * + * Only flushes at the implicit output buffer level (1) or when no buffering + * is active. Higher levels indicate explicit buffering (e.g. tests wrapping + * the call in `ob_start()`) which should not be disturbed. + * + * @return void + */ + protected function flushOutputBuffers(): void + { + $level = ob_get_level(); + if ($level <= 1) { + if ($level === 1) { + ob_flush(); + } + flush(); + $this->rowsSinceLastFlush = 0; + } + } + + /** + * Log a streaming error. + * + * No-op when `cakephp/log` is not installed (keeps the dependency + * optional for the `cakephp/http` package). + * + * @param string $message Error message. + * @param int $index Item index where the error occurred. + * @return void + */ + protected function logStreamError(string $message, int $index): void + { + if (class_exists(Log::class)) { + Log::error(sprintf( + '%s encoding failed at index %d: %s', + static::class, + $index, + $message, + )); + } + } + + /** + * Get the streaming options. + * + * @return array + */ + public function getStreamOptions(): array + { + return $this->getConfig(); + } + + /** + * Return an instance with updated streaming options. + * + * The body callback is rebuilt so the new instance streams using the + * updated options. + * + * @param array $options Options to merge with existing options. + * @return static + */ + public function withStreamOptions(array $options): static + { + $new = clone $this; + $new->setConfig( + $this->normalizeStreamOptions($options + $this->getConfig(), $options), + null, + false, + ); + $new->applyStreamingHeaders(); + + return $new->withBody(new CallbackStream($new->createStreamCallback())); + } +} diff --git a/src/Http/Response/JsonStreamResponse.php b/src/Http/Response/JsonStreamResponse.php new file mode 100644 index 00000000000..67194ba8bbb --- /dev/null +++ b/src/Http/Response/JsonStreamResponse.php @@ -0,0 +1,344 @@ + 'articles']); + * + * // NDJSON format + * return new JsonStreamResponse($query, ['format' => 'ndjson']); + * ``` + * + * ### Options + * + * - `root` (string|null, default: null): Wrap data in `{"root": [...]}` + * - `envelope` (array, default: []): Static metadata merged with streaming data + * - `dataKey` (string, default: 'data'): Key for streaming data when envelope is used + * - `format` (string, default: 'json'): Output format — 'json' or 'ndjson' + * - `transform` (callable|null, default: null): Transform each item before encoding + * - `flags` (int, default: DEFAULT_JSON_FLAGS): JSON encode flags + * - `flushEvery` (int, default: 1): Flush output buffers every N items + * + * ### ORM Integration + * + * For true streaming benefits, use unbuffered queries and avoid result formatters: + * + * ```php + * // Good - streams one row at a time + * $query = $this->Articles->find()->disableBufferedResults(); + * return new JsonStreamResponse($query); + * + * // Avoid - formatters like map(), combine() buffer results internally + * $query = $this->Articles->find()->map(fn($row) => $row); // Breaks streaming + * ``` + * + * ### Memory Profile + * + * With true streaming, memory usage stays constant: + * - 10,000 rows @ 1KB each: ~1KB memory (not ~10MB) + * - 100,000 rows @ 1KB each: ~1KB memory (not ~100MB) + * - Time to first byte: after first row (not after all rows) + */ +class JsonStreamResponse extends AbstractStreamResponse +{ + /** + * Default JSON encoding flags (consistent with JsonView). + * + * @var int + */ + public const DEFAULT_JSON_FLAGS = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT | JSON_THROW_ON_ERROR; + + /** + * JSON format constant. + * + * @var string + */ + public const FORMAT_JSON = 'json'; + + /** + * NDJSON (newline-delimited JSON) format constant. + * + * @var string + */ + public const FORMAT_NDJSON = 'ndjson'; + + /** + * Supported formats. + * + * @var array + */ + protected const SUPPORTED_FORMATS = [ + self::FORMAT_JSON, + self::FORMAT_NDJSON, + ]; + + /** + * Default streaming options. + * + * @var array + */ + protected array $_defaultConfig = [ + 'root' => null, + 'envelope' => [], + 'dataKey' => 'data', + 'format' => self::FORMAT_JSON, + 'transform' => null, + 'flags' => self::DEFAULT_JSON_FLAGS, + 'flushEvery' => 1, + ]; + + /** + * @inheritDoc + */ + protected function streamData(): void + { + if ($this->getConfigOrFail('format') === self::FORMAT_NDJSON) { + $this->streamNdjson(); + } else { + $this->streamJson(); + } + } + + /** + * @inheritDoc + */ + protected function contentType(): string + { + return $this->getConfigOrFail('format') === self::FORMAT_NDJSON + ? 'application/x-ndjson' + : 'application/json'; + } + + /** + * Stream data as standard JSON. + * + * @return void + */ + protected function streamJson(): void + { + $flags = $this->getConfigOrFail('flags'); + $root = $this->getConfig('root'); + $envelope = $this->getConfigOrFail('envelope'); + $dataKey = $this->getConfigOrFail('dataKey'); + $hasWrapper = $root !== null || $envelope !== []; + $hasItems = false; + $index = 0; + + foreach ($this->data as $item) { + if (!$hasItems) { + $encoded = $this->encodeStreamItem($item, $flags, $index); + $this->outputJsonPrefix($hasWrapper, $envelope, $root, $dataKey, $flags); + $this->output('['); + $this->outputAndFlush($encoded); + $hasItems = true; + $index++; + + continue; + } + + try { + $encoded = $this->encodeStreamItem($item, $flags, $index); + } catch (JsonException $exception) { + $this->output(','); + $this->outputAndFlush($this->buildStreamErrorMarker($exception->getMessage(), $index), force: true); + break; + } + + $this->output(','); + $this->outputAndFlush($encoded); + $index++; + } + + if (!$hasItems) { + $this->outputJsonPrefix($hasWrapper, $envelope, $root, $dataKey, $flags); + $this->output('[]'); + $this->outputJsonSuffix($hasWrapper); + + return; + } + + $this->output(']'); + $this->outputJsonSuffix($hasWrapper); + $this->flushOutputBuffers(); + } + + /** + * Stream data as NDJSON (newline-delimited JSON). + * + * @return void + */ + protected function streamNdjson(): void + { + $flags = $this->getConfigOrFail('flags'); + $hasItems = false; + $index = 0; + + foreach ($this->data as $item) { + try { + $encoded = $this->encodeStreamItem($item, $flags, $index); + } catch (JsonException $exception) { + $this->outputAndFlush($this->buildStreamErrorMarker($exception->getMessage(), $index) . "\n"); + break; + } + + $this->outputAndFlush($encoded . "\n"); + $hasItems = true; + $index++; + } + + if ($hasItems) { + $this->flushOutputBuffers(); + } + } + + /** + * Encode one stream item and normalize error handling. + * + * @param mixed $item Item to encode. + * @param int $flags JSON encode flags. + * @param int $index Item index. + * @return string + */ + protected function encodeStreamItem(mixed $item, int $flags, int $index): string + { + $transform = $this->getConfig('transform'); + if ($transform !== null) { + $item = $transform($item); + } + + try { + $encoded = json_encode($item, $flags); + } catch (JsonException $exception) { + $this->logStreamError($exception->getMessage(), $index); + + throw $exception; + } + + if ($encoded === false) { + $message = json_last_error_msg(); + $this->logStreamError($message, $index); + + throw new JsonException($message); + } + + return $encoded; + } + + /** + * Build the JSON wrapper prefix. + * + * @param bool $hasWrapper Whether wrapper output is needed. + * @param array $envelope Envelope data. + * @param string|null $root Root key. + * @param string $dataKey Data key. + * @param int $flags JSON encode flags. + * @return void + */ + protected function outputJsonPrefix( + bool $hasWrapper, + array $envelope, + ?string $root, + string $dataKey, + int $flags, + ): void { + if (!$hasWrapper) { + return; + } + + if ($envelope !== []) { + $this->output('{'); + $parts = []; + foreach ($envelope as $key => $value) { + $parts[] = json_encode($key, $flags) . ':' . json_encode($value, $flags); + } + $this->output(implode(',', $parts)); + $this->output(',' . json_encode($root ?? $dataKey, $flags) . ':'); + + return; + } + + $this->output('{' . json_encode($root, $flags) . ':'); + } + + /** + * Output the closing wrapper bytes when needed. + * + * @param bool $hasWrapper Whether wrapper output is needed. + * @return void + */ + protected function outputJsonSuffix(bool $hasWrapper): void + { + if ($hasWrapper) { + $this->output('}'); + } + } + + /** + * Build the marker emitted after a mid-stream encoding failure. + * + * @param string $message Error message. + * @param int $index Item index. + * @return string + */ + protected function buildStreamErrorMarker(string $message, int $index): string + { + return json_encode([ + '__streamError' => [ + 'message' => $message, + 'index' => $index, + ], + ], JSON_THROW_ON_ERROR); + } + + /** + * @inheritDoc + */ + protected function normalizeStreamOptions(array $options, array $originalOptions = []): array + { + $format = $options['format']; + if (!in_array($format, self::SUPPORTED_FORMATS, true)) { + throw new InvalidArgumentException(sprintf( + 'Invalid format `%s`. Supported formats are: %s', + $format, + implode(', ', self::SUPPORTED_FORMATS), + )); + } + + if (Configure::read('debug') && !isset($originalOptions['flags'])) { + $options['flags'] |= JSON_PRETTY_PRINT; + } + + return parent::normalizeStreamOptions($options, $originalOptions); + } +} diff --git a/src/Http/ResponseEmitter.php b/src/Http/ResponseEmitter.php index 9e320bc54f7..5d7f28bb55c 100644 --- a/src/Http/ResponseEmitter.php +++ b/src/Http/ResponseEmitter.php @@ -1,4 +1,6 @@ maxBufferLength = $maxBufferLength; + } + + /** + * Emit a response. + * + * Emits a response, including status line, headers, and the message body, + * according to the environment. + * + * @param \Psr\Http\Message\ResponseInterface $response The response to emit. + * @return bool */ - public function emit(ResponseInterface $response, $maxBufferLength = 8192) + public function emit(ResponseInterface $response): bool { - $file = $line = null; + $file = ''; + $line = 0; if (headers_sent($file, $line)) { - $message = "Unable to emit headers. Headers sent in file=$file line=$line"; - if (Configure::read('debug')) { - trigger_error($message, E_USER_WARNING); - } else { - Log::warning($message); - } + $message = "Unable to emit headers. Headers sent in file={$file} line={$line}"; + trigger_error($message, E_USER_WARNING); } $this->emitStatusLine($response); $this->emitHeaders($response); - $this->flush(); $range = $this->parseContentRange($response->getHeaderLine('Content-Range')); if (is_array($range)) { - $this->emitBodyRange($range, $response, $maxBufferLength); + $this->emitBodyRange($range, $response); } else { - $this->emitBody($response, $maxBufferLength); + $this->emitBody($response); } if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } + + return true; } /** * Emit the message body. * * @param \Psr\Http\Message\ResponseInterface $response The response to emit - * @param int $maxBufferLength The chunk size to emit * @return void */ - protected function emitBody(ResponseInterface $response, $maxBufferLength) + protected function emitBody(ResponseInterface $response): void { - if (in_array($response->getStatusCode(), [204, 304])) { + if (in_array($response->getStatusCode(), [204, 304], true)) { return; } $body = $response->getBody(); @@ -89,7 +104,7 @@ protected function emitBody(ResponseInterface $response, $maxBufferLength) $body->rewind(); while (!$body->eof()) { - echo $body->read($maxBufferLength); + echo $body->read($this->maxBufferLength); } } @@ -98,12 +113,11 @@ protected function emitBody(ResponseInterface $response, $maxBufferLength) * * @param array $range The range data to emit * @param \Psr\Http\Message\ResponseInterface $response The response to emit - * @param int $maxBufferLength The chunk size to emit * @return void */ - protected function emitBodyRange(array $range, ResponseInterface $response, $maxBufferLength) + protected function emitBodyRange(array $range, ResponseInterface $response): void { - list($unit, $first, $last, $length) = $range; + [, $first, $last] = $range; $body = $response->getBody(); @@ -117,14 +131,15 @@ protected function emitBodyRange(array $range, ResponseInterface $response, $max $body = new RelativeStream($body, $first); $body->rewind(); $pos = 0; + /** @var int $length */ $length = $last - $first + 1; while (!$body->eof() && $pos < $length) { - if (($pos + $maxBufferLength) > $length) { + if ($pos + $this->maxBufferLength > $length) { echo $body->read($length - $pos); break; } - echo $body->read($maxBufferLength); + echo $body->read($this->maxBufferLength); $pos = $body->tell(); } } @@ -138,14 +153,14 @@ protected function emitBodyRange(array $range, ResponseInterface $response, $max * @param \Psr\Http\Message\ResponseInterface $response The response to emit * @return void */ - protected function emitStatusLine(ResponseInterface $response) + protected function emitStatusLine(ResponseInterface $response): void { $reasonPhrase = $response->getReasonPhrase(); header(sprintf( 'HTTP/%s %d%s', $response->getProtocolVersion(), $response->getStatusCode(), - ($reasonPhrase ? ' ' . $reasonPhrase : '') + ($reasonPhrase ? ' ' . $reasonPhrase : ''), )); } @@ -160,11 +175,11 @@ protected function emitStatusLine(ResponseInterface $response) * @param \Psr\Http\Message\ResponseInterface $response The response to emit * @return void */ - protected function emitHeaders(ResponseInterface $response) + protected function emitHeaders(ResponseInterface $response): void { $cookies = []; - if (method_exists($response, 'getCookies')) { - $cookies = $response->getCookies(); + if ($response instanceof Response) { + $cookies = iterator_to_array($response->getCookieCollection()); } foreach ($response->getHeaders() as $name => $values) { @@ -177,97 +192,108 @@ protected function emitHeaders(ResponseInterface $response) header(sprintf( '%s: %s', $name, - $value + $value, ), $first); $first = false; } } $this->emitCookies($cookies); + $this->emitLinks($response); } /** - * Emit cookies using setcookie() + * Emit PSR-13 links as HTTP Link headers. * - * @param array $cookies An array of Set-Cookie headers. + * @param \Psr\Http\Message\ResponseInterface $response The response to emit. * @return void */ - protected function emitCookies(array $cookies) + protected function emitLinks(ResponseInterface $response): void { - foreach ($cookies as $cookie) { - if (is_array($cookie)) { - setcookie( - $cookie['name'], - $cookie['value'], - $cookie['expire'], - $cookie['path'], - $cookie['domain'], - $cookie['secure'], - $cookie['httpOnly'] - ); - continue; - } + if (!$response instanceof Response) { + return; + } - if (strpos($cookie, '";"') !== false) { - $cookie = str_replace('";"', '{__cookie_replace__}', $cookie); - $parts = str_replace('{__cookie_replace__}', '";"', explode(';', $cookie)); - } else { - $parts = preg_split('/\;[ \t]*/', $cookie); - } + $links = $response->getLinks()->getLinks(); + foreach ($links as $link) { + header(sprintf('Link: %s', $this->formatLink($link)), false); + } + } - list($name, $value) = explode('=', array_shift($parts), 2); - $data = [ - 'name' => urldecode($name), - 'value' => urldecode($value), - 'expires' => 0, - 'path' => '', - 'domain' => '', - 'secure' => false, - 'httponly' => false - ]; + /** + * Format a PSR-13 link as an HTTP Link header value. + * + * @param \Psr\Link\LinkInterface $link The link to format. + * @return string The formatted header value. + * @link https://www.rfc-editor.org/rfc/rfc8288 Web Linking (RFC 8288) + */ + protected function formatLink(LinkInterface $link): string + { + $parts = ['<' . $link->getHref() . '>']; - foreach ($parts as $part) { - if (strpos($part, '=') !== false) { - list($key, $value) = explode('=', $part); - } else { - $key = $part; - $value = true; - } + $rels = $link->getRels(); + if ($rels) { + $parts[] = 'rel="' . implode(' ', $rels) . '"'; + } - $key = strtolower($key); - $data[$key] = $value; + foreach ($link->getAttributes() as $key => $value) { + if (is_bool($value)) { + if ($value) { + $parts[] = $key; + } + continue; } - if (!empty($data['expires'])) { - $data['expires'] = strtotime($data['expires']); + + if (is_array($value)) { + foreach ($value as $v) { + $parts[] = $key . '="' . $this->escapeHeaderValue((string)$v) . '"'; + } + continue; } - setcookie( - $data['name'], - $data['value'], - $data['expires'], - $data['path'], - $data['domain'], - $data['secure'], - $data['httponly'] - ); + + $parts[] = $key . '="' . $this->escapeHeaderValue((string)$value) . '"'; } + + return implode('; ', $parts); } /** - * Loops through the output buffer, flushing each, before emitting - * the response. + * Escape a header value for use in a quoted string. * - * @param int|null $maxBufferLevel Flush up to this buffer level. + * @param string $value The value to escape. + * @return string The escaped value. + */ + protected function escapeHeaderValue(string $value): string + { + return str_replace(['\\', '"'], ['\\\\', '\\"'], $value); + } + + /** + * Emit cookies using setcookie() + * + * @param array<\Cake\Http\Cookie\CookieInterface|string> $cookies An array of cookies. * @return void */ - protected function flush($maxBufferLevel = null) + protected function emitCookies(array $cookies): void { - if (null === $maxBufferLevel) { - $maxBufferLevel = ob_get_level(); + foreach ($cookies as $cookie) { + $this->setCookie($cookie); } + } - while (ob_get_level() > $maxBufferLevel) { - ob_end_flush(); + /** + * Helper methods to set cookie. + * + * @param \Cake\Http\Cookie\CookieInterface|string $cookie Cookie. + * @return bool + */ + protected function setCookie(CookieInterface|string $cookie): bool + { + if (is_string($cookie)) { + $cookie = Cookie::createFromHeaderString($cookie, ['path' => '']); } + + return setcookie($cookie->getName(), $cookie->getScalarValue(), $cookie->getOptions()); } /** @@ -275,10 +301,10 @@ protected function flush($maxBufferLevel = null) * https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.16 * * @param string $header The Content-Range header to parse. - * @return false|array [unit, first, last, length]; returns false if no + * @return array|false [unit, first, last, length]; returns false if no * content range or an invalid content range is provided */ - protected function parseContentRange($header) + protected function parseContentRange(string $header): array|false { if (preg_match('/(?P[\w]+)\s+(?P\d+)-(?P\d+)\/(?P\d+|\*)/', $header, $matches)) { return [ diff --git a/src/Http/ResponseFactory.php b/src/Http/ResponseFactory.php new file mode 100644 index 00000000000..a05900b67a1 --- /dev/null +++ b/src/Http/ResponseFactory.php @@ -0,0 +1,39 @@ +withStatus($code, $reasonPhrase); + } +} diff --git a/src/Http/ResponseTransformer.php b/src/Http/ResponseTransformer.php deleted file mode 100644 index 58363baaef7..00000000000 --- a/src/Http/ResponseTransformer.php +++ /dev/null @@ -1,275 +0,0 @@ - $response->getStatusCode(), - 'body' => $body['body'], - ]; - $cake = new CakeResponse($data); - if ($body['file']) { - $cake->file($body['file']); - } - $cookies = static::parseCookies($response->getHeader('Set-Cookie')); - foreach ($cookies as $cookie) { - $cake->cookie($cookie); - } - $headers = static::collapseHeaders($response); - $cake->header($headers); - - if (!empty($headers['Content-Type'])) { - $cake->type($headers['Content-Type']); - } - - return $cake; - } - - /** - * Get the response body from a PSR7 Response. - * - * @param \Psr\Http\Message\ResponseInterface $response The response to convert. - * @return array A hash of 'body' and 'file' - */ - protected static function getBody(PsrResponse $response) - { - $stream = $response->getBody(); - if ($stream->getMetadata('wrapper_type') === 'plainfile') { - return ['body' => '', 'file' => $stream->getMetadata('uri')]; - } - if ($stream->getSize() === 0) { - return ['body' => '', 'file' => false]; - } - $stream->rewind(); - - return ['body' => $stream->getContents(), 'file' => false]; - } - - /** - * Parse the Set-Cookie headers in a PSR7 response - * into the format CakePHP expects. - * - * @param array $cookieHeader A list of Set-Cookie headers. - * @return array Parsed cookie data. - */ - protected static function parseCookies(array $cookieHeader) - { - $cookies = []; - foreach ($cookieHeader as $cookie) { - if (strpos($cookie, '";"') !== false) { - $cookie = str_replace('";"', '{__cookie_replace__}', $cookie); - $parts = preg_split('/\;[ \t]*/', $cookie); - $parts = str_replace('{__cookie_replace__}', '";"', $parts); - } else { - $parts = preg_split('/\;[ \t]*/', $cookie); - } - - list($name, $value) = explode('=', array_shift($parts), 2); - $parsed = ['name' => $name, 'value' => urldecode($value)]; - - foreach ($parts as $part) { - if (strpos($part, '=') !== false) { - list($key, $value) = explode('=', $part); - } else { - $key = $part; - $value = true; - } - - $key = strtolower($key); - if ($key === 'httponly') { - $key = 'httpOnly'; - } - if ($key === 'expires') { - $key = 'expire'; - $value = strtotime($value); - } - if (!isset($parsed[$key])) { - $parsed[$key] = $value; - } - } - $cookies[] = $parsed; - } - - return $cookies; - } - - /** - * Convert a PSR7 Response headers into a flat array - * - * @param \Psr\Http\Message\ResponseInterface $response The response to convert. - * @return array Headers. - */ - protected static function collapseHeaders(PsrResponse $response) - { - $out = []; - foreach ($response->getHeaders() as $name => $value) { - if (count($value) === 1) { - $out[$name] = $value[0]; - } else { - $out[$name] = $value; - } - } - - return $out; - } - - /** - * Convert a CakePHP response into a PSR7 one. - * - * @param \Cake\Http\Response $response The CakePHP response to convert - * @return \Psr\Http\Message\ResponseInterface $response The equivalent PSR7 response. - */ - public static function toPsr(CakeResponse $response) - { - $status = $response->statusCode(); - $headers = $response->header(); - if (!isset($headers['Content-Type'])) { - $headers = static::setContentType($headers, $response); - } - $cookies = $response->cookie(); - if ($cookies) { - $headers['Set-Cookie'] = static::buildCookieHeader($cookies); - } - $stream = static::getStream($response); - - return new DiactorosResponse($stream, $status, $headers); - } - - /** - * Add in the Content-Type header if necessary. - * - * @param array $headers The headers to update - * @param \Cake\Http\Response $response The CakePHP response to convert - * @return array The updated headers. - */ - protected static function setContentType($headers, $response) - { - if (isset($headers['Content-Type'])) { - return $headers; - } - if (in_array($response->statusCode(), [204, 304])) { - return $headers; - } - - $whitelist = [ - 'application/javascript', 'application/json', 'application/xml', 'application/rss+xml' - ]; - - $type = $response->type(); - $charset = $response->charset(); - - $hasCharset = false; - if ($charset && (strpos($type, 'text/') === 0 || in_array($type, $whitelist))) { - $hasCharset = true; - } - - $value = $type; - if ($hasCharset) { - $value = "{$type}; charset={$charset}"; - } - $headers['Content-Type'] = $value; - - return $headers; - } - - /** - * Convert an array of cookies into header lines. - * - * @param array $cookies The cookies to serialize. - * @return array A list of cookie header values. - */ - protected static function buildCookieHeader($cookies) - { - $headers = []; - foreach ($cookies as $cookie) { - $parts = [ - sprintf('%s=%s', urlencode($cookie['name']), urlencode($cookie['value'])) - ]; - if ($cookie['expire']) { - $cookie['expire'] = gmdate('D, d M Y H:i:s T', $cookie['expire']); - } - $attributes = [ - 'expire' => 'Expires=%s', - 'path' => 'Path=%s', - 'domain' => 'Domain=%s', - 'httpOnly' => 'HttpOnly', - 'secure' => 'Secure', - ]; - foreach ($attributes as $key => $attr) { - if ($cookie[$key]) { - $parts[] = sprintf($attr, $cookie[$key]); - } - } - $headers[] = implode('; ', $parts); - } - - return $headers; - } - - /** - * Get the stream for the new response. - * - * @param \Cake\Http\Response $response The cake response to extract the body from. - * @return \Psr\Http\Message\StreamInterface|string The stream. - */ - protected static function getStream($response) - { - $stream = 'php://memory'; - $body = $response->body(); - if (is_string($body) && strlen($body)) { - $stream = new Stream('php://memory', 'wb'); - $stream->write($body); - - return $stream; - } - if (is_callable($body)) { - $stream = new CallbackStream($body); - - return $stream; - } - $file = $response->getFile(); - if ($file) { - $stream = new Stream($file->path, 'rb'); - - return $stream; - } - - return $stream; - } -} diff --git a/src/Http/Runner.php b/src/Http/Runner.php index a6eaa814634..72d25cf0c77 100644 --- a/src/Http/Runner.php +++ b/src/Http/Runner.php @@ -1,4 +1,6 @@ middleware = $middleware; - $this->index = 0; + public function run( + MiddlewareQueue $queue, + ServerRequestInterface $request, + ?RequestHandlerInterface $fallbackHandler = null, + ): ResponseInterface { + $this->queue = $queue; + $this->queue->rewind(); + $this->fallbackHandler = $fallbackHandler; - return $this->__invoke($request, $response); + return $this->handle($request); } /** + * Handle incoming server request and return a response. + * * @param \Psr\Http\Message\ServerRequestInterface $request The server request - * @param \Psr\Http\Message\ResponseInterface $response The response object * @return \Psr\Http\Message\ResponseInterface An updated response */ - public function __invoke(ServerRequestInterface $request, ResponseInterface $response) + public function handle(ServerRequestInterface $request): ResponseInterface { - $next = $this->middleware->get($this->index); - if ($next) { - $this->index++; + if ( + $this->fallbackHandler instanceof RoutingApplicationInterface && + $request instanceof ServerRequest + ) { + Router::setRequest($request); + } + + if ($this->queue->valid()) { + $middleware = $this->queue->current(); + $this->queue->next(); + + return $middleware->process($request, $this); + } - return $next($request, $response, $this); + if ($this->fallbackHandler) { + return $this->fallbackHandler->handle($request); } - // End of the queue - return $response; + return new Response([ + 'body' => 'Middleware queue was exhausted without returning a response ' + . 'and no fallback request handler was set for Runner', + 'status' => 500, + ]); } } diff --git a/src/Http/Server.php b/src/Http/Server.php index 993a230f2f3..bd24b1d77c3 100644 --- a/src/Http/Server.php +++ b/src/Http/Server.php @@ -1,4 +1,6 @@ setApp($app); - $this->setRunner(new Runner()); + $this->app = $app; } /** @@ -62,61 +63,90 @@ public function __construct(HttpApplicationInterface $app) * - Run the middleware queue including the application. * * @param \Psr\Http\Message\ServerRequestInterface|null $request The request to use or null. - * @param \Psr\Http\Message\ResponseInterface|null $response The response to use or null. + * @param \Cake\Http\MiddlewareQueue|null $middlewareQueue MiddlewareQueue or null. * @return \Psr\Http\Message\ResponseInterface * @throws \RuntimeException When the application does not make a response. */ - public function run(ServerRequestInterface $request = null, ResponseInterface $response = null) - { - $this->app->bootstrap(); - $response = $response ?: new Response(); + public function run( + ?ServerRequestInterface $request = null, + ?MiddlewareQueue $middlewareQueue = null, + ): ResponseInterface { + $this->bootstrap(); + $request = $request ?: ServerRequestFactory::fromGlobals(); - $middleware = $this->app->middleware(new MiddlewareQueue()); - if (!($middleware instanceof MiddlewareQueue)) { - throw new RuntimeException('The application `middleware` method did not return a middleware queue.'); + if ($middlewareQueue === null) { + if ($this->app instanceof ContainerApplicationInterface) { + $middlewareQueue = new MiddlewareQueue([], $this->app->getContainer()); + } else { + $middlewareQueue = new MiddlewareQueue(); + } + } + + $middleware = $this->app->middleware($middlewareQueue); + if ($this->app instanceof PluginApplicationInterface) { + $middleware = $this->app->pluginMiddleware($middleware); } + $this->dispatchEvent('Server.buildMiddleware', ['middleware' => $middleware]); - $middleware->add($this->app); - $response = $this->runner->run($middleware, $request, $response); - - if (!($response instanceof ResponseInterface)) { - throw new RuntimeException(sprintf( - 'Application did not create a response. Got "%s" instead.', - is_object($response) ? get_class($response) : $response - )); + + $response = $this->runner->run($middleware, $request, $this->app); + + if ($request instanceof ServerRequest) { + $request->getSession()->close(); } return $response; } /** - * Emit the response using the PHP SAPI. + * Application bootstrap wrapper. + * + * Calls the application's `bootstrap()` hook. After the application the + * plugins are bootstrapped and events are registered. * - * @param \Psr\Http\Message\ResponseInterface $response The response to emit - * @param \Zend\Diactoros\Response\EmitterInterface|null $emitter The emitter to use. - * When null, a SAPI Stream Emitter will be used. * @return void */ - public function emit(ResponseInterface $response, EmitterInterface $emitter = null) + protected function bootstrap(): void { - if (!$emitter) { - $emitter = new ResponseEmitter(); + $this->app->bootstrap(); + if ($this->app instanceof PluginApplicationInterface) { + $this->app->pluginBootstrap(); } - $emitter->emit($response); } /** - * Set the application. + * Emit the response using the PHP SAPI. * - * @param \Cake\Core\HttpApplicationInterface $app The application to set. - * @return $this + * After the response has been emitted, the `Server.terminate` event will be triggered. + * + * The `Server.terminate` event can be used to do potentially heavy tasks after the + * response is sent to the client. Only the PHP FPM server API is able to send a + * response to the client while the server's PHP process still performs some tasks. + * For other environments the event will be triggered before the response is flushed + * to the client and will have no benefit. + * + * @param \Psr\Http\Message\ResponseInterface $response The response to emit + * @param \Cake\Http\ResponseEmitter|null $emitter The emitter to use. + * When null, a SAPI Stream Emitter will be used. + * @return void */ - public function setApp(HttpApplicationInterface $app) + public function emit(ResponseInterface $response, ?ResponseEmitter $emitter = null): void { - $this->app = $app; + $emitter ??= new ResponseEmitter(); + $emitter->emit($response); - return $this; + $request = null; + if ($this->app instanceof ContainerApplicationInterface) { + $container = $this->app->getContainer(); + if ($container->has(ServerRequest::class)) { + $request = $container->get(ServerRequest::class); + } + } + if (!$request) { + $request = Router::getRequest(); + } + $this->dispatchEvent('Server.terminate', compact('request', 'response')); } /** @@ -124,21 +154,42 @@ public function setApp(HttpApplicationInterface $app) * * @return \Cake\Core\HttpApplicationInterface The application that will be run. */ - public function getApp() + public function getApp(): HttpApplicationInterface { return $this->app; } /** - * Set the runner + * Get the application's event manager or the global one. * - * @param \Cake\Http\Runner $runner The runner to use. + * @return \Cake\Event\EventManagerInterface + */ + public function getEventManager(): EventManagerInterface + { + if ($this->app instanceof EventDispatcherInterface) { + return $this->app->getEventManager(); + } + + return EventManager::instance(); + } + + /** + * Set the application's event manager. + * + * If the application does not support events, an exception will be raised. + * + * @param \Cake\Event\EventManagerInterface $eventManager The event manager to set. * @return $this + * @throws \InvalidArgumentException */ - public function setRunner(Runner $runner) + public function setEventManager(EventManagerInterface $eventManager) { - $this->runner = $runner; + if ($this->app instanceof EventDispatcherInterface) { + $this->app->setEventManager($eventManager); + + return $this; + } - return $this; + throw new InvalidArgumentException('Cannot set the event manager, the application does not support events.'); } } diff --git a/src/Http/ServerRequest.php b/src/Http/ServerRequest.php index f7ebc8e573a..8079954f00e 100644 --- a/src/Http/ServerRequest.php +++ b/src/Http/ServerRequest.php @@ -1,4 +1,6 @@ null, 'controller' => null, 'action' => null, '_ext' => null, - 'pass' => [] + 'pass' => [], ]; /** @@ -56,80 +57,60 @@ class ServerRequest implements ArrayAccess, ServerRequestInterface * In PUT/PATCH/DELETE requests this property will contain the form-urlencoded * data. * - * @var array - * @deprecated 3.4.0 This public property will be removed in 4.0.0. Use getData() instead. + * @var object|array|null */ - public $data = []; + protected object|array|null $data = []; /** * Array of query string arguments * * @var array - * @deprecated 3.4.0 This public property will be removed in 4.0.0. Use getQuery() or getQueryParams() instead. */ - public $query = []; + protected array $query = []; /** * Array of cookie data. * - * @var array - * @deprecated 3.4.0 This public property will be removed in 4.0.0. Use getCookie() instead. + * @var array */ - public $cookies = []; + protected array $cookies = []; /** * Array of environment data. * - * @var array - */ - protected $_environment = []; - - /** - * The URL string used for the request. - * - * @var string + * @var array */ - public $url; + protected array $_environment = []; /** * Base URL path. * * @var string - * @deprecated 3.4.0 This public property will be removed in 4.0.0. Use getAttribute('base') instead. */ - public $base; + protected string $base; /** * webroot path segment for the request. * * @var string - * @deprecated 3.4.0 This public property will be removed in 4.0.0. Use getAttribute('webroot') instead. - */ - public $webroot = '/'; - - /** - * The full address to the current request - * - * @var string - * @deprecated 3.4.0 This public property will be removed in 4.0.0. Use getUri()->getPath() instead. */ - public $here; + protected string $webroot = '/'; /** - * Whether or not to trust HTTP_X headers set by most load balancers. + * Whether to trust HTTP_X headers set by most load balancers. * Only set to true if your application runs behind load balancers/proxies * that you control. * * @var bool */ - public $trustProxy = false; + public bool $trustProxy = false; /** - * Contents of php://input + * Trusted proxies list * - * @var string + * @var array */ - protected $_input; + protected array $trustedProxies = []; /** * The built in detectors used with `is()` can be modified with `addDetector()`. @@ -137,9 +118,9 @@ class ServerRequest implements ArrayAccess, ServerRequestInterface * There are several ways to specify a detector, see \Cake\Http\ServerRequest::addDetector() for the * various formats and ways to define detectors. * - * @var array + * @var array<\Closure|array> */ - protected static $_detectors = [ + protected static array $_detectors = [ 'get' => ['env' => 'REQUEST_METHOD', 'value' => 'GET'], 'post' => ['env' => 'REQUEST_METHOD', 'value' => 'POST'], 'put' => ['env' => 'REQUEST_METHOD', 'value' => 'PUT'], @@ -147,90 +128,86 @@ class ServerRequest implements ArrayAccess, ServerRequestInterface 'delete' => ['env' => 'REQUEST_METHOD', 'value' => 'DELETE'], 'head' => ['env' => 'REQUEST_METHOD', 'value' => 'HEAD'], 'options' => ['env' => 'REQUEST_METHOD', 'value' => 'OPTIONS'], - 'ssl' => ['env' => 'HTTPS', 'options' => [1, 'on']], + 'https' => ['env' => 'HTTPS', 'options' => [1, 'on']], 'ajax' => ['env' => 'HTTP_X_REQUESTED_WITH', 'value' => 'XMLHttpRequest'], - 'flash' => ['env' => 'HTTP_USER_AGENT', 'pattern' => '/^(Shockwave|Adobe) Flash/'], - 'requested' => ['param' => 'requested', 'value' => 1], 'json' => ['accept' => ['application/json'], 'param' => '_ext', 'value' => 'json'], - 'xml' => ['accept' => ['application/xml', 'text/xml'], 'param' => '_ext', 'value' => 'xml'], + 'xml' => [ + 'accept' => ['application/xml', 'text/xml'], + 'exclude' => ['text/html'], + 'param' => '_ext', + 'value' => 'xml', + ], ]; /** * Instance cache for results of is(something) calls * - * @var array + * @var array */ - protected $_detectorCache = []; + protected array $_detectorCache = []; /** * Request body stream. Contains php://input unless `input` constructor option is used. * * @var \Psr\Http\Message\StreamInterface */ - protected $stream; + protected StreamInterface $stream; /** * Uri instance * * @var \Psr\Http\Message\UriInterface */ - protected $uri; + protected UriInterface $uri; /** * Instance of a Session object relative to this request * - * @var \Cake\Network\Session + * @var \Cake\Http\Session + */ + protected Session $session; + + /** + * Instance of a FlashMessage object relative to this request + * + * @var \Cake\Http\FlashMessage */ - protected $session; + protected FlashMessage $flash; /** * Store the additional attributes attached to the request. * - * @var array + * @var array */ - protected $attributes = []; + protected array $attributes = []; /** - * A list of propertes that emulated by the PSR7 attribute methods. + * A list of properties that emulated by the PSR7 attribute methods. * - * @var array + * @var array */ - protected $emulatedAttributes = ['session', 'webroot', 'base', 'params']; + protected array $emulatedAttributes = ['session', 'flash', 'webroot', 'base', 'params', 'here']; /** * Array of Psr\Http\Message\UploadedFileInterface objects. * * @var array */ - protected $uploadedFiles = []; + protected array $uploadedFiles = []; /** * The HTTP protocol version used. * * @var string|null */ - protected $protocol; + protected ?string $protocol = null; /** * The request target if overridden * * @var string|null */ - protected $requestTarget; - - /** - * Wrapper method to create a new request from PHP superglobals. - * - * Uses the $_GET, $_POST, $_FILES, $_COOKIE, $_SERVER, $_ENV and php://input data to construct - * the request. - * - * @return self - * @deprecated 3.4.0 Use `Cake\Http\ServerRequestFactory` instead. - */ - public static function createFromGlobals() - { - return ServerRequestFactory::fromGlobals(); - } + protected ?string $requestTarget = null; /** * Create a new request object. @@ -241,25 +218,21 @@ public static function createFromGlobals() * * - `post` POST data or non query string data * - `query` Additional data from the query string. - * - `files` Uploaded file data formatted like $_FILES. + * - `files` Uploaded files in a normalized structure, with each leaf an instance of UploadedFileInterface. * - `cookies` Cookies for this request. * - `environment` $_SERVER and $_ENV data. - * - ~~`url`~~ The URL without the base path for the request. This option is deprecated and will be removed in 4.0.0 - * - `uri` The PSR7 UriInterface object. If null, one will be created. + * - `url` The URL without the base path for the request. + * - `uri` The PSR7 UriInterface object. If null, one will be created from `url` or `environment`. * - `base` The base URL for the request. * - `webroot` The webroot directory for the request. * - `input` The data that would come from php://input this is useful for simulating * requests with put, patch or delete data. * - `session` An instance of a Session object * - * @param string|array $config An array of request data to create a request with. - * The string version of this argument is *deprecated* and will be removed in 4.0.0 + * @param array $config An array of request data to create a request with. */ - public function __construct($config = []) + public function __construct(array $config = []) { - if (is_string($config)) { - $config = ['url' => $config]; - } $config += [ 'params' => $this->params, 'query' => [], @@ -280,317 +253,215 @@ public function __construct($config = []) /** * Process the config/settings data into properties. * - * @param array $config The config data to use. + * @param array $config The config data to use. * @return void */ - protected function _setConfig($config) + protected function _setConfig(array $config): void { - if (!empty($config['url']) && $config['url'][0] === '/') { - $config['url'] = substr($config['url'], 1); - } - if (empty($config['session'])) { $config['session'] = new Session([ - 'cookiePath' => $config['base'] + 'cookiePath' => $config['base'], ]); } - $this->_environment = $config['environment']; + if (empty($config['environment']['REQUEST_METHOD'])) { + $config['environment']['REQUEST_METHOD'] = 'GET'; + } + $this->cookies = $config['cookies']; - if (isset($config['uri']) && $config['uri'] instanceof UriInterface) { + if (isset($config['uri'])) { + if (!$config['uri'] instanceof UriInterface) { + throw new CakeException('The `uri` key must be an instance of ' . UriInterface::class); + } $uri = $config['uri']; } else { - $uri = ServerRequestFactory::createUri($config['environment']); + if ($config['url'] !== '') { + $config = $this->processUrlOption($config); + } + ['uri' => $uri] = UriFactory::marshalUriAndBaseFromSapi($config['environment']); } - // Extract a query string from config[url] if present. - // This is required for backwards compatibility and keeping - // UriInterface implementations happy. - $querystr = ''; - if (strpos($config['url'], '?') !== false) { - list($config['url'], $querystr) = explode('?', $config['url']); - } - if ($config['url']) { - $uri = $uri->withPath('/' . $config['url']); - } - if (strlen($querystr)) { - $uri = $uri->withQuery($querystr); - } + $this->_environment = $config['environment']; $this->uri = $uri; $this->base = $config['base']; $this->webroot = $config['webroot']; - $this->url = substr($uri->getPath(), 1); - $this->here = $this->base . '/' . $this->url; - if (isset($config['input'])) { $stream = new Stream('php://memory', 'rw'); $stream->write($config['input']); $stream->rewind(); } else { - $stream = new PhpInputStream(); + $stream = new Stream('php://input'); } $this->stream = $stream; - $config['post'] = $this->_processPost($config['post']); - $this->data = $this->_processFiles($config['post'], $config['files']); - $this->query = $this->_processGet($config['query'], $querystr); + $post = $config['post']; + if (!(is_array($post) || is_object($post) || $post === null)) { + throw new InvalidArgumentException(sprintf( + '`post` key must be an array, object or null.' + . ' Got `%s` instead.', + get_debug_type($post), + )); + } + $this->data = $post; + $this->uploadedFiles = $config['files']; + $this->query = $config['query']; $this->params = $config['params']; $this->session = $config['session']; + $this->flash = new FlashMessage($this->session); } /** - * Sets the REQUEST_METHOD environment variable based on the simulated _method - * HTTP override value. The 'ORIGINAL_REQUEST_METHOD' is also preserved, if you - * want the read the non-simulated HTTP method the client used. + * Set environment vars based on `url` option to facilitate UriInterface instance generation. * - * @param array $data Array of post data. - * @return array + * `query` option is also updated based on URL's querystring. + * + * @param array $config Config array. + * @return array Update config. */ - protected function _processPost($data) + protected function processUrlOption(array $config): array { - $method = $this->getEnv('REQUEST_METHOD'); - $override = false; - - if (in_array($method, ['PUT', 'DELETE', 'PATCH']) && - strpos($this->contentType(), 'application/x-www-form-urlencoded') === 0 - ) { - $data = $this->input(); - parse_str($data, $data); - } - if ($this->hasHeader('X-Http-Method-Override')) { - $data['_method'] = $this->getHeaderLine('X-Http-Method-Override'); - $override = true; - } - $this->_environment['ORIGINAL_REQUEST_METHOD'] = $method; - if (isset($data['_method'])) { - $this->_environment['REQUEST_METHOD'] = $data['_method']; - unset($data['_method']); - $override = true; + if (!str_starts_with($config['url'], '/')) { + $config['url'] = '/' . $config['url']; } - if ($override && !in_array($this->_environment['REQUEST_METHOD'], ['PUT', 'POST', 'DELETE', 'PATCH'])) { - $data = []; + if (str_contains($config['url'], '?')) { + [$config['url'], $config['environment']['QUERY_STRING']] = explode('?', $config['url']); + + parse_str($config['environment']['QUERY_STRING'], $queryArgs); + $config['query'] += $queryArgs; } - return $data; + $config['environment']['REQUEST_URI'] = $config['url']; + + return $config; } /** - * Process the GET parameters and move things into the object. + * Get the content type used in this request. * - * @param array $query The array to which the parsed keys/values are being added. - * @param string $queryString A query string from the URL if provided - * @return array An array containing the parsed query string as keys/values. + * @return string|null */ - protected function _processGet($query, $queryString = '') + public function contentType(): ?string { - $unsetUrl = '/' . str_replace(['.', ' '], '_', urldecode($this->url)); - unset($query[$unsetUrl], $query[$this->base . $unsetUrl]); - if (strlen($queryString)) { - parse_str($queryString, $queryArgs); - $query += $queryArgs; - } - - return $query; + return $this->getEnv('CONTENT_TYPE') ?: $this->getEnv('HTTP_CONTENT_TYPE'); } /** - * Process uploaded files and move things onto the post data. + * Returns the instance of the Session object for this request * - * @param array $post Post data to merge files onto. - * @param array $files Uploaded files to merge in. - * @return array merged post + file data. + * @return \Cake\Http\Session */ - protected function _processFiles($post, $files) + public function getSession(): Session { - if (!is_array($files)) { - return $post; - } - $fileData = []; - foreach ($files as $key => $value) { - if ($value instanceof UploadedFileInterface) { - $fileData[$key] = $value; - continue; - } - - if (is_array($value) && isset($value['tmp_name'])) { - $fileData[$key] = $this->_createUploadedFile($value); - continue; - } - - throw new InvalidArgumentException(sprintf( - 'Invalid value in FILES "%s"', - json_encode($value) - )); - } - $this->uploadedFiles = $fileData; - - // Make a flat map that can be inserted into $post for BC. - $fileMap = Hash::flatten($fileData); - foreach ($fileMap as $key => $file) { - $error = $file->getError(); - $tmpName = ''; - if ($error === UPLOAD_ERR_OK) { - $tmpName = $file->getStream()->getMetadata('uri'); - } - $post = Hash::insert($post, $key, [ - 'tmp_name' => $tmpName, - 'error' => $error, - 'name' => $file->getClientFilename(), - 'type' => $file->getClientMediaType(), - 'size' => $file->getSize(), - ]); - } - - return $post; + return $this->session; } /** - * Create an UploadedFile instance from a $_FILES array. - * - * If the value represents an array of values, this method will - * recursively process the data. + * Returns the instance of the FlashMessage object for this request * - * @param array $value $_FILES struct - * @return array|UploadedFileInterface + * @return \Cake\Http\FlashMessage */ - protected function _createUploadedFile(array $value) + public function getFlash(): FlashMessage { - if (is_array($value['tmp_name'])) { - return $this->_normalizeNestedFiles($value); - } - - return new UploadedFile( - $value['tmp_name'], - $value['size'], - $value['error'], - $value['name'], - $value['type'] - ); + return $this->flash; } /** - * Normalize an array of file specifications. - * - * Loops through all nested files and returns a normalized array of - * UploadedFileInterface instances. + * Get the IP the client is using, or says they are using. * - * @param array $files The file data to normalize & convert. - * @return array An array of UploadedFileInterface objects. + * @return string The client IP. */ - protected function _normalizeNestedFiles(array $files = []) + public function clientIp(): string { - $normalizedFiles = []; - foreach (array_keys($files['tmp_name']) as $key) { - $spec = [ - 'tmp_name' => $files['tmp_name'][$key], - 'size' => $files['size'][$key], - 'error' => $files['error'][$key], - 'name' => $files['name'][$key], - 'type' => $files['type'][$key], - ]; - $normalizedFiles[$key] = $this->_createUploadedFile($spec); - } + if ($this->trustProxy && $this->getEnv('HTTP_X_FORWARDED_FOR')) { + $addresses = array_map('trim', explode(',', (string)$this->getEnv('HTTP_X_FORWARDED_FOR'))); + $trusted = $this->trustedProxies !== []; + $n = count($addresses); - return $normalizedFiles; - } + if ($trusted) { + $trusted = array_diff($addresses, $this->trustedProxies); + $trusted = (count($trusted) === 1); + } - /** - * Get the content type used in this request. - * - * @return string - */ - public function contentType() - { - $type = $this->getEnv('CONTENT_TYPE'); - if ($type) { - return $type; + if ($trusted) { + return $addresses[0]; + } + + return $addresses[$n - 1]; } - return $this->getEnv('HTTP_CONTENT_TYPE'); - } + if ($this->trustProxy && $this->getEnv('HTTP_X_REAL_IP')) { + $ipaddr = $this->getEnv('HTTP_X_REAL_IP'); + } elseif ($this->trustProxy && $this->getEnv('HTTP_CLIENT_IP')) { + $ipaddr = $this->getEnv('HTTP_CLIENT_IP'); + } else { + $ipaddr = $this->getEnv('REMOTE_ADDR'); + } - /** - * Returns the instance of the Session object for this request - * - * @return \Cake\Network\Session - */ - public function getSession() - { - return $this->session; + return trim((string)$ipaddr); } /** - * Returns the instance of the Session object for this request + * register trusted proxies * - * If a session object is passed as first argument it will be set as - * the session to use for this request - * - * @deprecated 3.5.0 Use getSession() instead. The setter part will be removed. - * @param \Cake\Network\Session|null $session the session object to use - * @return \Cake\Network\Session + * @param array $proxies ips list of trusted proxies + * @return void */ - public function session(Session $session = null) + public function setTrustedProxies(array $proxies): void { - if ($session === null) { - return $this->session; - } - - return $this->session = $session; + $this->trustedProxies = $proxies; + $this->trustProxy = true; + $this->uri = $this->uri->withScheme($this->scheme()); } /** - * Get the IP the client is using, or says they are using. + * Get trusted proxies * - * @return string The client IP. + * @return array */ - public function clientIp() + public function getTrustedProxies(): array { - if ($this->trustProxy && $this->getEnv('HTTP_X_FORWARDED_FOR')) { - $addresses = explode(',', $this->getEnv('HTTP_X_FORWARDED_FOR')); - $ipaddr = end($addresses); - } elseif ($this->trustProxy && $this->getEnv('HTTP_CLIENT_IP')) { - $ipaddr = $this->getEnv('HTTP_CLIENT_IP'); - } else { - $ipaddr = $this->getEnv('REMOTE_ADDR'); - } - - return trim($ipaddr); + return $this->trustedProxies; } /** * Returns the referer that referred this request. * - * @param bool $local Attempt to return a local address. - * Local addresses do not contain hostnames. - * @return string The referring address for this request. + * @param bool $local When true, return the referer as a host-stripped local path, + * or null if the referer is not on the same origin as this application. + * When false, return the raw referer URL as-is. In both cases null is returned + * when no referer is available. + * @return string|null The referring address for this request or null. */ - public function referer($local = false) + public function referer(bool $local = true): ?string { $ref = $this->getEnv('HTTP_REFERER'); $base = Configure::read('App.fullBaseUrl') . $this->webroot; - if (!empty($ref) && !empty($base)) { - if ($local && strpos($ref, $base) === 0) { - $ref = substr($ref, strlen($base)); - if (!strlen($ref) || strpos($ref, '//') === 0) { - $ref = '/'; - } - if ($ref[0] !== '/') { - $ref = '/' . $ref; - } + if (!$ref || !$base) { + return null; + } - return $ref; + if ($local && str_starts_with($ref, $base)) { + $ref = substr($ref, strlen($base)); + if ($ref === '' || str_starts_with($ref, '//')) { + $ref = '/'; } - if (!$local) { - return $ref; + if (!str_starts_with($ref, '/')) { + return '/' . $ref; } + + return $ref; + } + + if ($local) { + return null; } - return '/'; + return $ref; } /** @@ -598,86 +469,55 @@ public function referer($local = false) * * @param string $name The method called * @param array $params Array of parameters for the method call - * @return mixed + * @return bool * @throws \BadMethodCallException when an invalid method is called. */ - public function __call($name, $params) + public function __call(string $name, array $params): bool { - if (strpos($name, 'is') === 0) { + if (str_starts_with($name, 'is')) { $type = strtolower(substr($name, 2)); array_unshift($params, $type); return $this->is(...$params); } - throw new BadMethodCallException(sprintf('Method %s does not exist', $name)); - } - - /** - * Magic get method allows access to parsed routing parameters directly on the object. - * - * Allows access to `$this->params['controller']` via `$this->controller` - * - * @param string $name The property being accessed. - * @return mixed Either the value of the parameter or null. - * @deprecated 3.4.0 Accessing routing parameters through __get will removed in 4.0.0. - * Use getParam() instead. - */ - public function __get($name) - { - if (isset($this->params[$name])) { - return $this->params[$name]; - } - - return null; - } - - /** - * Magic isset method allows isset/empty checks - * on routing parameters. - * - * @param string $name The property being accessed. - * @return bool Existence - * @deprecated 3.4.0 Accessing routing parameters through __isset will removed in 4.0.0. - * Use getParam() instead. - */ - public function __isset($name) - { - return isset($this->params[$name]); + throw new BadMethodCallException(sprintf('Method `%s()` does not exist.', $name)); } /** - * Check whether or not a Request is a certain type. + * Check whether a Request is a certain type. * - * Uses the built in detection rules as well as additional rules - * defined with Cake\Http\ServerRequest::addDetector(). Any detector can be called + * Uses the built-in detection rules as well as additional rules + * defined with {@link \Cake\Http\ServerRequest::addDetector()}. Any detector can be called * as `is($type)` or `is$Type()`. * - * @param string|array $type The type of request you want to check. If an array + * @param array|string $type The type of request you want to check. If an array * this method will return true if the request matches any type. - * @param array ...$args List of arguments - * @return bool Whether or not the request is the type you are checking. + * @param mixed ...$args List of arguments + * @return bool Whether the request is the type you are checking. + * @throws \InvalidArgumentException If no detector has been set for the provided type. */ - public function is($type, ...$args) + public function is(array|string $type, mixed ...$args): bool { if (is_array($type)) { - $result = array_map([$this, 'is'], $type); + foreach ($type as $_type) { + if ($this->is($_type)) { + return true; + } + } - return count(array_filter($result)) > 0; + return false; } $type = strtolower($type); if (!isset(static::$_detectors[$type])) { - return false; + throw new InvalidArgumentException(sprintf('No detector set for type `%s`.', $type)); } if ($args) { return $this->_is($type, $args); } - if (!isset($this->_detectorCache[$type])) { - $this->_detectorCache[$type] = $this->_is($type, $args); - } - return $this->_detectorCache[$type]; + return $this->_detectorCache[$type] ??= $this->_is($type, $args); } /** @@ -685,7 +525,7 @@ public function is($type, ...$args) * * @return void */ - public function clearDetectorCache() + public function clearDetectorCache(): void { $this->_detectorCache = []; } @@ -693,15 +533,14 @@ public function clearDetectorCache() /** * Worker for the public is() function * - * @param string|array $type The type of request you want to check. If an array - * this method will return true if the request matches any type. + * @param string $type The type of request you want to check. * @param array $args Array of custom detector arguments. - * @return bool Whether or not the request is the type you are checking. + * @return bool Whether the request is the type you are checking. */ - protected function _is($type, $args) + protected function _is(string $type, array $args): bool { $detect = static::$_detectors[$type]; - if (is_callable($detect)) { + if ($detect instanceof Closure) { array_unshift($args, $this); return $detect(...$args); @@ -726,36 +565,48 @@ protected function _is($type, $args) * Detects if a specific accept header is present. * * @param array $detect Detector options array. - * @return bool Whether or not the request is the type you are checking. + * @return bool Whether the request is the type you are checking. */ - protected function _acceptHeaderDetector($detect) + protected function _acceptHeaderDetector(array $detect): bool { - $acceptHeaders = explode(',', $this->getEnv('HTTP_ACCEPT')); - foreach ($detect['accept'] as $header) { - if (in_array($header, $acceptHeaders)) { - return true; - } + $content = new ContentTypeNegotiation(); + $options = $detect['accept']; + + // Some detectors overlap with the default browser Accept header + // For these types we use an exclude list to refine our content type + // detection. + $exclude = $detect['exclude'] ?? null; + if ($exclude) { + $options = array_merge($options, $exclude); } - return false; + $accepted = $content->preferredType($this, $options); + if ($accepted === null) { + return false; + } + if ($exclude && in_array($accepted, $exclude, true)) { + return false; + } + + return true; } /** * Detects if a specific header is present. * * @param array $detect Detector options array. - * @return bool Whether or not the request is the type you are checking. + * @return bool Whether the request is the type you are checking. */ - protected function _headerDetector($detect) + protected function _headerDetector(array $detect): bool { foreach ($detect['header'] as $header => $value) { $header = $this->getEnv('http_' . $header); if ($header !== null) { - if (!is_string($value) && !is_bool($value) && is_callable($value)) { - return call_user_func($value, $header); + if ($value instanceof Closure) { + return $value($header); } - return ($header === $value); + return $header === $value; } } @@ -766,18 +617,18 @@ protected function _headerDetector($detect) * Detects if a specific request parameter is present. * * @param array $detect Detector options array. - * @return bool Whether or not the request is the type you are checking. + * @return bool Whether the request is the type you are checking. */ - protected function _paramDetector($detect) + protected function _paramDetector(array $detect): bool { $key = $detect['param']; if (isset($detect['value'])) { $value = $detect['value']; - return isset($this->params[$key]) ? $this->params[$key] == $value : false; + return isset($this->params[$key]) && $this->params[$key] === $value; } if (isset($detect['options'])) { - return isset($this->params[$key]) ? in_array($this->params[$key], $detect['options']) : false; + return isset($this->params[$key]) && in_array($this->params[$key], $detect['options']); } return false; @@ -787,21 +638,21 @@ protected function _paramDetector($detect) * Detects if a specific environment variable is present. * * @param array $detect Detector options array. - * @return bool Whether or not the request is the type you are checking. + * @return bool Whether the request is the type you are checking. */ - protected function _environmentDetector($detect) + protected function _environmentDetector(array $detect): bool { if (isset($detect['env'])) { if (isset($detect['value'])) { - return $this->getEnv($detect['env']) == $detect['value']; + return $this->getEnv($detect['env']) === $detect['value']; } if (isset($detect['pattern'])) { - return (bool)preg_match($detect['pattern'], $this->getEnv($detect['env'])); + return (bool)preg_match($detect['pattern'], (string)$this->getEnv($detect['env'])); } if (isset($detect['options'])) { $pattern = '/' . implode('|', $detect['options']) . '/i'; - return (bool)preg_match($pattern, $this->getEnv($detect['env'])); + return (bool)preg_match($pattern, (string)$this->getEnv($detect['env'])); } } @@ -815,29 +666,32 @@ protected function _environmentDetector($detect) * See Request::is() for how to add additional types and the * built-in types. * - * @param array $types The types to check. + * @param array $types The types to check. * @return bool Success. * @see \Cake\Http\ServerRequest::is() */ - public function isAll(array $types) + public function isAll(array $types): bool { - $result = array_filter(array_map([$this, 'is'], $types)); + foreach ($types as $type) { + if (!$this->is($type)) { + return false; + } + } - return count($result) === count($types); + return true; } /** * Add a new detector to the list of detectors that a request can use. - * There are several different formats and types of detectors that can be set. + * There are several different types of detectors that can be set. * - * ### Callback detectors + * ### Callback comparison * - * Callback detectors allow you to provide a callable to handle the check. - * The callback will receive the request object as its only parameter. + * Callback detectors allow you to provide a closure to handle the check. + * The closure will receive the request object as its only parameter. * * ``` * addDetector('custom', function ($request) { //Return a boolean }); - * addDetector('custom', ['SomeClass', 'somemethod']); * ``` * * ### Environment value comparison @@ -845,7 +699,36 @@ public function isAll(array $types) * An environment value comparison, compares a value fetched from `env()` to a known value * the environment value is equality checked against the provided value. * - * e.g `addDetector('post', ['env' => 'REQUEST_METHOD', 'value' => 'POST'])` + * ``` + * addDetector('post', ['env' => 'REQUEST_METHOD', 'value' => 'POST']); + * ``` + * + * ### Request parameter comparison + * + * Allows for custom detectors on the request parameters. + * + * ``` + * addDetector('admin', ['param' => 'prefix', 'value' => 'admin']); + * ``` + * + * ### Accept comparison + * + * Allows for detector to compare against Accept header value. + * + * ``` + * addDetector('csv', ['accept' => 'text/csv']); + * ``` + * + * ### Header comparison + * + * Allows for one or more headers to be compared. + * + * ``` + * addDetector('fancy', ['header' => ['X-Fancy' => 1]]); + * ``` + * + * The `param`, `env` and comparison types allow the following + * value comparison options: * * ### Pattern value comparison * @@ -864,86 +747,31 @@ public function isAll(array $types) * addDetector('mobile', ['env' => 'HTTP_USER_AGENT', 'options' => ['Fennec']]); * ``` * - * ### Request parameter detectors - * - * Allows for custom detectors on the request parameters. - * - * e.g `addDetector('requested', ['param' => 'requested', 'value' => 1]` - * - * You can also make parameter detectors that accept multiple values + * You can also make compare against multiple values * using the `options` key. This is useful when you want to check - * if a request parameter is in a list of options. + * if a request value is in a list of options. * - * `addDetector('extension', ['param' => 'ext', 'options' => ['pdf', 'csv']]` + * `addDetector('extension', ['param' => '_ext', 'options' => ['pdf', 'csv']]` * * @param string $name The name of the detector. - * @param callable|array $callable A callable or options array for the detector definition. + * @param \Closure|array $detector A Closure or options array for the detector definition. * @return void */ - public static function addDetector($name, $callable) + public static function addDetector(string $name, Closure|array $detector): void { $name = strtolower($name); - if (is_callable($callable)) { - static::$_detectors[$name] = $callable; + if ($detector instanceof Closure) { + static::$_detectors[$name] = $detector; return; } - if (isset(static::$_detectors[$name], $callable['options'])) { - $callable = Hash::merge(static::$_detectors[$name], $callable); - } - static::$_detectors[$name] = $callable; - } - - /** - * Add parameters to the request's parsed parameter set. This will overwrite any existing parameters. - * This modifies the parameters available through `$request->getParam()`. - * - * @param array $params Array of parameters to merge in - * @return $this The current object, you can chain this method. - */ - public function addParams(array $params) - { - $this->params = array_merge($this->params, $params); - - return $this; - } - - /** - * Add paths to the requests' paths vars. This will overwrite any existing paths. - * Provides an easy way to modify, here, webroot and base. - * - * @param array $paths Array of paths to merge in - * @return $this The current object, you can chain this method. - */ - public function addPaths(array $paths) - { - foreach (['webroot', 'here', 'base'] as $element) { - if (isset($paths[$element])) { - $this->{$element} = $paths[$element]; - } - } - return $this; - } - - /** - * Get the value of the current requests URL. Will include the query string arguments. - * - * @param bool $base Include the base path, set to false to trim the base path off. - * @return string The current request URL including query string args. - * @deprecated 3.4.0 This method will be removed in 4.0.0. You should use getRequestTarget() instead. - */ - public function here($base = true) - { - $url = $this->here; - if (!empty($this->query)) { - $url .= '?' . http_build_query($this->query, null, '&'); + if (isset(static::$_detectors[$name], $detector['options'])) { + /** @var array $data */ + $data = static::$_detectors[$name]; + $detector = Hash::merge($data, $detector); } - if (!$base) { - $url = preg_replace('/^' . preg_quote($this->base, '/') . '/', '', $url, 1); - } - - return $url; + static::$_detectors[$name] = $detector; } /** @@ -952,34 +780,16 @@ public function here($base = true) * @param string $name The header name. * @return string The normalized header name. */ - protected function normalizeHeaderName($name) + protected function normalizeHeaderName(string $name): string { $name = str_replace('-', '_', strtoupper($name)); - if (!in_array($name, ['CONTENT_LENGTH', 'CONTENT_TYPE'])) { - $name = 'HTTP_' . $name; + if (!in_array($name, ['CONTENT_LENGTH', 'CONTENT_TYPE'], true)) { + return 'HTTP_' . $name; } return $name; } - /** - * Read an HTTP header from the Request information. - * - * If the header is not defined in the request, this method - * will fallback to reading data from $_SERVER and $_ENV. - * This fallback behavior is deprecated, and will be removed in 4.0.0 - * - * @param string $name Name of the header you want. - * @return string|null Either null on no header being set or the value of the header. - * @deprecated 4.0.0 The automatic fallback to env() will be removed in 4.0.0, see getHeader() - */ - public function header($name) - { - $name = $this->normalizeHeaderName($name); - - return $this->getEnv($name); - } - /** * Get all headers in the request. * @@ -989,23 +799,23 @@ public function header($name) * While header names are not case-sensitive, getHeaders() will normalize * the headers. * - * @return array An associative array of headers and their values. - * @link http://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + * @return array> An associative array of headers and their values. + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. */ - public function getHeaders() + public function getHeaders(): array { $headers = []; foreach ($this->_environment as $key => $value) { $name = null; - if (strpos($key, 'HTTP_') === 0) { + if (str_starts_with($key, 'HTTP_')) { $name = substr($key, 5); } - if (strpos($key, 'CONTENT_') === 0) { + if (str_starts_with($key, 'CONTENT_')) { $name = $key; } if ($name !== null) { - $name = strtr(strtolower($name), '_', ' '); - $name = strtr(ucwords($name), ' ', '-'); + $name = str_replace('_', ' ', strtolower($name)); + $name = str_replace(' ', '-', ucwords($name)); $headers[$name] = (array)$value; } } @@ -1017,10 +827,10 @@ public function getHeaders() * Check if a header is set in the request. * * @param string $name The header you want to get (case-insensitive) - * @return bool Whether or not the header is defined. - * @link http://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + * @return bool Whether the header is defined. + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. */ - public function hasHeader($name) + public function hasHeader(string $name): bool { $name = $this->normalizeHeaderName($name); @@ -1031,14 +841,14 @@ public function hasHeader($name) * Get a single header from the request. * * Return the header value as an array. If the header - * is not present an empty array will be returned. + * is not present, an empty array will be returned. * * @param string $name The header you want to get (case-insensitive) - * @return array An associative array of headers and their values. + * @return array An array of all the header values for a particular case-insensitive header by name. * If the header doesn't exist, an empty array will be returned. - * @link http://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. */ - public function getHeader($name) + public function getHeader(string $name): array { $name = $this->normalizeHeaderName($name); if (isset($this->_environment[$name])) { @@ -1053,9 +863,9 @@ public function getHeader($name) * * @param string $name The header you want to get (case-insensitive) * @return string Header values collapsed into a comma separated string. - * @link http://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. */ - public function getHeaderLine($name) + public function getHeaderLine(string $name): string { $value = $this->getHeader($name); @@ -1066,11 +876,12 @@ public function getHeaderLine($name) * Get a modified request with the provided header. * * @param string $name The header name. - * @param string|array $value The header value + * @param array|string $value The header value * @return static - * @link http://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint */ - public function withHeader($name, $value) + public function withHeader(string $name, $value): static { $new = clone $this; $name = $this->normalizeHeaderName($name); @@ -1086,11 +897,12 @@ public function withHeader($name, $value) * will be appended into the existing values. * * @param string $name The header name. - * @param string|array $value The header value + * @param array|string $value The header value * @return static - * @link http://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint */ - public function withAddedHeader($name, $value) + public function withAddedHeader(string $name, $value): static { $new = clone $this; $name = $this->normalizeHeaderName($name); @@ -1109,9 +921,9 @@ public function withAddedHeader($name, $value) * * @param string $name The header name to remove. * @return static - * @link http://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. */ - public function withoutHeader($name) + public function withoutHeader(string $name): static { $new = clone $this; $name = $this->normalizeHeaderName($name); @@ -1120,34 +932,23 @@ public function withoutHeader($name) return $new; } - /** - * Get the HTTP method used for this request. - * - * @return string The name of the HTTP method used. - * @deprecated 3.4.0 This method will be removed in 4.0.0. Use getMethod() instead. - */ - public function method() - { - return $this->getEnv('REQUEST_METHOD'); - } - /** * Get the HTTP method used for this request. * There are a few ways to specify a method. * * - If your client supports it you can use native HTTP methods. - * - You can set the HTTP-X-Method-Override header. + * - You can set the X-Http-Method-Override header. * - You can submit an input with the name `_method` * * Any of these 3 approaches can be used to set the HTTP method used - * by CakePHP internally, and will effect the result of this method. + * by CakePHP internally, and will affect the result of this method. * * @return string The name of the HTTP method used. - * @link http://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. */ - public function getMethod() + public function getMethod(): string { - return $this->getEnv('REQUEST_METHOD'); + return (string)$this->getEnv('REQUEST_METHOD'); } /** @@ -1155,18 +956,16 @@ public function getMethod() * * @param string $method The HTTP method to use. * @return static A new instance with the updated method. - * @link http://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. */ - public function withMethod($method) + public function withMethod(string $method): static { $new = clone $this; - if (!is_string($method) || - !preg_match('/^[!#$%&\'*+.^_`\|~0-9a-z-]+$/i', $method) - ) { + if (!preg_match('/^[!#$%&\'*+.^_`\|~0-9a-z-]+$/i', $method)) { throw new InvalidArgumentException(sprintf( - 'Unsupported HTTP method "%s" provided', - $method + 'Unsupported HTTP method `%s` provided.', + $method, )); } $new->_environment['REQUEST_METHOD'] = $method; @@ -1181,9 +980,9 @@ public function withMethod($method) * used to create this request. * * @return array - * @link http://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. */ - public function getServerParams() + public function getServerParams(): array { return $this->_environment; } @@ -1193,21 +992,47 @@ public function getServerParams() * use the alternative getQuery() method. * * @return array - * @link http://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. */ - public function getQueryParams() + public function getQueryParams(): array { return $this->query; } + /** + * Returns query parameters filtered to include only the specified keys or exclude specified keys. + * + * If the `$only` parameter is provided, only those keys will be returned. + * If the `$exclude` parameter is provided, all keys except those will be returned. + * Both parameters cannot be provided at the same time. + * + * @param array $only List of query parameter keys to include. Defaults to an empty array. + * @param array $exclude List of query parameter keys to exclude. Defaults to an empty array. + * @return array Filtered query parameters. + * @throws \InvalidArgumentException When both `$only` and `$exclude` are provided. + */ + public function getFilteredQueryParams(array $only = [], array $exclude = []): array + { + if ($only !== [] && $exclude !== []) { + throw new InvalidArgumentException('Specify either `$only` or `$exclude`, not both.'); + } + $params = $this->getQueryParams(); + + if ($only !== []) { + return array_intersect_key($params, array_flip($only)); + } + + return array_diff_key($params, array_flip($exclude)); + } + /** * Update the query string data and get a new instance. * * @param array $query The query string data to use * @return static A new instance with the updated query string data. - * @link http://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. + * @link https://www.php-fig.org/psr/psr-7/ This method is part of the PSR-7 server request interface. */ - public function withQueryParams(array $query) + public function withQueryParams(array $query): static { $new = clone $this; $new->query = $query; @@ -1218,9 +1043,9 @@ public function withQueryParams(array $query) /** * Get the host that the request was handled on. * - * @return string + * @return string|null */ - public function host() + public function host(): ?string { if ($this->trustProxy && $this->getEnv('HTTP_X_FORWARDED_HOST')) { return $this->getEnv('HTTP_X_FORWARDED_HOST'); @@ -1232,9 +1057,9 @@ public function host() /** * Get the port the request was handled on. * - * @return string + * @return string|null */ - public function port() + public function port(): ?string { if ($this->trustProxy && $this->getEnv('HTTP_X_FORWARDED_PORT')) { return $this->getEnv('HTTP_X_FORWARDED_PORT'); @@ -1250,10 +1075,10 @@ public function port() * * @return string The scheme used for the request. */ - public function scheme() + public function scheme(): string { if ($this->trustProxy && $this->getEnv('HTTP_X_FORWARDED_PROTO')) { - return $this->getEnv('HTTP_X_FORWARDED_PROTO'); + return (string)$this->getEnv('HTTP_X_FORWARDED_PROTO'); } return $this->getEnv('HTTPS') ? 'https' : 'http'; @@ -1266,9 +1091,14 @@ public function scheme() * While `example.co.uk` contains 2. * @return string Domain name without subdomains. */ - public function domain($tldLength = 1) + public function domain(int $tldLength = 1): string { - $segments = explode('.', $this->host()); + $host = $this->host(); + if (!$host) { + return ''; + } + + $segments = explode('.', $host); $domain = array_slice($segments, -1 * ($tldLength + 1)); return implode('.', $domain); @@ -1279,11 +1109,16 @@ public function domain($tldLength = 1) * * @param int $tldLength Number of segments your tld contains. For example: `example.com` contains 1 tld. * While `example.co.uk` contains 2. - * @return array An array of subdomains. + * @return array An array of subdomains. */ - public function subdomains($tldLength = 1) + public function subdomains(int $tldLength = 1): array { - $segments = explode('.', $this->host()); + $host = $this->host(); + if (!$host) { + return []; + } + + $segments = explode('.', $host); return array_slice($segments, 0, -1 * ($tldLength + 1)); } @@ -1308,35 +1143,22 @@ public function subdomains($tldLength = 1) * by the client. * * @param string|null $type The content type to check for. Leave null to get all types a client accepts. - * @return array|bool Either an array of all the types the client accepts or a boolean if they accept the + * @return array|bool Either an array of all the types the client accepts or a boolean if they accept the * provided type. */ - public function accepts($type = null) + public function accepts(?string $type = null): array|bool { - $raw = $this->parseAccept(); + $content = new ContentTypeNegotiation(); + if ($type) { + return $content->preferredType($this, [$type]) !== null; + } + $accept = []; - foreach ($raw as $types) { + foreach ($content->parseAccept($this) as $types) { $accept = array_merge($accept, $types); } - if ($type === null) { - return $accept; - } - return in_array($type, $accept); - } - - /** - * Parse the HTTP_ACCEPT header and return a sorted array with content types - * as the keys, and pref values as the values. - * - * Generally you want to use Cake\Http\ServerRequest::accept() to get a simple list - * of the accepted content types. - * - * @return array An array of prefValue => [content/types] - */ - public function parseAccept() - { - return $this->_parseAcceptWithQualifier($this->getHeaderLine('Accept')); + return $accept; } /** @@ -1344,111 +1166,43 @@ public function parseAccept() * * Get the list of accepted languages: * - * ``` \Cake\Http\ServerRequest::acceptLanguage(); ``` + * ```$request->acceptLanguage();``` * * Check if a specific language is accepted: * - * ``` \Cake\Http\ServerRequest::acceptLanguage('es-es'); ``` + * ```$request->acceptLanguage('es-es');``` * * @param string|null $language The language to test. - * @return array|bool If a $language is provided, a boolean. Otherwise the array of accepted languages. - */ - public function acceptLanguage($language = null) - { - $raw = $this->_parseAcceptWithQualifier($this->getHeaderLine('Accept-Language')); - $accept = []; - foreach ($raw as $languages) { - foreach ($languages as &$lang) { - if (strpos($lang, '_')) { - $lang = str_replace('_', '-', $lang); - } - $lang = strtolower($lang); - } - $accept = array_merge($accept, $languages); - } - if ($language === null) { - return $accept; - } - - return in_array(strtolower($language), $accept); - } - - /** - * Parse Accept* headers with qualifier options. - * - * Only qualifiers will be extracted, any other accept extensions will be - * discarded as they are not frequently used. - * - * @param string $header Header to parse. - * @return array - */ - protected function _parseAcceptWithQualifier($header) - { - $accept = []; - $header = explode(',', $header); - foreach (array_filter($header) as $value) { - $prefValue = '1.0'; - $value = trim($value); - - $semiPos = strpos($value, ';'); - if ($semiPos !== false) { - $params = explode(';', $value); - $value = trim($params[0]); - foreach ($params as $param) { - $qPos = strpos($param, 'q='); - if ($qPos !== false) { - $prefValue = substr($param, $qPos + 2); - } - } - } - - if (!isset($accept[$prefValue])) { - $accept[$prefValue] = []; - } - if ($prefValue) { - $accept[$prefValue][] = $value; - } - } - krsort($accept); - - return $accept; - } - - /** - * Provides a read accessor for `$this->query`. Allows you - * to use a syntax similar to `CakeSession` for reading URL query data. - * - * @param string|null $name Query string variable name or null to read all. - * @return string|array|null The value being read - * @deprecated 3.4.0 Use getQuery() or the PSR-7 getQueryParams() and withQueryParams() methods instead. + * @return array|bool If a $language is provided, a boolean. Otherwise, the array of accepted languages. */ - public function query($name = null) + public function acceptLanguage(?string $language = null): array|bool { - if ($name === null) { - return $this->query; + $content = new ContentTypeNegotiation(); + if ($language !== null) { + return $content->acceptLanguage($this, $language); } - return $this->getQuery($name); + return $content->acceptedLanguages($this); } /** * Read a specific query value or dotted path. * - * Developers are encouraged to use getQueryParams() when possible as it is PSR-7 compliant, and this method - * is not. + * Developers are encouraged to use getQueryParams() if they need the whole query array, + * as it is PSR-7 compliant, and this method is not. Using Hash::get() you can also get single params. * * ### PSR-7 Alternative * * ``` - * $value = Hash::get($request->getQueryParams(), 'Post.id', null); + * $value = Hash::get($request->getQueryParams(), 'Post.id'); * ``` * * @param string|null $name The name or dotted path to the query param or null to read all. * @param mixed $default The default value if the named parameter is not set, and $name is not null. - * @return null|string|array Query data. + * @return mixed Query data. * @see ServerRequest::getQueryParams() */ - public function getQuery($name = null, $default = null) + public function getQuery(?string $name = null, mixed $default = null): mixed { if ($name === null) { return $this->query; @@ -1457,46 +1211,6 @@ public function getQuery($name = null, $default = null) return Hash::get($this->query, $name, $default); } - /** - * Provides a read/write accessor for `$this->data`. Allows you - * to use a syntax similar to `Cake\Model\Datasource\Session` for reading post data. - * - * ### Reading values. - * - * ``` - * $request->data('Post.title'); - * ``` - * - * When reading values you will get `null` for keys/values that do not exist. - * - * ### Writing values - * - * ``` - * $request->data('Post.title', 'New post!'); - * ``` - * - * You can write to any value, even paths/keys that do not exist, and the arrays - * will be created for you. - * - * @param string|null $name Dot separated name of the value to read/write - * @param mixed ...$args The data to set (deprecated) - * @return mixed|$this Either the value being read, or this so you can chain consecutive writes. - * @deprecated 3.4.0 Use withData() and getData() or getParsedBody() instead. - */ - public function data($name = null, ...$args) - { - if (count($args) === 1) { - $this->data = Hash::insert($this->data, $name, $args[0]); - - return $this; - } - if ($name !== null) { - return Hash::get($this->data, $name); - } - - return $this->data; - } - /** * Provides a safe accessor for request data. Allows * you to use Hash::get() compatible paths. @@ -1516,103 +1230,39 @@ public function data($name = null, ...$args) * * When reading values you will get `null` for keys/values that do not exist. * + * Developers are encouraged to use getParsedBody() if they need the whole data array, + * as it is PSR-7 compliant, and this method is not. Using Hash::get() you can also get single params. + * + * ### PSR-7 Alternative + * + * ``` + * $value = Hash::get($request->getParsedBody(), 'Post.id'); + * ``` + * * @param string|null $name Dot separated name of the value to read. Or null to read all data. * @param mixed $default The default data. - * @return null|string|array The value being read. + * @return mixed The value being read. */ - public function getData($name = null, $default = null) + public function getData(?string $name = null, mixed $default = null): mixed { if ($name === null) { return $this->data; } - if (!is_array($this->data) && $name) { + if (!is_array($this->data)) { return $default; } return Hash::get($this->data, $name, $default); } - /** - * Safely access the values in $this->params. - * - * @param string $name The name of the parameter to get. - * @param mixed ...$args Value to set (deprecated). - * @return mixed|$this The value of the provided parameter. Will - * return false if the parameter doesn't exist or is falsey. - * @deprecated 3.4.0 Use getParam() and withParam() instead. - */ - public function param($name, ...$args) - { - if (count($args) === 1) { - $this->params = Hash::insert($this->params, $name, $args[0]); - - return $this; - } - - return $this->getParam($name); - } - - /** - * Read data from `php://input`. Useful when interacting with XML or JSON - * request body content. - * - * Getting input with a decoding function: - * - * ``` - * $this->request->input('json_decode'); - * ``` - * - * Getting input using a decoding function, and additional params: - * - * ``` - * $this->request->input('Xml::build', ['return' => 'DOMDocument']); - * ``` - * - * Any additional parameters are applied to the callback in the order they are given. - * - * @param string|null $callback A decoding callback that will convert the string data to another - * representation. Leave empty to access the raw input data. You can also - * supply additional parameters for the decoding callback using var args, see above. - * @param array ...$args The additional arguments - * @return string The decoded/processed request data. - */ - public function input($callback = null, ...$args) - { - $this->stream->rewind(); - $input = $this->stream->getContents(); - if ($callback) { - array_unshift($args, $input); - - return call_user_func_array($callback, $args); - } - - return $input; - } - - /** - * Read cookie data from the request's cookie data. - * - * @param string $key The key you want to read. - * @return null|string Either the cookie value, or null if the value doesn't exist. - * @deprecated 3.4.0 Use getCookie() instead. - */ - public function cookie($key) - { - if (isset($this->cookies[$key])) { - return $this->cookies[$key]; - } - - return null; - } - /** * Read cookie data from the request's cookie data. * * @param string $key The key or dotted path you want to read. - * @param string $default The default value if the cookie is not set. - * @return null|array|string Either the cookie value, or null if the value doesn't exist. + * @param array|string|null $default The default value if the cookie is not set. + * @return array|string|null Either the cookie value, or null if the value doesn't exist. */ - public function getCookie($key, $default = null) + public function getCookie(string $key, array|string|null $default = null): array|string|null { return Hash::get($this->cookies, $key, $default); } @@ -1632,7 +1282,7 @@ public function getCookie($key, $default = null) * * @return \Cake\Http\Cookie\CookieCollection */ - public function getCookieCollection() + public function getCookieCollection(): CookieCollection { return CookieCollection::createFromServerRequest($this); } @@ -1644,7 +1294,7 @@ public function getCookieCollection() * @param \Cake\Http\Cookie\CookieCollection $cookies The cookie collection * @return static */ - public function withCookieCollection(CookieCollection $cookies) + public function withCookieCollection(CookieCollection $cookies): static { $new = clone $this; $values = []; @@ -1659,9 +1309,9 @@ public function withCookieCollection(CookieCollection $cookies) /** * Get all the cookie data from the request. * - * @return array An array of cookie data. + * @return array An array of cookie data. */ - public function getCookieParams() + public function getCookieParams(): array { return $this->cookies; } @@ -1672,7 +1322,7 @@ public function getCookieParams() * @param array $cookies The new cookie data to use. * @return static */ - public function withCookieParams(array $cookies) + public function withCookieParams(array $cookies): static { $new = clone $this; $new->cookies = $cookies; @@ -1684,14 +1334,14 @@ public function withCookieParams(array $cookies) * Get the parsed request body data. * * If the request Content-Type is either application/x-www-form-urlencoded - * or multipart/form-data, nd the request method is POST, this will be the + * or multipart/form-data, and the request method is POST, this will be the * post data. For other content types, it may be the deserialized request * body. * - * @return null|array|object The deserialized body parameters, if any. - * These will typically be an array or object. + * @return object|array|null The deserialized body parameters, if any. + * These will typically be an array. */ - public function getParsedBody() + public function getParsedBody(): object|array|null { return $this->data; } @@ -1699,11 +1349,12 @@ public function getParsedBody() /** * Update the parsed body and get a new instance. * - * @param null|array|object $data The deserialized body data. This will + * @param object|array|null $data The deserialized body data. This will * typically be in an array or object. * @return static + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint */ - public function withParsedBody($data) + public function withParsedBody($data): static { $new = clone $this; $new->data = $data; @@ -1716,14 +1367,14 @@ public function withParsedBody($data) * * @return string HTTP protocol version. */ - public function getProtocolVersion() + public function getProtocolVersion(): string { if ($this->protocol) { return $this->protocol; } // Lazily populate this data as it is generally not used. - preg_match('/^HTTP\/([\d.]+)$/', $this->getEnv('SERVER_PROTOCOL'), $match); + preg_match('/^HTTP\/([\d.]+)$/', (string)$this->getEnv('SERVER_PROTOCOL'), $match); $protocol = '1.1'; if (isset($match[1])) { $protocol = $match[1]; @@ -1742,10 +1393,10 @@ public function getProtocolVersion() * @param string $version HTTP protocol version * @return static */ - public function withProtocolVersion($version) + public function withProtocolVersion(string $version): static { if (!preg_match('/^(1\.[01]|2)$/', $version)) { - throw new InvalidArgumentException("Unsupported protocol version '{$version}' provided"); + throw new InvalidArgumentException(sprintf('Unsupported protocol version `%s` provided.', $version)); } $new = clone $this; $new->protocol = $version; @@ -1762,14 +1413,22 @@ public function withProtocolVersion($version) * variable's value that does not exist. * @return string|null Either the environment value, or null if the value doesn't exist. */ - public function getEnv($key, $default = null) + public function getEnv(string $key, ?string $default = null): ?string { $key = strtoupper($key); if (!array_key_exists($key, $this->_environment)) { $this->_environment[$key] = env($key); } - return $this->_environment[$key] !== null ? $this->_environment[$key] : $default; + if ($this->_environment[$key] === null) { + return $default; + } + + if (is_array($this->_environment[$key])) { + return implode(', ', $this->_environment[$key]); + } + + return (string)$this->_environment[$key]; } /** @@ -1782,7 +1441,7 @@ public function getEnv($key, $default = null) * @param string $value Value to set * @return static */ - public function withEnv($key, $value) + public function withEnv(string $key, string $value): static { $new = clone $this; $new->_environment[$key] = $value; @@ -1791,35 +1450,6 @@ public function withEnv($key, $value) return $new; } - /** - * Get/Set value from the request's environment data. - * Fallback to using env() if key not set in $environment property. - * - * @deprecated 3.5.0 Use getEnv()/withEnv() instead. - * @param string $key The key you want to read/write from/to. - * @param string|null $value Value to set. Default null. - * @param string|null $default Default value when trying to retrieve an environment - * variable's value that does not exist. The value parameter must be null. - * @return $this|string|null This instance if used as setter, - * if used as getter either the environment value, or null if the value doesn't exist. - */ - public function env($key, $value = null, $default = null) - { - if ($value !== null) { - $this->_environment[$key] = $value; - $this->clearDetectorCache(); - - return $this; - } - - $key = strtoupper($key); - if (!array_key_exists($key, $this->_environment)) { - $this->_environment[$key] = env($key); - } - - return $this->_environment[$key] !== null ? $this->_environment[$key] : $default; - } - /** * Allow only certain HTTP request methods, if the request method does not match * a 405 error will be shown and the required "Allow" response header will be set. @@ -1833,11 +1463,11 @@ public function env($key, $value = null, $default = null) * If the request would be GET, response header "Allow: POST, DELETE" will be set * and a 405 error will be returned. * - * @param string|array $methods Allowed HTTP request methods. - * @return bool true - * @throws \Cake\Network\Exception\MethodNotAllowedException + * @param array|string $methods Allowed HTTP request methods. + * @return true + * @throws \Cake\Http\Exception\MethodNotAllowedException */ - public function allowMethod($methods) + public function allowMethod(array|string $methods): bool { $methods = (array)$methods; foreach ($methods as $method) { @@ -1847,57 +1477,49 @@ public function allowMethod($methods) } $allowed = strtoupper(implode(', ', $methods)); $e = new MethodNotAllowedException(); - $e->responseHeader('Allow', $allowed); + $e->setHeader('Allow', $allowed); throw $e; } /** - * Read data from php://input, mocked in tests. + * Update the request with a new request data element. + * + * Returns an updated request object. This method returns + * a *new* request object and does not mutate the request in-place. + * + * Use `withParsedBody()` if you need to replace all the request data. * - * @return string contents of php://input + * @param string $name The dot separated path to insert $value at. + * @param mixed $value The value to insert into the request data. + * @return static */ - protected function _readInput() + public function withData(string $name, mixed $value): static { - if (empty($this->_input)) { - $fh = fopen('php://input', 'rb'); - $content = stream_get_contents($fh); - fclose($fh); - $this->_input = $content; - } + $copy = clone $this; - return $this->_input; - } + if (is_array($copy->data)) { + $copy->data = Hash::insert($copy->data, $name, $value); + } - /** - * Modify data originally from `php://input`. Useful for altering json/xml data - * in middleware or DispatcherFilters before it gets to RequestHandlerComponent - * - * @param string $input A string to replace original parsed data from input() - * @return void - * @deprecated 3.4.0 This method will be removed in 4.0.0. Use withBody() instead. - */ - public function setInput($input) - { - $stream = new Stream('php://memory', 'rw'); - $stream->write($input); - $stream->rewind(); - $this->stream = $stream; + return $copy; } /** - * Update the request with a new request data element. + * Update the request removing a data element. * * Returns an updated request object. This method returns * a *new* request object and does not mutate the request in-place. * - * @param string $name The dot separated path to insert $value at. - * @param mixed $value The value to insert into the request data. + * @param string $name The dot separated path to remove. * @return static */ - public function withData($name, $value) + public function withoutData(string $name): static { $copy = clone $this; - $copy->data = Hash::insert($copy->data, $name, $value); + + if (is_array($copy->data)) { + $copy->data = Hash::remove($copy->data, $name); + } return $copy; } @@ -1912,7 +1534,7 @@ public function withData($name, $value) * @param mixed $value The value to insert into the the request parameters. * @return static */ - public function withParam($name, $value) + public function withParam(string $name, mixed $value): static { $copy = clone $this; $copy->params = Hash::insert($copy->params, $name, $value); @@ -1924,11 +1546,18 @@ public function withParam($name, $value) * Safely access the values in $this->params. * * @param string $name The name or dotted path to parameter. - * @param mixed $default The default value if $name is not set. + * @param mixed $default The default value if `$name` is not set. Default `null`. * @return mixed */ - public function getParam($name, $default = false) + public function getParam(string $name, mixed $default = null): mixed { + if ($name === '?') { + deprecationWarning( + '5.3.0', + 'Using `$request->getParam("?")` is deprecated. Use `$request->getQueryParams()` instead.', + ); + } + return Hash::get($this->params, $name, $default); } @@ -1939,7 +1568,7 @@ public function getParam($name, $default = false) * @param mixed $value The value of the attribute. * @return static */ - public function withAttribute($name, $value) + public function withAttribute(string $name, mixed $value): static { $new = clone $this; if (in_array($name, $this->emulatedAttributes, true)) { @@ -1956,14 +1585,14 @@ public function withAttribute($name, $value) * * @param string $name The attribute name. * @return static - * @throws InvalidArgumentException + * @throws \InvalidArgumentException */ - public function withoutAttribute($name) + public function withoutAttribute(string $name): static { $new = clone $this; if (in_array($name, $this->emulatedAttributes, true)) { throw new InvalidArgumentException( - "You cannot unset '$name'. It is a required CakePHP attribute." + "You cannot unset '{$name}'. It is a required CakePHP attribute.", ); } unset($new->attributes[$name]); @@ -1975,12 +1604,16 @@ public function withoutAttribute($name) * Read an attribute from the request, or get the default * * @param string $name The attribute name. - * @param mixed|null $default The default value if the attribute has not been set. + * @param mixed $default The default value if the attribute has not been set. * @return mixed */ - public function getAttribute($name, $default = null) + public function getAttribute(string $name, mixed $default = null): mixed { if (in_array($name, $this->emulatedAttributes, true)) { + if ($name === 'here') { + return $this->base . $this->uri->getPath(); + } + return $this->{$name}; } if (array_key_exists($name, $this->attributes)) { @@ -1993,17 +1626,18 @@ public function getAttribute($name, $default = null) /** * Get all the attributes in the request. * - * This will include the params, webroot, and base attributes that CakePHP + * This will include the params, webroot, base, and here attributes that CakePHP * provides. * - * @return array + * @return array */ - public function getAttributes() + public function getAttributes(): array { $emulated = [ 'params' => $this->params, 'webroot' => $this->webroot, - 'base' => $this->base + 'base' => $this->base, + 'here' => $this->base . $this->uri->getPath(), ]; return $this->attributes + $emulated; @@ -2013,9 +1647,9 @@ public function getAttributes() * Get the uploaded file from a dotted path. * * @param string $path The dot separated path to the file you want. - * @return null|\Psr\Http\Message\UploadedFileInterface + * @return \Psr\Http\Message\UploadedFileInterface|null */ - public function getUploadedFile($path) + public function getUploadedFile(string $path): ?UploadedFileInterface { $file = Hash::get($this->uploadedFiles, $path); if (!$file instanceof UploadedFile) { @@ -2030,7 +1664,7 @@ public function getUploadedFile($path) * * @return array */ - public function getUploadedFiles() + public function getUploadedFiles(): array { return $this->uploadedFiles; } @@ -2038,15 +1672,15 @@ public function getUploadedFiles() /** * Update the request replacing the files, and creating a new instance. * - * @param array $files An array of uploaded file objects. + * @param array $uploadedFiles An array of uploaded file objects. * @return static - * @throws InvalidArgumentException when $files contains an invalid object. + * @throws \InvalidArgumentException when $files contains an invalid object. */ - public function withUploadedFiles(array $files) + public function withUploadedFiles(array $uploadedFiles): static { - $this->validateUploadedFiles($files, ''); + $this->validateUploadedFiles($uploadedFiles, ''); $new = clone $this; - $new->uploadedFiles = $files; + $new->uploadedFiles = $uploadedFiles; return $new; } @@ -2057,9 +1691,9 @@ public function withUploadedFiles(array $files) * @param array $uploadedFiles The new files array to validate. * @param string $path The path thus far. * @return void - * @throws InvalidArgumentException If any leaf elements are not valid files. + * @throws \InvalidArgumentException If any leaf elements are not valid files. */ - protected function validateUploadedFiles(array $uploadedFiles, $path) + protected function validateUploadedFiles(array $uploadedFiles, string $path): void { foreach ($uploadedFiles as $key => $file) { if (is_array($file)) { @@ -2068,7 +1702,7 @@ protected function validateUploadedFiles(array $uploadedFiles, $path) } if (!$file instanceof UploadedFileInterface) { - throw new InvalidArgumentException("Invalid file at '{$path}{$key}'"); + throw new InvalidArgumentException(sprintf('Invalid file at `%s%s`.', $path, $key)); } } } @@ -2078,7 +1712,7 @@ protected function validateUploadedFiles(array $uploadedFiles, $path) * * @return \Psr\Http\Message\StreamInterface Returns the body as a stream. */ - public function getBody() + public function getBody(): StreamInterface { return $this->stream; } @@ -2089,7 +1723,7 @@ public function getBody() * @param \Psr\Http\Message\StreamInterface $body The new request body * @return static */ - public function withBody(StreamInterface $body) + public function withBody(StreamInterface $body): static { $new = clone $this; $new->stream = $body; @@ -2103,7 +1737,7 @@ public function withBody(StreamInterface $body) * @return \Psr\Http\Message\UriInterface Returns a UriInterface instance * representing the URI of the request. */ - public function getUri() + public function getUri(): UriInterface { return $this->uri; } @@ -2115,10 +1749,10 @@ public function getUri() * and `url` attributes. * * @param \Psr\Http\Message\UriInterface $uri The new request uri - * @param bool $preserveHost Whether or not the host should be retained. + * @param bool $preserveHost Whether the host should be retained. * @return static */ - public function withUri(UriInterface $uri, $preserveHost = false) + public function withUri(UriInterface $uri, bool $preserveHost = false): static { $new = clone $this; $new->uri = $uri; @@ -2131,8 +1765,9 @@ public function withUri(UriInterface $uri, $preserveHost = false) if (!$host) { return $new; } - if ($uri->getPort()) { - $host .= ':' . $uri->getPort(); + $port = $uri->getPort(); + if ($port) { + $host .= ':' . $port; } $new->_environment['HTTP_HOST'] = $host; @@ -2148,13 +1783,13 @@ public function withUri(UriInterface $uri, $preserveHost = false) * * @link https://tools.ietf.org/html/rfc7230#section-2.7 (for the various * request-target forms allowed in request messages) - * @param string $target The request target. + * @param string $requestTarget The request target. * @return static */ - public function withRequestTarget($target) + public function withRequestTarget(string $requestTarget): static { $new = clone $this; - $new->requestTarget = $target; + $new->requestTarget = $requestTarget; return $new; } @@ -2169,7 +1804,7 @@ public function withRequestTarget($target) * * @return string */ - public function getRequestTarget() + public function getRequestTarget(): string { if ($this->requestTarget !== null) { return $this->requestTarget; @@ -2180,76 +1815,27 @@ public function getRequestTarget() $target .= '?' . $this->uri->getQuery(); } - if (empty($target)) { - $target = '/'; + if (!$target) { + return '/'; } return $target; } /** - * Array access read implementation - * - * @param string $name Name of the key being accessed. - * @return mixed - * @deprecated 3.4.0 The ArrayAccess methods will be removed in 4.0.0. Use getParam(), getData() and getQuery() instead. - */ - public function offsetGet($name) - { - if (isset($this->params[$name])) { - return $this->params[$name]; - } - if ($name === 'url') { - return $this->query; - } - if ($name === 'data') { - return $this->data; - } - - return null; - } - - /** - * Array access write implementation + * Get the path of current request. * - * @param string $name Name of the key being written - * @param mixed $value The value being written. - * @return void - * @deprecated 3.4.0 The ArrayAccess methods will be removed in 4.0.0. Use withParam() instead. - */ - public function offsetSet($name, $value) - { - $this->params[$name] = $value; - } - - /** - * Array access isset() implementation - * - * @param string $name thing to check. - * @return bool - * @deprecated 3.4.0 The ArrayAccess methods will be removed in 4.0.0. Use getParam() instead. + * @return string + * @since 3.6.1 */ - public function offsetExists($name) + public function getPath(): string { - if ($name === 'url' || $name === 'data') { - return true; + if ($this->requestTarget === null) { + return $this->uri->getPath(); } - return isset($this->params[$name]); - } + [$path] = explode('?', $this->requestTarget); - /** - * Array access unset() implementation - * - * @param string $name Name to unset. - * @return void - * @deprecated 3.4.0 The ArrayAccess methods will be removed in 4.0.0. Use withParam() instead. - */ - public function offsetUnset($name) - { - unset($this->params[$name]); + return $path; } } - -// @deprecated Add backwards compat alias. -class_alias('Cake\Http\ServerRequest', 'Cake\Network\Request'); diff --git a/src/Http/ServerRequestFactory.php b/src/Http/ServerRequestFactory.php index f8d62857305..47df6fb0341 100644 --- a/src/Http/ServerRequestFactory.php +++ b/src/Http/ServerRequestFactory.php @@ -1,4 +1,6 @@ $uri, 'base' => $base, 'webroot' => $webroot] = UriFactory::marshalUriAndBaseFromSapi($server); + $sessionConfig = (array)Configure::read('Session') + [ 'defaults' => 'php', - 'cookiePath' => $uri->webroot + 'cookiePath' => $webroot, ]; $session = Session::create($sessionConfig); + $request = new ServerRequest([ 'environment' => $server, 'uri' => $uri, - 'files' => $files ?: $_FILES, - 'cookies' => $cookies ?: $_COOKIE, - 'query' => $query ?: $_GET, - 'post' => $body ?: $_POST, - 'webroot' => $uri->webroot, - 'base' => $uri->base, + 'cookies' => $cookies ?? $_COOKIE, + 'query' => $query ?? $_GET, + 'webroot' => $webroot, + 'base' => $base, 'session' => $session, + 'input' => $server['CAKEPHP_INPUT'] ?? null, ]); - return $request; - } - - /** - * Create a new Uri instance from the provided server data. - * - * @param array $server Array of server data to build the Uri from. - * $_SERVER will be added into the $server parameter. - * @return \Psr\Http\Message\UriInterface New instance. - */ - public static function createUri(array $server = []) - { - $server += $_SERVER; - $server = static::normalizeServer($server); - $headers = static::marshalHeaders($server); + $request = static::marshalBodyAndRequestMethod($parsedBody ?? $_POST, $request); + // This is required as `ServerRequest::scheme()` ignores the value of + // `HTTP_X_FORWARDED_PROTO` unless `trustProxy` is enabled, while the + // `Uri` instance initially created always takes values of `HTTP_X_FORWARDED_PROTO` + // into account. + $uri = $request->getUri()->withScheme($request->scheme()); + $request = $request->withUri($uri, true); - return static::marshalUriFromServer($server, $headers); + return static::marshalFiles($files ?? $_FILES, $request); } /** - * Build a UriInterface object. + * Sets the REQUEST_METHOD environment variable based on the simulated _method + * HTTP override value. The 'ORIGINAL_REQUEST_METHOD' is also preserved, if you + * want the read the non-simulated HTTP method the client used. * - * Add in some CakePHP specific logic/properties that help - * perserve backwards compatibility. + * Request body of content type "application/x-www-form-urlencoded" is parsed + * into array for PUT/PATCH/DELETE requests. * - * @param array $server The server parameters. - * @param array $headers The normalized headers - * @return \Psr\Http\Message\UriInterface a constructed Uri + * @param array $parsedBody Parsed body. + * @param \Cake\Http\ServerRequest $request Request instance. + * @return \Cake\Http\ServerRequest */ - public static function marshalUriFromServer(array $server, array $headers) + protected static function marshalBodyAndRequestMethod(array $parsedBody, ServerRequest $request): ServerRequest { - $uri = parent::marshalUriFromServer($server, $headers); - list($base, $webroot) = static::getBase($uri, $server); - - // Look in PATH_INFO first, as this is the exact value we need prepared - // by PHP. - $pathInfo = Hash::get($server, 'PATH_INFO'); - if ($pathInfo) { - $uri = $uri->withPath($pathInfo); - } else { - $uri = static::updatePath($base, $uri); + $method = $request->getMethod(); + $override = false; + + if ( + in_array($method, ['PUT', 'DELETE', 'PATCH'], true) && + str_starts_with((string)$request->contentType(), 'application/x-www-form-urlencoded') + ) { + $data = (string)$request->getBody(); + parse_str($data, $parsedBody); + } + if ($request->hasHeader('X-Http-Method-Override')) { + $parsedBody['_method'] = $request->getHeaderLine('X-Http-Method-Override'); + $override = true; } - if (!$uri->getHost()) { - $uri = $uri->withHost('localhost'); + $request = $request->withEnv('ORIGINAL_REQUEST_METHOD', $method); + if (isset($parsedBody['_method'])) { + $request = $request->withEnv('REQUEST_METHOD', $parsedBody['_method']); + unset($parsedBody['_method']); + $override = true; } - // Splat on some extra attributes to save - // some method calls. - $uri->base = $base; - $uri->webroot = $webroot; + if ( + $override && + !in_array($request->getMethod(), ['PUT', 'POST', 'DELETE', 'PATCH'], true) + ) { + $parsedBody = []; + } - return $uri; + return $request->withParsedBody($parsedBody); } /** - * Updates the request URI to remove the base directory. + * Process uploaded files and move things onto the parsed body. * - * @param string $base The base path to remove. - * @param \Psr\Http\Message\UriInterface $uri The uri to update. - * @return \Psr\Http\Message\UriInterface The modified Uri instance. + * @param array $files Files array for normalization and merging in parsed body. + * @param \Cake\Http\ServerRequest $request Request instance. + * @return \Cake\Http\ServerRequest */ - protected static function updatePath($base, $uri) + protected static function marshalFiles(array $files, ServerRequest $request): ServerRequest { - $path = $uri->getPath(); - if (strlen($base) > 0 && strpos($path, $base) === 0) { - $path = substr($path, strlen($base)); - } - if ($path === '/index.php' && $uri->getQuery()) { - $path = $uri->getQuery(); - } - if (empty($path) || $path === '/' || $path === '//' || $path === '/index.php') { - $path = '/'; - } - $endsWithIndex = '/webroot/index.php'; - $endsWithLength = strlen($endsWithIndex); - if (strlen($path) >= $endsWithLength && - substr($path, -$endsWithLength) === $endsWithIndex - ) { - $path = '/'; + $files = normalizeUploadedFiles($files); + $request = $request->withUploadedFiles($files); + + $parsedBody = $request->getParsedBody(); + if (!is_array($parsedBody)) { + return $request; } - return $uri->withPath($path); + $parsedBody = Hash::merge($parsedBody, $files); + + return $request->withParsedBody($parsedBody); } /** - * Calculate the base directory and webroot directory. + * Create a new server request. * - * @param \Psr\Http\Message\UriInterface $uri The Uri instance. - * @param array $server The SERVER data to use. - * @return array An array containing the [baseDir, webroot] + * Note that server-params are taken precisely as given - no parsing/processing + * of the given values is performed, and, in particular, no attempt is made to + * determine the HTTP method or URI, which must be provided explicitly. + * + * @param string $method The HTTP method associated with the request. + * @param \Psr\Http\Message\UriInterface|string $uri The URI associated with the request. If + * the value is a string, the factory MUST create a UriInterface + * instance based on it. + * @param array $serverParams Array of SAPI parameters with which to seed + * the generated request instance. + * @return \Psr\Http\Message\ServerRequestInterface + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint */ - protected static function getBase($uri, $server) + public function createServerRequest(string $method, $uri, array $serverParams = []): ServerRequestInterface { - $config = (array)Configure::read('App') + [ - 'base' => null, - 'webroot' => null, - 'baseUrl' => null - ]; - $base = $config['base']; - $baseUrl = $config['baseUrl']; - $webroot = $config['webroot']; - - if ($base !== false && $base !== null) { - return [$base, $base . '/']; - } - - if (!$baseUrl) { - $base = dirname(Hash::get($server, 'PHP_SELF')); - // Clean up additional / which cause following code to fail.. - $base = preg_replace('#/+#', '/', $base); - - $indexPos = strpos($base, '/' . $webroot . '/index.php'); - if ($indexPos !== false) { - $base = substr($base, 0, $indexPos) . '/' . $webroot; - } - if ($webroot === basename($base)) { - $base = dirname($base); - } - - if ($base === DIRECTORY_SEPARATOR || $base === '.') { - $base = ''; - } - $base = implode('/', array_map('rawurlencode', explode('/', $base))); - - return [$base, $base . '/']; - } - - $file = '/' . basename($baseUrl); - $base = dirname($baseUrl); - - if ($base === DIRECTORY_SEPARATOR || $base === '.') { - $base = ''; - } - $webrootDir = $base . '/'; - - $docRoot = Hash::get($server, 'DOCUMENT_ROOT'); - $docRootContainsWebroot = strpos($docRoot, $webroot); + $serverParams['REQUEST_METHOD'] = $method; + $options = ['environment' => $serverParams]; - if (!empty($base) || !$docRootContainsWebroot) { - if (strpos($webrootDir, '/' . $webroot . '/') === false) { - $webrootDir .= $webroot . '/'; - } + if (is_string($uri)) { + $uri = (new UriFactory())->createUri($uri); } + $options['uri'] = $uri; - return [$base . $file, $webrootDir]; + return new ServerRequest($options); } } diff --git a/src/Http/Session.php b/src/Http/Session.php new file mode 100644 index 00000000000..6e6ff4c7563 --- /dev/null +++ b/src/Http/Session.php @@ -0,0 +1,721 @@ + [ + 'ini' => [ + 'session.use_trans_sid' => 0, + ], + ], + 'cake' => [ + 'ini' => [ + 'session.use_trans_sid' => 0, + 'session.serialize_handler' => 'php', + 'session.use_cookies' => 1, + 'session.save_path' => (defined('TMP') ? TMP : sys_get_temp_dir() . DIRECTORY_SEPARATOR) + . 'sessions', + 'session.save_handler' => 'files', + ], + ], + 'cache' => [ + 'ini' => [ + 'session.use_trans_sid' => 0, + 'session.use_cookies' => 1, + ], + 'handler' => [ + 'engine' => 'CacheSession', + 'config' => 'default', + ], + ], + 'database' => [ + 'ini' => [ + 'session.use_trans_sid' => 0, + 'session.use_cookies' => 1, + 'session.serialize_handler' => 'php', + ], + 'handler' => [ + 'engine' => 'DatabaseSession', + ], + ], + ]; + + if (!isset($defaults[$name])) { + throw new CakeException(sprintf( + 'Invalid session defaults name `%s`. Valid values are: %s.', + $name, + implode(', ', array_keys($defaults)), + )); + } + + if (empty(ini_get('session.cookie_samesite'))) { + $defaults[$name]['ini']['session.cookie_samesite'] = 'Lax'; + } + + return $defaults[$name]; + } + + /** + * Constructor. + * + * ### Configuration: + * + * - timeout: The time in minutes that a session can be idle and remain valid. + * If set to 0, no server side timeout will be applied. + * - cookiePath: The url path for which session cookie is set. Maps to the + * `session.cookie_path` php.ini config. Defaults to base path of app. + * - ini: A list of php.ini directives to change before the session start. + * - handler: An array containing at least the `engine` key. To be used as the session + * engine for persisting data. The rest of the keys in the array will be passed as + * the configuration array for the engine. You can set the `engine` key to an already + * instantiated session handler object. + * + * @param array $config The Configuration to apply to this session object + */ + public function __construct(array $config = []) + { + $config += [ + 'timeout' => null, + 'cookie' => null, + 'ini' => [], + 'handler' => [], + ]; + + $lifetime = (int)ini_get('session.gc_maxlifetime'); + if ($config['timeout'] !== null) { + $lifetime = (int)$config['timeout'] * 60; + } + $this->configureSessionLifetime($lifetime); + + if ($config['cookie']) { + $config['ini']['session.name'] = $config['cookie']; + } + + if (!isset($config['ini']['session.cookie_path'])) { + $cookiePath = empty($config['cookiePath']) ? '/' : $config['cookiePath']; + $config['ini']['session.cookie_path'] = $cookiePath; + } + + $this->options($config['ini']); + + if (!empty($config['handler'])) { + $class = $config['handler']['engine']; + unset($config['handler']['engine']); + $this->engine($class, $config['handler']); + } + + $this->_isCLI = (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg'); + session_register_shutdown(); + } + + /** + * Sets the session handler instance to use for this session. + * If a string is passed for the first argument, it will be treated as the + * class name and the second argument will be passed as the first argument + * in the constructor. + * + * If an instance of a SessionHandlerInterface is provided as the first argument, + * the handler will be set to it. + * + * If no arguments are passed it will return the currently configured handler instance + * or null if none exists. + * + * @param \SessionHandlerInterface|string|null $class The session handler to use + * @param array $options the options to pass to the SessionHandler constructor + * @return \SessionHandlerInterface|null + * @throws \InvalidArgumentException + */ + public function engine( + SessionHandlerInterface|string|null $class = null, + array $options = [], + ): ?SessionHandlerInterface { + if ($class === null) { + return $this->_engine; + } + if ($class instanceof SessionHandlerInterface) { + return $this->setEngine($class); + } + + /** @var class-string<\SessionHandlerInterface>|null $className */ + $className = App::className($class, 'Http/Session'); + if ($className === null) { + throw new InvalidArgumentException( + sprintf('The class `%s` does not exist and cannot be used as a session engine', $class), + ); + } + + return $this->setEngine(new $className($options)); + } + + /** + * Set the engine property and update the session handler in PHP. + * + * @param \SessionHandlerInterface $handler The handler to set + * @return \SessionHandlerInterface + */ + protected function setEngine(SessionHandlerInterface $handler): SessionHandlerInterface + { + if (!headers_sent() && session_status() !== PHP_SESSION_ACTIVE) { + session_set_save_handler($handler, false); + } + + return $this->_engine = $handler; + } + + /** + * Calls ini_set for each of the keys in `$options` and set them + * to the respective value in the passed array. + * + * ### Example: + * + * ``` + * $session->options(['session.use_cookies' => 1]); + * ``` + * + * @param array $options Ini options to set. + * @return void + * @throws \Cake\Core\Exception\CakeException if any directive could not be set + */ + public function options(array $options): void + { + if (session_status() === PHP_SESSION_ACTIVE || headers_sent()) { + return; + } + + foreach ($options as $setting => $value) { + if (ini_set($setting, (string)$value) === false) { + throw new CakeException( + sprintf('Unable to configure the session, setting %s failed.', $setting), + ); + } + } + } + + /** + * Starts the Session. + * + * @return bool True if session was started + * @throws \Cake\Core\Exception\CakeException if the session was already started + */ + public function start(): bool + { + if ($this->_started) { + return true; + } + + if ($this->_isCLI) { + $_SESSION = []; + $this->id('cli'); + + return $this->_started = true; + } + + if (session_status() === PHP_SESSION_ACTIVE) { + throw new CakeException('Session was already started'); + } + $filename = null; + $line = null; + if (ini_get('session.use_cookies') && headers_sent($filename, $line)) { + $this->headerSentInfo = ['filename' => $filename, 'line' => $line]; + + return false; + } + + if (!session_start()) { + throw new CakeException('Could not start the session'); + } + + $this->_started = true; + + if ($this->_timedOut()) { + $this->destroy(); + + return $this->start(); + } + + return $this->_started; + } + + /** + * Write data and close the session + * + * @return true + */ + public function close(): bool + { + if (!$this->_started) { + return true; + } + + if ($this->_isCLI) { + $this->_started = false; + + return true; + } + + if (!session_write_close()) { + throw new CakeException('Could not close the session'); + } + + $this->_started = false; + + return true; + } + + /** + * Determine if Session has already been started. + * + * @return bool True if session has been started. + */ + public function started(): bool + { + return $this->_started || session_status() === PHP_SESSION_ACTIVE; + } + + /** + * Returns true if given variable name is set in session. + * + * @param string|null $name Variable name to check for + * @return bool True if variable is there + */ + public function check(?string $name = null): bool + { + if ($this->_hasSession() && !$this->started()) { + $this->start(); + } + + if (!isset($_SESSION)) { + return false; + } + + if ($name === null) { + return (bool)$_SESSION; + } + + return Hash::get($_SESSION, $name) !== null; + } + + /** + * Returns given session variable, or all of them, if no parameters given. + * + * @param string|null $name The name of the session variable (or a path as sent to Hash.extract) + * @param mixed $default The return value when the path does not exist + * @return mixed|null The value of the session variable, or default value if a session + * is not available, can't be started, or provided $name is not found in the session. + */ + public function read(?string $name = null, mixed $default = null): mixed + { + if ($this->_hasSession() && !$this->started()) { + $this->start(); + } + + if (!isset($_SESSION)) { + return $default; + } + + if ($name === null) { + return $_SESSION ?: []; + } + + return Hash::get($_SESSION, $name, $default); + } + + /** + * Returns given session variable, or throws Exception if not found. + * + * @param string $name The name of the session variable (or a path as sent to Hash.extract) + * @throws \Cake\Core\Exception\CakeException + * @return mixed|null + */ + public function readOrFail(string $name): mixed + { + if (!$this->check($name)) { + throw new CakeException(sprintf('Expected session key `%s` not found.', $name)); + } + + return $this->read($name); + } + + /** + * Reads and deletes a variable from session. + * + * @param string $name The key to read and remove (or a path as sent to Hash.extract). + * @return mixed|null The value of the session variable, null if session not available, + * session not started, or provided name not found in the session. + */ + public function consume(string $name): mixed + { + if (!$name) { + return null; + } + $value = $this->read($name); + if ($value !== null) { + $this->_overwrite($_SESSION, Hash::remove($_SESSION, $name)); + } + + return $value; + } + + /** + * Writes value to given session variable name. + * + * @param array|string $name Name of variable + * @param mixed $value Value to write + * @return void + */ + public function write(array|string $name, mixed $value = null): void + { + $started = $this->started() || $this->start(); + if (!$started) { + $message = 'Could not start the session'; + if ($this->headerSentInfo !== null) { + $message .= sprintf( + ', headers already sent in file `%s` on line `%s`', + Debugger::trimPath($this->headerSentInfo['filename']), + $this->headerSentInfo['line'], + ); + } + + throw new CakeException($message); + } + + if (!is_array($name)) { + $name = [$name => $value]; + } + + $data = $_SESSION ?? []; + foreach ($name as $key => $val) { + $data = Hash::insert($data, $key, $val); + } + + $this->_overwrite($_SESSION, $data); + } + + /** + * Returns the session ID. + * Calling this method will not auto start the session. You might have to manually + * assert a started session. + * + * Passing an ID into it, you can also replace the session ID if the session + * has not already been started. + * Note that depending on the session handler, not all characters are allowed + * within the session ID. For example, the file session handler only allows + * characters in the range a-z A-Z 0-9 , (comma) and - (minus). + * + * @param string|null $id ID to replace the current session ID. + * @return string Session ID + */ + public function id(?string $id = null): string + { + if ($id !== null && !headers_sent()) { + session_id($id); + } + + return (string)session_id(); + } + + /** + * Removes a variable from session. + * + * @param string $name Session variable to remove + * @return void + */ + public function delete(string $name): void + { + if ($this->check($name)) { + $this->_overwrite($_SESSION, Hash::remove($_SESSION, $name)); + } + } + + /** + * Used to write new data to _SESSION, since PHP doesn't like us setting the _SESSION var itself. + * + * @param array $old Set of old variables => values + * @param array $new New set of variable => value + * @return void + */ + protected function _overwrite(array &$old, array $new): void + { + foreach ($old as $key => $var) { + if (!isset($new[$key])) { + unset($old[$key]); + } + } + + foreach ($new as $key => $var) { + $old[$key] = $var; + } + } + + /** + * Helper method to destroy invalid sessions. + * + * @return void + */ + public function destroy(): void + { + if ($this->_hasSession() && !$this->started()) { + $this->start(); + } + + if (!$this->_isCLI && session_status() === PHP_SESSION_ACTIVE) { + session_destroy(); + } + + $_SESSION = []; + $this->_started = false; + } + + /** + * Clears the session. + * + * Optionally it also clears the session id and renews the session. + * + * @param bool $renew If session should be renewed, as well. Defaults to false. + * @return void + */ + public function clear(bool $renew = false): void + { + $_SESSION = []; + if ($renew) { + $this->renew(); + } + } + + /** + * Returns whether a session exists + * + * @return bool + */ + protected function _hasSession(): bool + { + return !ini_get('session.use_cookies') + || isset($_COOKIE[session_name()]) + || $this->_isCLI + || (ini_get('session.use_trans_sid') && isset($_GET[session_name()])); + } + + /** + * Restarts this session. + * + * @return void + */ + public function renew(): void + { + if (!$this->_hasSession() || $this->_isCLI) { + return; + } + + $this->start(); + $params = session_get_cookie_params(); + unset($params['lifetime']); + $params['expires'] = time() - 42000; + setcookie( + (string)session_name(), + '', + $params, + ); + + if (session_id() !== '') { + session_regenerate_id(true); + } + } + + /** + * Returns true if the session is no longer valid because the last time it was + * accessed was after the configured timeout. + * + * @return bool + */ + protected function _timedOut(): bool + { + $time = $this->read('Config.time'); + $result = false; + + $checkTime = $time !== null && $this->_lifetime > 0; + if ($checkTime && (time() - (int)$time > $this->_lifetime)) { + $result = true; + } + + $this->write('Config.time', time()); + + return $result; + } + + /** + * Set the session timeout period. + * + * If set to `0`, no server side timeout will be applied. + * + * @param int $lifetime in seconds + * @return void + * @throws \Cake\Core\Exception\CakeException + */ + public function setSessionLifetime(int $lifetime): void + { + if ($this->started()) { + throw new CakeException("Can't modify session lifetime after session has already been started."); + } + + $this->configureSessionLifetime($lifetime); + } + + /** + * Configure session lifetime + * + * @param int $lifetime + * @return void + */ + protected function configureSessionLifetime(int $lifetime): void + { + if ($lifetime !== 0) { + $this->options([ + 'session.gc_maxlifetime' => $lifetime, + ]); + } + + $this->_lifetime = $lifetime; + } +} diff --git a/src/Http/Session/CacheSession.php b/src/Http/Session/CacheSession.php new file mode 100644 index 00000000000..045ad8e501c --- /dev/null +++ b/src/Http/Session/CacheSession.php @@ -0,0 +1,127 @@ + + */ + protected array $_options = []; + + /** + * Constructor. + * + * @param array $config The configuration to use for this engine + * It requires the key 'config' which is the name of the Cache config to use for + * storing the session + * @throws \InvalidArgumentException if the 'config' key is not provided + */ + public function __construct(array $config = []) + { + if (empty($config['config'])) { + throw new InvalidArgumentException('The cache configuration name to use is required'); + } + $this->_options = $config; + } + + /** + * Method called on open of a database session. + * + * @param string $path The path where to store/retrieve the session. + * @param string $name The session name. + * @return bool Success + */ + public function open(string $path, string $name): bool + { + return true; + } + + /** + * Method called on close of a database session. + * + * @return bool Success + */ + public function close(): bool + { + return true; + } + + /** + * Method used to read from a cache session. + * + * @param string $id ID that uniquely identifies session in cache. + * @return string|false Session data or false if it does not exist. + */ + public function read(string $id): string|false + { + return Cache::read($id, $this->_options['config']) ?? ''; + } + + /** + * Helper function called on write for cache sessions. + * + * @param string $id ID that uniquely identifies session in cache. + * @param string $data The data to be saved. + * @return bool True for successful write, false otherwise. + */ + public function write(string $id, string $data): bool + { + if (!$id) { + return false; + } + + return Cache::write($id, $data, $this->_options['config']); + } + + /** + * Method called on the destruction of a cache session. + * + * @param string $id ID that uniquely identifies session in cache. + * @return bool Always true. + */ + public function destroy(string $id): bool + { + Cache::delete($id, $this->_options['config']); + + return true; + } + + /** + * No-op method. Always returns 0 since cache engine don't have garbage collection. + * + * @param int $max_lifetime Sessions that have not updated for the last maxlifetime seconds will be removed. + * @return int|false + */ + public function gc(int $max_lifetime): int|false + { + return 0; + } +} diff --git a/src/Http/Session/DatabaseSession.php b/src/Http/Session/DatabaseSession.php new file mode 100644 index 00000000000..d0b51ad31ef --- /dev/null +++ b/src/Http/Session/DatabaseSession.php @@ -0,0 +1,190 @@ + $config The configuration for this engine. It requires the 'model' + * key to be present corresponding to the Table to use for managing the sessions. + */ + public function __construct(array $config = []) + { + if (isset($config['tableLocator'])) { + $this->setTableLocator($config['tableLocator']); + } + $tableLocator = $this->getTableLocator(); + + if (empty($config['model'])) { + $config = $tableLocator->exists('Sessions') ? [] : ['table' => 'sessions', 'allowFallbackClass' => true]; + $this->_table = $tableLocator->get('Sessions', $config); + } else { + $this->_table = $tableLocator->get($config['model']); + } + + $this->_timeout = (int)ini_get('session.gc_maxlifetime'); + } + + /** + * Set the timeout value for sessions. + * + * Primarily used in testing. + * + * @param int $timeout The timeout duration. + * @return $this + */ + public function setTimeout(int $timeout) + { + $this->_timeout = $timeout; + + return $this; + } + + /** + * Method called on open of a database session. + * + * @param string $path The path where to store/retrieve the session. + * @param string $name The session name. + * @return bool Success + */ + public function open(string $path, string $name): bool + { + return true; + } + + /** + * Method called on close of a database session. + * + * @return bool Success + */ + public function close(): bool + { + return true; + } + + /** + * Method used to read from a database session. + * + * @param string $id ID that uniquely identifies session in database. + * @return string|false Session data or false if it does not exist. + */ + public function read(string $id): string|false + { + $pkField = $this->_table->getPrimaryKey(); + assert(is_string($pkField)); + $result = $this->_table + ->find('all') + ->select(['data']) + ->where([$pkField => $id]) + ->disableHydration() + ->first(); + + if (!$result) { + return ''; + } + + if (is_string($result['data'])) { + return $result['data']; + } + + $session = stream_get_contents($result['data']); + + if ($session === false) { + return ''; + } + + return $session; + } + + /** + * Helper function called on write for database sessions. + * + * @param string $id ID that uniquely identifies session in database. + * @param string $data The data to be saved. + * @return bool True for successful write, false otherwise. + */ + public function write(string $id, string $data): bool + { + if (!$id) { + return false; + } + + /** @var string $pkField */ + $pkField = $this->_table->getPrimaryKey(); + $session = $this->_table->newEntity([ + $pkField => $id, + 'data' => $data, + 'expires' => time() + $this->_timeout, + ], ['accessibleFields' => [$pkField => true]]); + + return (bool)$this->_table->save($session); + } + + /** + * Method called on the destruction of a database session. + * + * @param string $id ID that uniquely identifies session in database. + * @return bool True for successful delete, false otherwise. + */ + public function destroy(string $id): bool + { + /** @var string $pkField */ + $pkField = $this->_table->getPrimaryKey(); + $this->_table->deleteAll([$pkField => $id]); + + return true; + } + + /** + * Helper function called on gc for database sessions. + * + * @param int $max_lifetime Sessions that have not updated for the last maxlifetime seconds will be removed. + * @return int|false The number of deleted sessions on success, or false on failure. + */ + public function gc(int $max_lifetime): int|false + { + return $this->_table->deleteAll(['expires <' => time()]); + } +} diff --git a/src/Http/StreamFactory.php b/src/Http/StreamFactory.php new file mode 100644 index 00000000000..54fac023e30 --- /dev/null +++ b/src/Http/StreamFactory.php @@ -0,0 +1,79 @@ +createStreamFromResource($resource); + } + + /** + * Create a stream from an existing file. + * + * The file MUST be opened using the given mode, which may be any mode + * supported by the `fopen` function. + * + * The `$filename` MAY be any string supported by `fopen()`. + * + * @param string $filename The filename or stream URI to use as basis of stream. + * @param string $mode The mode with which to open the underlying filename/stream. + * @throws \RuntimeException If the file cannot be opened. + * @throws \InvalidArgumentException If the mode is invalid. + */ + public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface + { + if (!is_readable($filename)) { + throw new RuntimeException(sprintf('Cannot read file `%s`', $filename)); + } + + return new Stream($filename, $mode); + } + + /** + * Create a new stream from an existing resource. + * + * The stream MUST be readable and may be writable. + * + * @param resource $resource The PHP resource to use as the basis for the stream. + */ + public function createStreamFromResource($resource): StreamInterface + { + return new Stream($resource); + } +} diff --git a/src/Http/TestSuite/HttpClientTrait.php b/src/Http/TestSuite/HttpClientTrait.php new file mode 100644 index 00000000000..7c2d101ed6e --- /dev/null +++ b/src/Http/TestSuite/HttpClientTrait.php @@ -0,0 +1,125 @@ + $headers A list of headers for the response. Example `Content-Type: application/json` + * @param string $body The body for the response. + * @return \Cake\Http\Client\Response + */ + public function newClientResponse(int $code = 200, array $headers = [], string $body = ''): Response + { + $headers = array_merge(["HTTP/1.1 {$code}"], $headers); + + return new Response($headers, $body); + } + + /** + * Add a mock response for a POST request. + * + * @param string $url The URL to mock + * @param \Cake\Http\Client\Response $response The response for the mock. + * @param array $options Additional options. See Client::addMockResponse() + * @return void + */ + public function mockClientPost(string $url, Response $response, array $options = []): void + { + Client::addMockResponse('POST', $url, $response, $options); + } + + /** + * Add a mock response for a GET request. + * + * @param string $url The URL to mock + * @param \Cake\Http\Client\Response $response The response for the mock. + * @param array $options Additional options. See Client::addMockResponse() + * @return void + */ + public function mockClientGet(string $url, Response $response, array $options = []): void + { + Client::addMockResponse('GET', $url, $response, $options); + } + + /** + * Add a mock response for a PATCH request. + * + * @param string $url The URL to mock + * @param \Cake\Http\Client\Response $response The response for the mock. + * @param array $options Additional options. See Client::addMockResponse() + * @return void + */ + public function mockClientPatch(string $url, Response $response, array $options = []): void + { + Client::addMockResponse('PATCH', $url, $response, $options); + } + + /** + * Add a mock response for a PUT request. + * + * @param string $url The URL to mock + * @param \Cake\Http\Client\Response $response The response for the mock. + * @param array $options Additional options. See Client::addMockResponse() + * @return void + */ + public function mockClientPut(string $url, Response $response, array $options = []): void + { + Client::addMockResponse('PUT', $url, $response, $options); + } + + /** + * Add a mock response for a DELETE request. + * + * @param string $url The URL to mock + * @param \Cake\Http\Client\Response $response The response for the mock. + * @param array $options Additional options. See Client::addMockResponse() + * @return void + */ + public function mockClientDelete(string $url, Response $response, array $options = []): void + { + Client::addMockResponse('DELETE', $url, $response, $options); + } +} + +// phpcs:disable +class_alias( + 'Cake\Http\TestSuite\HttpClientTrait', + 'Cake\TestSuite\HttpClientTrait' +); +// phpcs:enable diff --git a/src/Http/UploadedFileFactory.php b/src/Http/UploadedFileFactory.php new file mode 100644 index 00000000000..907e4bdbada --- /dev/null +++ b/src/Http/UploadedFileFactory.php @@ -0,0 +1,56 @@ +getSize() ?? 0; + + return new UploadedFile($stream, $size, $error, $clientFilename, $clientMediaType); + } +} diff --git a/src/Http/UriFactory.php b/src/Http/UriFactory.php new file mode 100644 index 00000000000..05037457549 --- /dev/null +++ b/src/Http/UriFactory.php @@ -0,0 +1,180 @@ + $base, 'webroot' => $webroot] = static::getBase($uri, $server); + + $uri = static::updatePath($base, $uri); + + if (!$uri->getHost()) { + $uri = $uri->withHost('localhost'); + } + + return ['uri' => $uri, 'base' => $base, 'webroot' => $webroot]; + } + + /** + * Updates the request URI to remove the base directory. + * + * @param string $base The base path to remove. + * @param \Psr\Http\Message\UriInterface $uri The uri to update. + * @return \Psr\Http\Message\UriInterface + */ + protected static function updatePath(string $base, UriInterface $uri): UriInterface + { + $path = $uri->getPath(); + if ($base !== '' && str_starts_with($path, $base)) { + $path = substr($path, strlen($base)); + } + + // App.baseUrl is meant to be set only when URL rewriting is not used. + if (!Configure::read('App.baseUrl')) { + if ($path === '' || $path === '//') { + $path = '/'; + } + + return $uri->withPath($path); + } + + if ($path === '/index.php' && $uri->getQuery()) { + $path = $uri->getQuery(); + } + if (in_array($path, ['', '//', '/index.php'], true)) { + $path = '/'; + } + + // Check for $webroot/index.php at the start and end of the path. + $search = ''; + if (str_starts_with($path, '/')) { + $search .= '/'; + } + $search .= (Configure::read('App.webroot') ?: 'webroot') . '/index.php'; + if (str_starts_with($path, $search)) { + $path = substr($path, strlen($search)); + } elseif (str_ends_with($path, $search)) { + $path = '/'; + } + if (!$path) { + $path = '/'; + } + + return $uri->withPath($path); + } + + /** + * Calculate the base directory and webroot directory. + * + * @param \Psr\Http\Message\UriInterface $uri The Uri instance. + * @param array $server The SERVER data to use. + * @return array{base: string, webroot: string} An array containing the base and webroot paths. + */ + protected static function getBase(UriInterface $uri, array $server): array + { + $config = (array)Configure::read('App') + [ + 'base' => null, + 'webroot' => null, + 'baseUrl' => null, + ]; + $base = $config['base']; + $baseUrl = $config['baseUrl']; + $webroot = (string)$config['webroot']; + + if ($base !== false && $base !== null) { + return ['base' => $base, 'webroot' => $base . '/']; + } + + if (!$baseUrl) { + $phpSelf = $server['PHP_SELF'] ?? null; + if ($phpSelf === null) { + return ['base' => '', 'webroot' => '/']; + } + + $base = dirname($server['PHP_SELF'] ?? DIRECTORY_SEPARATOR); + // Clean up additional / which cause following code to fail.. + $base = (string)preg_replace('#/+#', '/', $base); + + $indexPos = strpos($base, '/index.php'); + if ($indexPos !== false) { + $base = substr($base, 0, $indexPos); + } + if ($webroot === basename($base)) { + $base = dirname($base); + } + + if ($base === DIRECTORY_SEPARATOR || $base === '.') { + $base = ''; + } + $base = implode('/', array_map('rawurlencode', explode('/', $base))); + + return ['base' => $base, 'webroot' => $base . '/']; + } + + $file = '/' . basename($baseUrl); + $base = dirname($baseUrl); + + if ($base === DIRECTORY_SEPARATOR || $base === '.') { + $base = ''; + } + $webrootDir = $base . '/'; + + $docRoot = $server['DOCUMENT_ROOT'] ?? ''; + if ( + ($base || !str_contains($docRoot, $webroot)) + && !str_contains($webrootDir, '/' . $webroot . '/') + ) { + $webrootDir .= $webroot . '/'; + } + + return ['base' => $base . $file, 'webroot' => $webrootDir]; + } +} diff --git a/src/Http/composer.json b/src/Http/composer.json new file mode 100644 index 00000000000..22302fb1371 --- /dev/null +++ b/src/Http/composer.json @@ -0,0 +1,74 @@ +{ + "name": "cakephp/http", + "description": "CakePHP HTTP client and PSR-7, PSR-13, PSR-15, PSR-17, PSR-18 compliant libraries", + "type": "library", + "keywords": [ + "cakephp", + "http", + "PSR-7", + "PSR-13", + "PSR-15", + "PSR-17", + "PSR-18" + ], + "homepage": "https://cakephp.org", + "license": "MIT", + "authors": [ + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/http/graphs/contributors" + } + ], + "support": { + "issues": "https://github.com/cakephp/cakephp/issues", + "forum": "https://stackoverflow.com/tags/cakephp", + "irc": "irc://irc.freenode.org/cakephp", + "source": "https://github.com/cakephp/http" + }, + "require": { + "php": ">=8.2", + "cakephp/core": "^5.4.0", + "cakephp/event": "^5.4.0", + "cakephp/utility": "^5.4.0", + "composer/ca-bundle": "^1.5", + "psr/http-client": "^1.0.2", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0.2", + "psr/http-server-middleware": "^1.0.2", + "psr/link": "^2.0", + "laminas/laminas-diactoros": "^3.8", + "laminas/laminas-httphandlerrunner": "^2.6" + }, + "require-dev": { + "cakephp/cache": "^5.4.0", + "cakephp/console": "^5.4.0", + "cakephp/orm": "^5.4.0", + "cakephp/i18n": "^5.4.0", + "paragonie/csp-builder": "^3.0" + }, + "autoload": { + "psr-4": { + "Cake\\Http\\": "." + } + }, + "provide": { + "psr/http-client-implementation": "^1.0", + "psr/http-factory-implementation": "^1.1", + "psr/http-server-handler-implementation": "^1.0", + "psr/http-server-middleware-implementation": "^1.0", + "psr/link-implementation": "^2.0" + }, + "suggest": { + "cakephp/cache": "To use cache session storage", + "cakephp/orm": "To use database session storage", + "paragonie/csp-builder": "To use CspMiddleware" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" + } + } +} diff --git a/src/Http/phpstan.neon.dist b/src/Http/phpstan.neon.dist new file mode 100644 index 00000000000..6ec0d9a03c0 --- /dev/null +++ b/src/Http/phpstan.neon.dist @@ -0,0 +1,20 @@ +parameters: + level: 8 + treatPhpDocTypesAsCertain: false + bootstrapFiles: + - tests/phpstan-bootstrap.php + paths: + - ./ + excludePaths: + - BaseApplication.php + - Runner.php + - Session.php + - vendor/ + ignoreErrors: + - + identifier: trait.unused + - + identifier: missingType.iterableValue + - '#Unsafe usage of new static\(\).#' + - "#^Constructor of class Cake\\\\Http\\\\Client\\\\Auth\\\\Digest has an unused parameter \\$options\\.$#" + - '#Call to static method getRequest\(\) on an unknown class Cake\\Routing\\Router.#' diff --git a/src/Http/tests/phpstan-bootstrap.php b/src/Http/tests/phpstan-bootstrap.php new file mode 100644 index 00000000000..0e60e7fbe4e --- /dev/null +++ b/src/Http/tests/phpstan-bootstrap.php @@ -0,0 +1,60 @@ + 'App', + 'encoding' => 'UTF-8', +]); + +ini_set('intl.default_locale', 'en_US'); +ini_set('session.gc_divisor', '1'); +ini_set('assert.exception', '1'); diff --git a/src/I18n/ChainMessagesLoader.php b/src/I18n/ChainMessagesLoader.php index 9b613013d02..9720c9c78bf 100644 --- a/src/I18n/ChainMessagesLoader.php +++ b/src/I18n/ChainMessagesLoader.php @@ -1,4 +1,6 @@ */ - protected $_loaders = []; + protected array $_loaders = []; /** * Receives a list of callable functions or objects that will be executed * one after another until one of them returns a non-empty translations package * - * @param callable[] $loaders List of callables to execute + * @param array $loaders List of callables to execute */ public function __construct(array $loaders) { @@ -46,16 +46,16 @@ public function __construct(array $loaders) * Executes this object returning the translations package as configured in * the chain. * - * @return \Aura\Intl\Package - * @throws \RuntimeException if any of the loaders in the chain is not a valid callable + * @return \Cake\I18n\Package + * @throws \Cake\Core\Exception\CakeException if any of the loaders in the chain is not a valid callable */ - public function __invoke() + public function __invoke(): Package { foreach ($this->_loaders as $k => $loader) { if (!is_callable($loader)) { - throw new RuntimeException(sprintf( - 'Loader "%s" in the chain is not a valid callable', - $k + throw new CakeException(sprintf( + 'Loader `%s` in the chain is not a valid callable.', + $k, )); } @@ -65,9 +65,9 @@ public function __invoke() } if (!($package instanceof Package)) { - throw new RuntimeException(sprintf( - 'Loader "%s" in the chain did not return a valid Package object', - $k + throw new CakeException(sprintf( + 'Loader `%s` in the chain did not return a valid Package object.', + $k, )); } diff --git a/src/I18n/Date.php b/src/I18n/Date.php index a02172481b7..671408e3d56 100644 --- a/src/I18n/Date.php +++ b/src/I18n/Date.php @@ -1,4 +1,6 @@ * @see \Cake\I18n\Date::timeAgoInWords() */ - public static $wordAccuracy = [ + public static array $wordAccuracy = [ 'year' => 'day', 'month' => 'day', 'week' => 'day', @@ -92,7 +104,165 @@ class Date extends MutableDate implements JsonSerializable * @var string * @see \Cake\I18n\Date::timeAgoInWords() */ - public static $wordEnd = '+1 month'; + public static string $wordEnd = '+1 month'; + + /** + * Sets the default format used when type converting instances of this type to string + * + * The format should be either the formatting constants from IntlDateFormatter as + * described in (https://secure.php.net/manual/en/class.intldateformatter.php) or a pattern + * as specified in (https://unicode-org.github.io/icu-docs/apidoc/released/icu4c/classSimpleDateFormat.html#details) + * + * @param string|int $format Format. + * @return void + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint + */ + public static function setToStringFormat($format): void + { + static::$_toStringFormat = $format; + } + + /** + * Sets the default format used when converting this object to JSON + * + * The format should be either the formatting constants from IntlDateFormatter as + * described in (https://secure.php.net/manual/en/class.intldateformatter.php) or a pattern + * as specified in (http://www.icu-project.org/apiref/icu4c/classSimpleDateFormat.html#details) + * + * Alternatively, the format can provide a callback. In this case, the callback + * can receive this object and return a formatted string. + * + * @see \Cake\I18n\Date::i18nFormat() + * @param \Closure|string|int $format Format. + * @return void + */ + public static function setJsonEncodeFormat(Closure|string|int $format): void + { + static::$_jsonEncodeFormat = $format; + } + + /** + * Returns a new Date object after parsing the provided $date string based on + * the passed or configured format. This method is locale dependent, + * Any string that is passed to this function will be interpreted as a locale + * dependent string. + * + * When no $format is provided, the `wordFormat` format will be used. + * + * If it was impossible to parse the provided time, null will be returned. + * + * Example: + * + * ``` + * $time = Date::parseDate('10/13/2013'); + * $time = Date::parseDate('13 Oct, 2013', 'dd MMM, y'); + * $time = Date::parseDate('13 Oct, 2013', IntlDateFormatter::SHORT); + * ``` + * + * @param string $date The date string to parse. + * @param string|int|null $format Any format accepted by IntlDateFormatter. + * @return static|null + */ + public static function parseDate(string $date, string|int|null $format = null): ?static + { + $format ??= static::$wordFormat; + if (is_int($format)) { + $format = [$format, IntlDateFormatter::NONE]; + } + + return static::_parseDateTime($date, $format); + } + + /** + * Get the difference formatter instance. + * + * @param \Cake\Chronos\DifferenceFormatterInterface|null $formatter Difference formatter + * @return \Cake\I18n\RelativeTimeFormatter + */ + public static function diffFormatter(?DifferenceFormatterInterface $formatter = null): RelativeTimeFormatter + { + if ($formatter) { + if (!$formatter instanceof RelativeTimeFormatter) { + throw new InvalidArgumentException('Formatter for I18n must extend RelativeTimeFormatter.'); + } + + return static::$diffFormatter = $formatter; + } + + /** @var \Cake\I18n\RelativeTimeFormatter $formatter */ + $formatter = static::$diffFormatter ??= new RelativeTimeFormatter(); + + return $formatter; + } + + /** + * Returns a formatted string for this time object using the preferred format and + * language for the specified locale. + * + * It is possible to specify the desired format for the string to be displayed. + * You can either pass `IntlDateFormatter` constants as the first argument of this + * function, or pass a full ICU date formatting string as specified in the following + * resource: https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax. + * + * ### Examples + * + * ``` + * $date = new Date('2014-04-20'); + * $date->i18nFormat(); // outputs '4/20/14' for the en-US locale + * $date->i18nFormat(\IntlDateFormatter::FULL); // Use the full date format + * $date->i18nFormat('yyyy-MM-dd'); // outputs '2014-04-20' + * ``` + * + * You can control the default format used through `Date::setToStringFormat()`. + * + * You can read about the available IntlDateFormatter constants at + * https://secure.php.net/manual/en/class.intldateformatter.php + * + * Should you need to use a different locale for displaying this time object, + * pass a locale string as the third parameter to this function. + * + * ### Examples + * + * ``` + * $date = new Date('2014-04-20'); + * $time->i18nFormat(null, 'de-DE'); + * $time->i18nFormat(\IntlDateFormatter::FULL, 'de-DE'); + * ``` + * + * You can control the default locale used through `Date::setDefaultLocale()`. + * If empty, the default will be taken from the `intl.default_locale` ini config. + * + * @param string|int|null $format Format string. + * @param string|null $locale The locale name in which the date should be displayed (e.g. pt-BR) + * @return string|int Formatted and translated date string + */ + public function i18nFormat( + string|int|null $format = null, + ?string $locale = null, + ): string|int { + if ($format === DateTime::UNIX_TIMESTAMP_FORMAT) { + throw new InvalidArgumentException('UNIT_TIMESTAMP_FORMAT is not supported for Date.'); + } + + $format ??= static::$_toStringFormat; + $format = is_int($format) ? [$format, IntlDateFormatter::NONE] : $format; + $locale = $locale ?: DateTime::getDefaultLocale(); + + return $this->_formatObject($this->native, $format, $locale); + } + + /** + * Returns a nicely formatted date string for this object. + * + * The format to be used is stored in the static property `Date::$niceFormat`. + * + * @param string|null $locale The locale name in which the date should be displayed (e.g. pt-BR) + * @return string Formatted date string + */ + public function nice(?string $locale = null): string + { + return (string)$this->i18nFormat(static::$niceFormat, $locale); + } /** * Returns either a relative or a formatted absolute date depending @@ -101,12 +271,12 @@ class Date extends MutableDate implements JsonSerializable * ### Options: * * - `from` => another Date object representing the "now" date - * - `format` => a fall back format if the relative time is longer than the duration specified by end + * - `format` => a fallback format if the relative time is longer than the duration specified by end * - `accuracy` => Specifies how accurate the date should be described (array) - * - year => The format if years > 0 (default "day") - * - month => The format if months > 0 (default "day") - * - week => The format if weeks > 0 (default "day") - * - day => The format if weeks > 0 (default "day") + * - year => The format if years > 0 (default "day") + * - month => The format if months > 0 (default "day") + * - week => The format if weeks > 0 (default "day") + * - day => The format if weeks > 0 (default "day") * - `end` => The end of relative date telling * - `relativeString` => The printf compatible string when outputting relative date * - `absoluteString` => The printf compatible string when outputting absolute date @@ -125,11 +295,47 @@ class Date extends MutableDate implements JsonSerializable * * NOTE: If the difference is one week or more, the lowest level of accuracy is day. * - * @param array $options Array of options. + * @param array $options Array of options. * @return string Relative time string. */ - public function timeAgoInWords(array $options = []) + public function timeAgoInWords(array $options = []): string { return static::diffFormatter()->dateAgoInWords($this, $options); } + + /** + * Returns a string that should be serialized when converting this object to JSON + * + * @return string|int + */ + public function jsonSerialize(): mixed + { + if (static::$_jsonEncodeFormat instanceof Closure) { + return call_user_func(static::$_jsonEncodeFormat, $this); + } + + return $this->i18nFormat(static::$_jsonEncodeFormat); + } + + /** + * Returns a UNIX timestamp as an integer. + * + * @return int UNIX timestamp + */ + public function getTimestamp(): int + { + return (int)$this->toUnixString(); + } + + /** + * @inheritDoc + */ + public function __toString(): string + { + return (string)$this->i18nFormat(); + } } + +// phpcs:disable +class_alias('Cake\I18n\Date', 'Cake\I18n\FrozenDate'); +// phpcs:enable diff --git a/src/I18n/DateFormatTrait.php b/src/I18n/DateFormatTrait.php index f0b043be8a8..1d05f532a03 100644 --- a/src/I18n/DateFormatTrait.php +++ b/src/I18n/DateFormatTrait.php @@ -1,4 +1,6 @@ */ - public function nice($timezone = null, $locale = null) - { - return $this->i18nFormat(static::$niceFormat, $timezone, $locale); - } - - /** - * Returns a formatted string for this time object using the preferred format and - * language for the specified locale. - * - * It is possible to specify the desired format for the string to be displayed. - * You can either pass `IntlDateFormatter` constants as the first argument of this - * function, or pass a full ICU date formatting string as specified in the following - * resource: http://www.icu-project.org/apiref/icu4c/classSimpleDateFormat.html#details. - * - * Additional to `IntlDateFormatter` constants and date formatting string you can use - * Time::UNIX_TIMESTAMP_FORMAT to get a unix timestamp - * - * ### Examples - * - * ``` - * $time = new Time('2014-04-20 22:10'); - * $time->i18nFormat(); // outputs '4/20/14, 10:10 PM' for the en-US locale - * $time->i18nFormat(\IntlDateFormatter::FULL); // Use the full date and time format - * $time->i18nFormat([\IntlDateFormatter::FULL, \IntlDateFormatter::SHORT]); // Use full date but short time format - * $time->i18nFormat('yyyy-MM-dd HH:mm:ss'); // outputs '2014-04-20 22:10' - * $time->i18nFormat(Time::UNIX_TIMESTAMP_FORMAT); // outputs '1398031800' - * ``` - * - * If you wish to control the default format to be used for this method, you can alter - * the value of the static `Time::$defaultLocale` variable and set it to one of the - * possible formats accepted by this function. - * - * You can read about the available IntlDateFormatter constants at - * https://secure.php.net/manual/en/class.intldateformatter.php - * - * If you need to display the date in a different timezone than the one being used for - * this Time object without altering its internal state, you can pass a timezone - * string or object as the second parameter. - * - * Finally, should you need to use a different locale for displaying this time object, - * pass a locale string as the third parameter to this function. - * - * ### Examples - * - * ``` - * $time = new Time('2014-04-20 22:10'); - * $time->i18nFormat(null, null, 'de-DE'); - * $time->i18nFormat(\IntlDateFormatter::FULL, 'Europe/Berlin', 'de-DE'); - * ``` - * - * You can control the default locale to be used by setting the static variable - * `Time::$defaultLocale` to a valid locale string. If empty, the default will be - * taken from the `intl.default_locale` ini config. - * - * @param string|int|null $format Format string. - * @param string|\DateTimeZone|null $timezone Timezone string or DateTimeZone object - * in which the date will be displayed. The timezone stored for this object will not - * be changed. - * @param string|null $locale The locale name in which the date should be displayed (e.g. pt-BR) - * @return string Formatted and translated date string - */ - public function i18nFormat($format = null, $timezone = null, $locale = null) - { - if ($format === Time::UNIX_TIMESTAMP_FORMAT) { - return $this->getTimestamp(); - } - - $time = $this; - - if ($timezone) { - // Handle the immutable and mutable object cases. - $time = clone $this; - $time = $time->timezone($timezone); - } - - $format = $format !== null ? $format : static::$_toStringFormat; - $locale = $locale ?: static::$defaultLocale; - - return $this->_formatObject($time, $format, $locale); - } + protected static array $formatters = []; /** * Returns a translated and localized date string. * Implements what IntlDateFormatter::formatObject() is in PHP 5.5+ * - * @param \DateTime $date Date. - * @param string|int|array $format Format. - * @param string $locale The locale name in which the date should be displayed. + * @param \DateTimeInterface $date Date. + * @param array|string $format Format. + * @param string|null $locale The locale name in which the date should be displayed. * @return string */ - protected function _formatObject($date, $format, $locale) - { - $pattern = $dateFormat = $timeFormat = $calendar = null; + protected function _formatObject( + DateTimeInterface $date, + array|string $format, + ?string $locale, + ): string { + $pattern = ''; if (is_array($format)) { - list($dateFormat, $timeFormat) = $format; - } elseif (is_numeric($format)) { - $dateFormat = $format; + [$dateFormat, $timeFormat] = $format; } else { - $dateFormat = $timeFormat = IntlDateFormatter::FULL; + $dateFormat = IntlDateFormatter::FULL; + $timeFormat = IntlDateFormatter::FULL; $pattern = $format; } - if (preg_match('/@calendar=(japanese|buddhist|chinese|persian|indian|islamic|hebrew|coptic|ethiopic)/', $locale)) { + $locale ??= I18n::getLocale(); + + if ( + preg_match( + '/@calendar=(japanese|buddhist|chinese|persian|indian|islamic|hebrew|coptic|ethiopic)/', + $locale, + ) + ) { $calendar = IntlDateFormatter::TRADITIONAL; } else { $calendar = IntlDateFormatter::GREGORIAN; @@ -208,64 +76,33 @@ protected function _formatObject($date, $format, $locale) $timezone = $date->getTimezone()->getName(); $key = "{$locale}.{$dateFormat}.{$timeFormat}.{$timezone}.{$calendar}.{$pattern}"; - if (!isset(static::$_formatters[$key])) { + if (!isset(static::$formatters[$key])) { if ($timezone === '+00:00' || $timezone === 'Z') { $timezone = 'UTC'; - } elseif ($timezone[0] === '+' || $timezone[0] === '-') { + } elseif (str_starts_with($timezone, '+') || str_starts_with($timezone, '-')) { $timezone = 'GMT' . $timezone; } - static::$_formatters[$key] = datefmt_create( + + $formatter = datefmt_create( $locale, $dateFormat, $timeFormat, $timezone, $calendar, - $pattern + $pattern, ); - } - return static::$_formatters[$key]->format($date->format('U')); - } - - /** - * {@inheritDoc} - */ - public function __toString() - { - return $this->i18nFormat(); - } - - /** - * Resets the format used to the default when converting an instance of this type to - * a string - * - * @return void - */ - public static function resetToStringFormat() - { - static::setToStringFormat([IntlDateFormatter::SHORT, IntlDateFormatter::SHORT]); - } + if (!$formatter) { + throw new CakeException( + 'Your version of icu does not support creating a date formatter for ' . + "`{$key}`. You should try to upgrade libicu and the intl extension.", + ); + } - /** - * Sets the default format used when type converting instances of this type to string - * - * @param string|array|int $format Format. - * @return void - */ - public static function setToStringFormat($format) - { - static::$_toStringFormat = $format; - } + static::$formatters[$key] = $formatter; + } - /** - * Sets the default format used when converting this object to json - * - * @param string|array|int $format Format. - * @return void - */ - public static function setJsonEncodeFormat($format) - { - static::$_jsonEncodeFormat = $format; + return (string)static::$formatters[$key]->format($date); } /** @@ -274,7 +111,9 @@ public static function setJsonEncodeFormat($format) * Any string that is passed to this function will be interpreted as a locale * dependent string. * - * When no $format is provided, the `toString` format will be used. + * Unlike DateTime, the time zone of the returned instance is always converted + * to `$tz` (default time zone if null) even if the `$time` string specified a + * time zone. This is a limitation of IntlDateFormatter. * * If it was impossible to parse the provided time, null will be returned. * @@ -283,154 +122,55 @@ public static function setJsonEncodeFormat($format) * ``` * $time = Time::parseDateTime('10/13/2013 12:54am'); * $time = Time::parseDateTime('13 Oct, 2013 13:54', 'dd MMM, y H:mm'); - * $time = Time::parseDateTime('10/10/2015', [IntlDateFormatter::SHORT, -1]); + * $time = Time::parseDateTime('10/10/2015', [IntlDateFormatter::SHORT, IntlDateFormatter::NONE]); * ``` * * @param string $time The time string to parse. - * @param string|array|null $format Any format accepted by IntlDateFormatter. + * @param array|string $format Any format accepted by IntlDateFormatter. + * @param \DateTimeZone|string|null $tz The timezone for the instance * @return static|null */ - public static function parseDateTime($time, $format = null) - { - $dateFormat = $format ?: static::$_toStringFormat; - $timeFormat = $pattern = null; + protected static function _parseDateTime( + string $time, + array|string $format, + DateTimeZone|string|null $tz = null, + ): ?static { + $pattern = ''; - if (is_array($dateFormat)) { - list($newDateFormat, $timeFormat) = $dateFormat; - $dateFormat = $newDateFormat; + if (is_array($format)) { + [$dateFormat, $timeFormat] = $format; } else { - $pattern = $dateFormat; - $dateFormat = null; - } - - if (static::$_isDateInstance === null) { - static::$_isDateInstance = - is_subclass_of(static::class, ChronosDate::class) || - is_subclass_of(static::class, MutableDate::class); + $dateFormat = IntlDateFormatter::FULL; + $timeFormat = IntlDateFormatter::FULL; + $pattern = $format; } - $defaultTimezone = static::$_isDateInstance ? 'UTC' : date_default_timezone_get(); + $locale = DateTime::getDefaultLocale() ?? I18n::getLocale(); $formatter = datefmt_create( - static::$defaultLocale, + $locale, $dateFormat, $timeFormat, - $defaultTimezone, + $tz, null, - $pattern + $pattern, ); - $time = $formatter->parse($time); - if ($time !== false) { - $result = new static('@' . $time); - - return static::$_isDateInstance ? $result : $result->setTimezone($defaultTimezone); - } - - return null; - } - - /** - * Returns a new Time object after parsing the provided $date string based on - * the passed or configured date time format. This method is locale dependent, - * Any string that is passed to this function will be interpreted as a locale - * dependent string. - * - * When no $format is provided, the `wordFormat` format will be used. - * - * If it was impossible to parse the provided time, null will be returned. - * - * Example: - * - * ``` - * $time = Time::parseDate('10/13/2013'); - * $time = Time::parseDate('13 Oct, 2013', 'dd MMM, y'); - * $time = Time::parseDate('13 Oct, 2013', IntlDateFormatter::SHORT); - * ``` - * - * @param string $date The date string to parse. - * @param string|int|null $format Any format accepted by IntlDateFormatter. - * @return static|null - */ - public static function parseDate($date, $format = null) - { - if (is_int($format)) { - $format = [$format, -1]; + if (!$formatter) { + throw new CakeException('Unable to create IntlDateFormatter instance'); } - $format = $format ?: static::$wordFormat; + $formatter->setLenient(DateTime::lenientParsingEnabled()); - return static::parseDateTime($date, $format); - } - - /** - * Returns a new Time object after parsing the provided $time string based on - * the passed or configured date time format. This method is locale dependent, - * Any string that is passed to this function will be interpreted as a locale - * dependent string. - * - * When no $format is provided, the IntlDateFormatter::SHORT format will be used. - * - * If it was impossible to parse the provided time, null will be returned. - * - * Example: - * - * ``` - * $time = Time::parseTime('11:23pm'); - * ``` - * - * @param string $time The time string to parse. - * @param string|int|null $format Any format accepted by IntlDateFormatter. - * @return static|null - */ - public static function parseTime($time, $format = null) - { - if (is_int($format)) { - $format = [-1, $format]; + $time = $formatter->parse($time); + if ($time === false) { + return null; } - $format = $format ?: [-1, IntlDateFormatter::SHORT]; - return static::parseDateTime($time, $format); - } + $dateTime = new DateTimeImmutable('@' . $time); - /** - * Returns a string that should be serialized when converting this object to json - * - * @return string - */ - public function jsonSerialize() - { - return $this->i18nFormat(static::$_jsonEncodeFormat); - } - - /** - * Get the difference formatter instance or overwrite the current one. - * - * @param \Cake\I18n\RelativeTimeFormatter|null $formatter The formatter instance when setting. - * @return \Cake\I18n\RelativeTimeFormatter The formatter instance. - */ - public static function diffFormatter($formatter = null) - { - if ($formatter === null) { - // Use the static property defined in chronos. - if (static::$diffFormatter === null) { - static::$diffFormatter = new RelativeTimeFormatter(); - } - - return static::$diffFormatter; + if (!($tz instanceof DateTimeZone)) { + $tz = new DateTimeZone($tz ?? date_default_timezone_get()); } + $dateTime = $dateTime->setTimezone($tz); - return static::$diffFormatter = $formatter; - } - - /** - * Returns the data that should be displayed when debugging this object - * - * @return array - */ - public function __debugInfo() - { - return [ - 'time' => $this->toIso8601String(), - 'timezone' => $this->getTimezone()->getName(), - 'fixedNowTime' => static::hasTestNow() ? static::getTestNow()->toIso8601String() : false - ]; + return new static($dateTime); } } diff --git a/src/I18n/DatePeriod.php b/src/I18n/DatePeriod.php new file mode 100644 index 00000000000..aeb3bc783e7 --- /dev/null +++ b/src/I18n/DatePeriod.php @@ -0,0 +1,37 @@ + + */ +class DatePeriod extends ChronosDatePeriod +{ + /** + * @return \Cake\I18n\Date + */ + public function current(): Date + { + return new Date($this->iterator->current()); + } +} diff --git a/src/I18n/DateTime.php b/src/I18n/DateTime.php new file mode 100644 index 00000000000..aee19dfbd51 --- /dev/null +++ b/src/I18n/DateTime.php @@ -0,0 +1,651 @@ +|string|int + * @see \Cake\I18n\DateTime::i18nFormat() + */ + protected static array|string|int $_toStringFormat = [IntlDateFormatter::SHORT, IntlDateFormatter::SHORT]; + + /** + * The format to use when converting this object to JSON. + * + * The format should be either the formatting constants from IntlDateFormatter as + * described in (https://secure.php.net/manual/en/class.intldateformatter.php) or a pattern + * as specified in (https://unicode-org.github.io/icu-docs/apidoc/released/icu4c/classSimpleDateFormat.html#details) + * + * It is possible to provide an array of 2 constants. In this case, the first position + * will be used for formatting the date part of the object and the second position + * will be used to format the time part. + * + * @var \Closure|array|string|int + * @see \Cake\I18n\DateTime::i18nFormat() + */ + protected static Closure|array|string|int $_jsonEncodeFormat = "yyyy-MM-dd'T'HH':'mm':'ssxxx"; + + /** + * The format to use when formatting a time using `Cake\I18n\DateTime::nice()` + * + * The format should be either the formatting constants from IntlDateFormatter as + * described in (https://secure.php.net/manual/en/class.intldateformatter.php) or a pattern + * as specified in (https://unicode-org.github.io/icu-docs/apidoc/released/icu4c/classSimpleDateFormat.html#details) + * + * It is possible to provide an array of 2 constants. In this case, the first position + * will be used for formatting the date part of the object and the second position + * will be used to format the time part. + * + * @var array|string|int + * @see \Cake\I18n\DateTime::nice() + */ + public static array|string|int $niceFormat = [IntlDateFormatter::MEDIUM, IntlDateFormatter::SHORT]; + + /** + * The format to use when formatting a time using `Cake\I18n\DateTime::timeAgoInWords()` + * and the difference is more than `Cake\I18n\DateTime::$wordEnd` + * + * @var array|string|int + * @see \Cake\I18n\DateTime::timeAgoInWords() + */ + public static array|string|int $wordFormat = [IntlDateFormatter::SHORT, IntlDateFormatter::NONE]; + + /** + * The format to use when formatting a time using `DateTime::timeAgoInWords()` + * and the difference is less than `DateTime::$wordEnd` + * + * @var array + * @see \Cake\I18n\DateTime::timeAgoInWords() + */ + public static array $wordAccuracy = [ + 'year' => 'day', + 'month' => 'day', + 'week' => 'day', + 'day' => 'hour', + 'hour' => 'minute', + 'minute' => 'minute', + 'second' => 'second', + ]; + + /** + * The end of relative time telling + * + * @var string + * @see \Cake\I18n\DateTime::timeAgoInWords() + */ + public static string $wordEnd = '+1 month'; + + /** + * serialise the value as a Unix Timestamp + * + * @var string + */ + public const UNIX_TIMESTAMP_FORMAT = 'unixTimestampFormat'; + + /** + * Gets the default locale. + * + * @return string|null The default locale string to be used or null. + */ + public static function getDefaultLocale(): ?string + { + return static::$defaultLocale; + } + + /** + * Sets the default locale. + * + * Set to null to use IntlDateFormatter default. + * + * @param string|null $locale The default locale string to be used. + * @return void + */ + public static function setDefaultLocale(?string $locale = null): void + { + static::$defaultLocale = $locale; + } + + /** + * Gets whether locale format parsing is set to lenient. + * + * @return bool + */ + public static function lenientParsingEnabled(): bool + { + return static::$lenientParsing; + } + + /** + * Enables lenient parsing for locale formats. + * + * @return void + */ + public static function enableLenientParsing(): void + { + static::$lenientParsing = true; + } + + /** + * Enables lenient parsing for locale formats. + * + * @return void + */ + public static function disableLenientParsing(): void + { + static::$lenientParsing = false; + } + + /** + * Sets the default format used when type converting instances of this type to string + * + * The format should be either the formatting constants from IntlDateFormatter as + * described in (https://secure.php.net/manual/en/class.intldateformatter.php) or a pattern + * as specified in (https://unicode-org.github.io/icu-docs/apidoc/released/icu4c/classSimpleDateFormat.html#details) + * + * It is possible to provide an array of 2 constants. In this case, the first position + * will be used for formatting the date part of the object and the second position + * will be used to format the time part. + * + * @param array|string|int $format Format. + * @return void + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint + */ + public static function setToStringFormat($format): void + { + static::$_toStringFormat = $format; + } + + /** + * Resets the format used to the default when converting an instance of this type to + * a string + * + * @return void + */ + public static function resetToStringFormat(): void + { + static::setToStringFormat([IntlDateFormatter::SHORT, IntlDateFormatter::SHORT]); + } + + /** + * Sets the default format used when converting this object to JSON + * + * The format should be either the formatting constants from IntlDateFormatter as + * described in (https://secure.php.net/manual/en/class.intldateformatter.php) or a pattern + * as specified in (http://www.icu-project.org/apiref/icu4c/classSimpleDateFormat.html#details) + * + * It is possible to provide an array of 2 constants. In this case, the first position + * will be used for formatting the date part of the object and the second position + * will be used to format the time part. + * + * Alternatively, the format can provide a callback. In this case, the callback + * can receive this datetime object and return a formatted string. + * + * @see \Cake\I18n\DateTime::i18nFormat() + * @param \Closure|array|string|int $format Format. + * @return void + */ + public static function setJsonEncodeFormat(Closure|array|string|int $format): void + { + static::$_jsonEncodeFormat = $format; + } + + /** + * Returns a new Time object after parsing the provided time string based on + * the passed or configured date time format. This method is locale dependent, + * Any string that is passed to this function will be interpreted as a locale + * dependent string. + * + * When no $format is provided, the `toString` format will be used. + * + * Unlike DateTime, the time zone of the returned instance is always converted + * to `$tz` (default time zone if null) even if the `$time` string specified a + * time zone. This is a limitation of IntlDateFormatter. + * + * If it was impossible to parse the provided time, null will be returned. + * + * Example: + * + * ``` + * $time = DateTime::parseDateTime('10/13/2013 12:54am'); + * $time = DateTime::parseDateTime('13 Oct, 2013 13:54', 'dd MMM, y H:mm'); + * $time = DateTime::parseDateTime('10/10/2015', [IntlDateFormatter::SHORT, IntlDateFormatter::NONE]); + * ``` + * + * @param string $time The time string to parse. + * @param array|string|int|null $format Any format accepted by IntlDateFormatter. + * @param \DateTimeZone|string|null $tz The timezone for the instance + * @return static|null + */ + public static function parseDateTime( + string $time, + array|string|int|null $format = null, + DateTimeZone|string|null $tz = null, + ): ?static { + $format ??= static::$_toStringFormat; + $format = is_int($format) ? [$format, $format] : $format; + + return static::_parseDateTime($time, $format, $tz); + } + + /** + * Returns a new Time object after parsing the provided $date string based on + * the passed or configured date time format. This method is locale dependent, + * Any string that is passed to this function will be interpreted as a locale + * dependent string. + * + * When no $format is provided, the `wordFormat` format will be used. + * + * If it was impossible to parse the provided time, null will be returned. + * + * Example: + * + * ``` + * $time = DateTime::parseDate('10/13/2013'); + * $time = DateTime::parseDate('13 Oct, 2013', 'dd MMM, y'); + * $time = DateTime::parseDate('13 Oct, 2013', IntlDateFormatter::SHORT); + * ``` + * + * @param string $date The date string to parse. + * @param array|string|int|null $format Any format accepted by IntlDateFormatter. + * @return static|null + */ + public static function parseDate(string $date, array|string|int|null $format = null): ?static + { + $format ??= static::$wordFormat; + if (is_int($format)) { + $format = [$format, IntlDateFormatter::NONE]; + } + + return static::parseDateTime($date, $format); + } + + /** + * Returns a new Time object after parsing the provided $time string based on + * the passed or configured date time format. This method is locale dependent, + * Any string that is passed to this function will be interpreted as a locale + * dependent string. + * + * When no $format is provided, the IntlDateFormatter::SHORT format will be used. + * + * If it was impossible to parse the provided time, null will be returned. + * + * Example: + * + * ``` + * $time = DateTime::parseTime('11:23pm'); + * ``` + * + * @param string $time The time string to parse. + * @param array|string|int|null $format Any format accepted by IntlDateFormatter. + * @return static|null + */ + public static function parseTime(string $time, array|string|int|null $format = null): ?static + { + if (is_int($format)) { + $format = [IntlDateFormatter::NONE, $format]; + } + $format = $format ?: [IntlDateFormatter::NONE, IntlDateFormatter::SHORT]; + + return static::parseDateTime($time, $format); + } + + /** + * Get the difference formatter instance. + * + * @param \Cake\Chronos\DifferenceFormatterInterface|null $formatter Difference formatter + * @return \Cake\I18n\RelativeTimeFormatter + */ + public static function diffFormatter(?DifferenceFormatterInterface $formatter = null): RelativeTimeFormatter + { + if ($formatter) { + if (!$formatter instanceof RelativeTimeFormatter) { + throw new InvalidArgumentException('Formatter for I18n must extend RelativeTimeFormatter.'); + } + + return static::$diffFormatter = $formatter; + } + + /** @var \Cake\I18n\RelativeTimeFormatter $formatter */ + $formatter = static::$diffFormatter ??= new RelativeTimeFormatter(); + + return $formatter; + } + + /** + * Returns a formatted string for this time object using the preferred format and + * language for the specified locale. + * + * It is possible to specify the desired format for the string to be displayed. + * You can either pass `IntlDateFormatter` constants as the first argument of this + * function, or pass a full ICU date formatting string as specified in the following + * resource: https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax. + * + * Additional to `IntlDateFormatter` constants and date formatting string you can use + * DateTime::UNIX_TIMESTAMP_FORMAT to get a unix timestamp + * + * ### Examples + * + * ``` + * $time = new DateTime('2014-04-20 22:10'); + * $time->i18nFormat(); // outputs '4/20/14, 10:10 PM' for the en-US locale + * $time->i18nFormat(\IntlDateFormatter::FULL); // Use the full date and time format + * $time->i18nFormat([\IntlDateFormatter::FULL, \IntlDateFormatter::SHORT]); // Use full date but short time format + * $time->i18nFormat('yyyy-MM-dd HH:mm:ss'); // outputs '2014-04-20 22:10' + * $time->i18nFormat(DateTime::UNIX_TIMESTAMP_FORMAT); // outputs '1398031800' + * ``` + * + * You can control the default format used through `DateTime::setToStringFormat()`. + * + * You can read about the available IntlDateFormatter constants at + * https://secure.php.net/manual/en/class.intldateformatter.php + * + * If you need to display the date in a different timezone than the one being used for + * this Time object without altering its internal state, you can pass a timezone + * string or object as the second parameter. + * + * Finally, should you need to use a different locale for displaying this time object, + * pass a locale string as the third parameter to this function. + * + * ### Examples + * + * ``` + * $time = new Time('2014-04-20 22:10'); + * $time->i18nFormat(null, null, 'de-DE'); + * $time->i18nFormat(\IntlDateFormatter::FULL, 'Europe/Berlin', 'de-DE'); + * ``` + * + * You can control the default locale used through `DateTime::setDefaultLocale()`. + * If empty, the default will be taken from the `intl.default_locale` ini config. + * + * @param array|string|int|null $format Format string. + * @param \DateTimeZone|string|null $timezone Timezone string or DateTimeZone object + * in which the date will be displayed. The timezone stored for this object will not + * be changed. + * @param string|null $locale The locale name in which the date should be displayed (e.g. pt-BR) + * @return string|int Formatted and translated date string + */ + public function i18nFormat( + array|string|int|null $format = null, + DateTimeZone|string|null $timezone = null, + ?string $locale = null, + ): string|int { + if ($format === DateTime::UNIX_TIMESTAMP_FORMAT) { + return $this->getTimestamp(); + } + + $time = $this; + + if ($timezone) { + $time = $time->setTimezone($timezone); + } + + $format ??= static::$_toStringFormat; + $format = is_int($format) ? [$format, $format] : $format; + $locale = $locale ?: DateTime::getDefaultLocale(); + + return $this->_formatObject($time, $format, $locale); + } + + /** + * Returns a nicely formatted date string for this object. + * + * The format to be used is stored in the static property `DateTime::$niceFormat`. + * + * @param \DateTimeZone|string|null $timezone Timezone string or DateTimeZone object + * in which the date will be displayed. The timezone stored for this object will not + * be changed. + * @param string|null $locale The locale name in which the date should be displayed (e.g. pt-BR) + * @return string Formatted date string + */ + public function nice(DateTimeZone|string|null $timezone = null, ?string $locale = null): string + { + return (string)$this->i18nFormat(static::$niceFormat, $timezone, $locale); + } + + /** + * Returns either a relative or a formatted absolute date depending + * on the difference between the current time and this object. + * + * ### Options: + * + * - `from` => another Time object representing the "now" time + * - `format` => a fallback format if the relative time is longer than the duration specified by end + * - `accuracy` => Specifies how accurate the date should be described (array) + * - year => The format if years > 0 (default "day") + * - month => The format if months > 0 (default "day") + * - week => The format if weeks > 0 (default "day") + * - day => The format if weeks > 0 (default "hour") + * - hour => The format if hours > 0 (default "minute") + * - minute => The format if minutes > 0 (default "minute") + * - second => The format if seconds > 0 (default "second") + * - `end` => The end of relative time telling + * - `relativeString` => The printf compatible string when outputting relative time + * - `absoluteString` => The printf compatible string when outputting absolute time + * - `timezone` => The user timezone the timestamp should be formatted in. + * + * Relative dates look something like this: + * + * - 3 weeks, 4 days ago + * - 15 seconds ago + * + * Default date formatting is d/M/YY e.g: on 18/2/09. Formatting is done internally using + * `i18nFormat`, see the method for the valid formatting strings + * + * The returned string includes 'ago' or 'on' and assumes you'll properly add a word + * like 'Posted ' before the function output. + * + * NOTE: If the difference is one week or more, the lowest level of accuracy is day + * + * @param array $options Array of options. + * @return string Relative time string. + */ + public function timeAgoInWords(array $options = []): string + { + return static::diffFormatter()->timeAgoInWords($this, $options); + } + + /** + * Get list of timezone identifiers + * + * @param string|int|null $filter A regex to filter identifier + * Or one of DateTimeZone class constants + * @param string|null $country A two-letter ISO 3166-1 compatible country code. + * This option is only used when $filter is set to DateTimeZone::PER_COUNTRY + * @param array|bool $options If true (default value) groups the identifiers list by primary region. + * Otherwise, an array containing `group`, `abbr`, `before`, and `after` + * keys. Setting `group` and `abbr` to true will group results and append + * timezone abbreviation in the display value. Set `before` and `after` + * to customize the abbreviation wrapper. + * @return array List of timezone identifiers + * @since 2.2 + */ + public static function listTimezones( + string|int|null $filter = null, + ?string $country = null, + array|bool $options = [], + ): array { + if (is_bool($options)) { + $options = [ + 'group' => $options, + ]; + } + $defaults = [ + 'group' => true, + 'abbr' => false, + 'before' => ' - ', + 'after' => null, + ]; + $options += $defaults; + $group = $options['group']; + + $regex = null; + if (is_string($filter)) { + $regex = $filter; + $filter = null; + } + $filter ??= DateTimeZone::ALL; + $identifiers = DateTimeZone::listIdentifiers($filter, (string)$country) ?: []; + + if ($regex) { + foreach ($identifiers as $key => $tz) { + if (!preg_match($regex, $tz)) { + unset($identifiers[$key]); + } + } + } + + if ($group) { + $groupedIdentifiers = []; + $now = time(); + $before = $options['before']; + $after = $options['after']; + foreach ($identifiers as $tz) { + $abbr = ''; + if ($options['abbr']) { + $dateTimeZone = new DateTimeZone($tz); + $trans = $dateTimeZone->getTransitions($now, $now); + $abbr = isset($trans[0]['abbr']) ? + $before . $trans[0]['abbr'] . $after : + ''; + } + $item = explode('/', $tz, 2); + if (isset($item[1])) { + $groupedIdentifiers[$item[0]][$tz] = $item[1] . $abbr; + } else { + $groupedIdentifiers[$item[0]] = [$tz => $item[0] . $abbr]; + } + } + + return $groupedIdentifiers; + } + + return array_combine($identifiers, $identifiers); + } + + /** + * Returns a string that should be serialized when converting this object to JSON + * + * @return string|int + */ + public function jsonSerialize(): mixed + { + if (static::$_jsonEncodeFormat instanceof Closure) { + return call_user_func(static::$_jsonEncodeFormat, $this); + } + + return $this->i18nFormat(static::$_jsonEncodeFormat); + } + + /** + * Returns the quarter + * + * Deprecated 5.3.0 Argument $range. Use toQuarterRange() to get quarter date ranges instead of passing $range = true + * + * @param bool $range Range. Deprecated, use toQuarterRange() instead. + * @return array|int 1, 2, 3, or 4 quarter of year or array if $range true + */ + public function toQuarter(bool $range = false): int|array + { + if ($range) { + trigger_error( + 'Passing $range = true to toQuarter() is deprecated. Use toQuarterRange() instead.', + E_USER_DEPRECATED, + ); + + return $this->toQuarterRange(); + } + + return (int)ceil((int)$this->format('m') / 3); + } + + /** + * Returns the date range for the quarter this date falls in. + * + * @return array{0: string, 1: string} Array with start and end dates in 'Y-m-d' format + */ + public function toQuarterRange(): array + { + $quarter = $this->toQuarter(); + $year = $this->format('Y'); + + return match ($quarter) { + 1 => [$year . '-01-01', $year . '-03-31'], + 2 => [$year . '-04-01', $year . '-06-30'], + 3 => [$year . '-07-01', $year . '-09-30'], + default => [$year . '-10-01', $year . '-12-31'], + }; + } + + /** + * @inheritDoc + */ + public function __toString(): string + { + return (string)$this->i18nFormat(); + } +} + +// phpcs:disable +class_alias('Cake\I18n\DateTime', 'Cake\I18n\FrozenTime'); +// phpcs:enable diff --git a/src/I18n/DateTimePeriod.php b/src/I18n/DateTimePeriod.php new file mode 100644 index 00000000000..8e7e683217c --- /dev/null +++ b/src/I18n/DateTimePeriod.php @@ -0,0 +1,37 @@ + + */ +class DateTimePeriod extends ChronosPeriod +{ + /** + * @return \Cake\I18n\DateTime + */ + public function current(): DateTime + { + return new DateTime($this->iterator->current()); + } +} diff --git a/src/I18n/Exception/I18nException.php b/src/I18n/Exception/I18nException.php new file mode 100644 index 00000000000..3b3819e3ee6 --- /dev/null +++ b/src/I18n/Exception/I18nException.php @@ -0,0 +1,27 @@ +_formatMessage($locale, $message, $vars); - } - - /** - * Does the actual formatting using the MessageFormatter class - * - * @param string $locale The locale in which the message is presented. - * @param string|array $message The message to be translated - * @param array $vars The list of values to interpolate in the message + * @param string $message The message to be translated + * @param array $tokenValues The list of values to interpolate in the message * @return string The formatted message - * @throws \Aura\Intl\Exception\CannotInstantiateFormatter if any error occurred - * while parsing the message - * @throws \Aura\Intl\Exception\CannotFormat If any error related to the passed - * variables is found + * @throws \Cake\I18n\Exception\I18nException */ - protected function _formatMessage($locale, $message, $vars) + public function format(string $locale, string $message, array $tokenValues): string { if ($message === '') { return $message; } - // Using procedural style as it showed twice as fast as - // its counterpart in PHP 5.5 - $result = MessageFormatter::formatMessage($locale, $message, $vars); + $formatter = new MessageFormatter($locale, $message); + $result = $formatter->format($tokenValues); if ($result === false) { - // The user might be interested in what went wrong, so replay the - // previous action using the object oriented style to figure out - $formatter = new MessageFormatter($locale, $message); - if (!$formatter) { - throw new CannotInstantiateFormatter(intl_get_error_message(), intl_get_error_code()); - } - - $formatter->format($vars); - throw new CannotFormat($formatter->getErrorMessage(), $formatter->getErrorCode()); + throw new I18nException($formatter->getErrorMessage(), $formatter->getErrorCode()); } return $result; diff --git a/src/I18n/Formatter/SprintfFormatter.php b/src/I18n/Formatter/SprintfFormatter.php index 2cb8d5dc301..84c5d7bddd6 100644 --- a/src/I18n/Formatter/SprintfFormatter.php +++ b/src/I18n/Formatter/SprintfFormatter.php @@ -1,4 +1,6 @@ > + */ + protected array $registry = []; + + /** + * Tracks whether a registry entry has been converted from a + * FQCN to a formatter object. + * + * @var array + */ + protected array $converted = []; + + /** + * Constructor. + * + * @param array> $registry An array of key-value pairs where the key is the + * formatter name the value is a FQCN for the formatter. + */ + public function __construct(array $registry = []) + { + foreach ($registry as $name => $spec) { + $this->set($name, $spec); + } + } + + /** + * Sets a formatter into the registry by name. + * + * @param string $name The formatter name. + * @param class-string<\Cake\I18n\FormatterInterface> $className A FQCN for a formatter. + * @return void + */ + public function set(string $name, string $className): void + { + $this->registry[$name] = $className; + $this->converted[$name] = false; + } + + /** + * Gets a formatter from the registry by name. + * + * @param string $name The formatter to retrieve. + * @return \Cake\I18n\FormatterInterface A formatter object. + * @throws \Cake\I18n\Exception\I18nException + */ + public function get(string $name): FormatterInterface + { + if (!isset($this->registry[$name])) { + throw new I18nException(sprintf('Formatter named `%s` has not been registered.', $name)); + } + + if (!$this->converted[$name]) { + /** @var class-string<\Cake\I18n\FormatterInterface> $formatter */ + $formatter = $this->registry[$name]; + $this->registry[$name] = new $formatter(); + $this->converted[$name] = true; + } + + /** @var \Cake\I18n\FormatterInterface */ + return $this->registry[$name]; + } +} diff --git a/src/I18n/FrozenDate.php b/src/I18n/FrozenDate.php index 471d1779919..bec29c82f68 100644 --- a/src/I18n/FrozenDate.php +++ b/src/I18n/FrozenDate.php @@ -1,137 +1,9 @@ 'day', - 'month' => 'day', - 'week' => 'day', - 'day' => 'day', - 'hour' => 'day', - 'minute' => 'day', - 'second' => 'day', - ]; - - /** - * The end of relative time telling - * - * @var string - * @see \Cake\I18n\Date::timeAgoInWords() - */ - public static $wordEnd = '+1 month'; - - /** - * Returns either a relative or a formatted absolute date depending - * on the difference between the current date and this object. - * - * ### Options: - * - * - `from` => another Date object representing the "now" date - * - `format` => a fall back format if the relative time is longer than the duration specified by end - * - `accuracy` => Specifies how accurate the date should be described (array) - * - year => The format if years > 0 (default "day") - * - month => The format if months > 0 (default "day") - * - week => The format if weeks > 0 (default "day") - * - day => The format if weeks > 0 (default "day") - * - `end` => The end of relative date telling - * - `relativeString` => The printf compatible string when outputting relative date - * - `absoluteString` => The printf compatible string when outputting absolute date - * - `timezone` => The user timezone the timestamp should be formatted in. - * - * Relative dates look something like this: - * - * - 3 weeks, 4 days ago - * - 1 day ago - * - * Default date formatting is d/M/YY e.g: on 18/2/09. Formatting is done internally using - * `i18nFormat`, see the method for the valid formatting strings. - * - * The returned string includes 'ago' or 'on' and assumes you'll properly add a word - * like 'Posted ' before the function output. - * - * NOTE: If the difference is one week or more, the lowest level of accuracy is day. - * - * @param array $options Array of options. - * @return string Relative time string. - */ - public function timeAgoInWords(array $options = []) - { - return static::diffFormatter()->dateAgoInWords($this, $options); - } -} +class_exists(Date::class); diff --git a/src/I18n/FrozenTime.php b/src/I18n/FrozenTime.php index bc6a4c153b5..71051135252 100644 --- a/src/I18n/FrozenTime.php +++ b/src/I18n/FrozenTime.php @@ -1,284 +1,9 @@ 'day', - 'month' => 'day', - 'week' => 'day', - 'day' => 'hour', - 'hour' => 'minute', - 'minute' => 'minute', - 'second' => 'second', - ]; - - /** - * The end of relative time telling - * - * @var string - * @see \Cake\I18n\FrozenTime::timeAgoInWords() - */ - public static $wordEnd = '+1 month'; - - /** - * serialise the value as a Unix Timestamp - * - * @var string - */ - const UNIX_TIMESTAMP_FORMAT = 'unixTimestampFormat'; - - /** - * {@inheritDoc} - */ - public function __construct($time = null, $tz = null) - { - if ($time instanceof DateTimeInterface) { - $tz = $time->getTimezone(); - $time = $time->format('Y-m-d H:i:s'); - } - - if (is_numeric($time)) { - $time = '@' . $time; - } - - parent::__construct($time, $tz); - } - - /** - * Returns either a relative or a formatted absolute date depending - * on the difference between the current time and this object. - * - * ### Options: - * - * - `from` => another Time object representing the "now" time - * - `format` => a fall back format if the relative time is longer than the duration specified by end - * - `accuracy` => Specifies how accurate the date should be described (array) - * - year => The format if years > 0 (default "day") - * - month => The format if months > 0 (default "day") - * - week => The format if weeks > 0 (default "day") - * - day => The format if weeks > 0 (default "hour") - * - hour => The format if hours > 0 (default "minute") - * - minute => The format if minutes > 0 (default "minute") - * - second => The format if seconds > 0 (default "second") - * - `end` => The end of relative time telling - * - `relativeString` => The printf compatible string when outputting relative time - * - `absoluteString` => The printf compatible string when outputting absolute time - * - `timezone` => The user timezone the timestamp should be formatted in. - * - * Relative dates look something like this: - * - * - 3 weeks, 4 days ago - * - 15 seconds ago - * - * Default date formatting is d/M/YY e.g: on 18/2/09. Formatting is done internally using - * `i18nFormat`, see the method for the valid formatting strings - * - * The returned string includes 'ago' or 'on' and assumes you'll properly add a word - * like 'Posted ' before the function output. - * - * NOTE: If the difference is one week or more, the lowest level of accuracy is day - * - * @param array $options Array of options. - * @return string Relative time string. - */ - public function timeAgoInWords(array $options = []) - { - return static::diffFormatter()->timeAgoInWords($this, $options); - } - - /** - * Get list of timezone identifiers - * - * @param int|string|null $filter A regex to filter identifier - * Or one of DateTimeZone class constants - * @param string|null $country A two-letter ISO 3166-1 compatible country code. - * This option is only used when $filter is set to DateTimeZone::PER_COUNTRY - * @param bool|array $options If true (default value) groups the identifiers list by primary region. - * Otherwise, an array containing `group`, `abbr`, `before`, and `after` - * keys. Setting `group` and `abbr` to true will group results and append - * timezone abbreviation in the display value. Set `before` and `after` - * to customize the abbreviation wrapper. - * @return array List of timezone identifiers - * @since 2.2 - */ - public static function listTimezones($filter = null, $country = null, $options = []) - { - if (is_bool($options)) { - $options = [ - 'group' => $options, - ]; - } - $defaults = [ - 'group' => true, - 'abbr' => false, - 'before' => ' - ', - 'after' => null, - ]; - $options += $defaults; - $group = $options['group']; - - $regex = null; - if (is_string($filter)) { - $regex = $filter; - $filter = null; - } - if ($filter === null) { - $filter = DateTimeZone::ALL; - } - $identifiers = DateTimeZone::listIdentifiers($filter, $country); - - if ($regex) { - foreach ($identifiers as $key => $tz) { - if (!preg_match($regex, $tz)) { - unset($identifiers[$key]); - } - } - } - - if ($group) { - $groupedIdentifiers = []; - $now = time(); - $before = $options['before']; - $after = $options['after']; - foreach ($identifiers as $key => $tz) { - $abbr = null; - if ($options['abbr']) { - $dateTimeZone = new DateTimeZone($tz); - $trans = $dateTimeZone->getTransitions($now, $now); - $abbr = isset($trans[0]['abbr']) ? - $before . $trans[0]['abbr'] . $after : - null; - } - $item = explode('/', $tz, 2); - if (isset($item[1])) { - $groupedIdentifiers[$item[0]][$tz] = $item[1] . $abbr; - } else { - $groupedIdentifiers[$item[0]] = [$tz => $item[0] . $abbr]; - } - } - - return $groupedIdentifiers; - } - - return array_combine($identifiers, $identifiers); - } - - /** - * Returns true this instance will happen within the specified interval - * - * This overridden method provides backwards compatible behavior for integers, - * or strings with trailing spaces. This behavior is *deprecated* and will be - * removed in future versions of CakePHP. - * - * @param string|int $timeInterval the numeric value with space then time type. - * Example of valid types: 6 hours, 2 days, 1 minute. - * @return bool - */ - public function wasWithinLast($timeInterval) - { - $tmp = trim($timeInterval); - if (is_numeric($tmp)) { - $timeInterval = $tmp . ' days'; - } - - return parent::wasWithinLast($timeInterval); - } - - /** - * Returns true this instance happened within the specified interval - * - * This overridden method provides backwards compatible behavior for integers, - * or strings with trailing spaces. This behavior is *deprecated* and will be - * removed in future versions of CakePHP. - * - * @param string|int $timeInterval the numeric value with space then time type. - * Example of valid types: 6 hours, 2 days, 1 minute. - * @return bool - */ - public function isWithinNext($timeInterval) - { - $tmp = trim($timeInterval); - if (is_numeric($tmp)) { - $timeInterval = $tmp . ' days'; - } - - return parent::isWithinNext($timeInterval); - } -} +class_exists(DateTime::class); diff --git a/src/I18n/I18n.php b/src/I18n/I18n.php index 68a99e5ec6a..f1fdea76269 100644 --- a/src/I18n/I18n.php +++ b/src/I18n/I18n.php @@ -1,4 +1,6 @@ function () { - return new SprintfFormatter(); - }, - 'default' => function () { - return new IcuFormatter(); - }, + 'default' => IcuFormatter::class, + 'sprintf' => SprintfFormatter::class, ]), - new TranslatorFactory, - static::getLocale() + static::getLocale(), ); - if (class_exists('Cake\Cache\Cache')) { - static::$_collection->setCacher(Cache::engine('_cake_core_')); + if (class_exists(Cache::class)) { + try { + $pool = Cache::pool(static::$cacheConfig); + } catch (InvalidArgumentException $e) { + if (static::$cacheConfig !== self::DEFAULT_CACHE_CONFIG) { + throw $e; + } + $pool = Cache::pool('_cake_core_'); + deprecationWarning( + '5.1.0', + 'Cache config `_cake_core_` is deprecated. Use `_cake_translations_` instead', + ); + } + static::$_collection->setCacher($pool); } return static::$_collection; } /** - * Returns an instance of a translator that was configured for the name and passed - * locale. If no locale is passed then it takes the value returned by the `getLocale()` method. + * Sets the Cache configuration name used by the default translators registry. * - * This method can be used to configure future translators, this is achieved by passing a callable - * as the last argument of this function. + * Must be called before the first translator is resolved. To swap the + * cacher after translators have been built, use + * {@see \Cake\I18n\TranslatorRegistry::setCacher()} directly. * - * ### Example: - * - * ``` - * I18n::setTranslator('default', function () { - * $package = new \Aura\Intl\Package(); - * $package->setMessages([ - * 'Cake' => 'Gâteau' - * ]); - * return $package; - * }, 'fr_FR'); - * - * $translator = I18n::translator('default', 'fr_FR'); - * echo $translator->translate('Cake'); - * ``` - * - * You can also use the `Cake\I18n\MessagesFileLoader` class to load a specific - * file from a folder. For example for loading a `my_translations.po` file from - * the `src/Locale/custom` folder, you would do: - * - * ``` - * I18n::translator( - * 'default', - * 'fr_FR', - * new MessagesFileLoader('my_translations', 'custom', 'po'); - * ); - * ``` - * - * @deprecated 3.5 Use getTranslator() and setTranslator() - * @param string $name The domain of the translation messages. - * @param string|null $locale The locale for the translator. - * @param callable|null $loader A callback function or callable class responsible for - * constructing a translations package instance. - * @return \Aura\Intl\TranslatorInterface|null The configured translator. + * @param string $name The Cache config name to use for translator persistence. + * @return void + * @throws \RuntimeException When the translators registry has already been built. */ - public static function translator($name = 'default', $locale = null, callable $loader = null) + public static function setCacheConfig(string $name): void { - if ($loader !== null) { - static::setTranslator($name, $loader, $locale); - - return null; + if (static::$_collection !== null) { + throw new RuntimeException( + '`I18n::setCacheConfig()` must be called before the translators registry is built. ' + . 'Call `I18n::clear()` first, or use `I18n::translators()->setCacher()` to swap the cacher.', + ); } - return self::getTranslator($name, $locale); + static::$cacheConfig = $name; } /** @@ -144,7 +139,7 @@ public static function translator($name = 'default', $locale = null, callable $l * * ``` * I18n::setTranslator('default', function () { - * $package = new \Aura\Intl\Package(); + * $package = new \Cake\I18n\Package(); * $package->setMessages([ * 'Cake' => 'Gâteau' * ]); @@ -157,7 +152,7 @@ public static function translator($name = 'default', $locale = null, callable $l * * You can also use the `Cake\I18n\MessagesFileLoader` class to load a specific * file from a folder. For example for loading a `my_translations.po` file from - * the `src/Locale/custom` folder, you would do: + * the `resources/locales/custom` folder, you would do: * * ``` * I18n::setTranslator( @@ -173,7 +168,7 @@ public static function translator($name = 'default', $locale = null, callable $l * @param string|null $locale The locale for the translator. * @return void */ - public static function setTranslator($name, callable $loader, $locale = null) + public static function setTranslator(string $name, callable $loader, ?string $locale = null): void { $locale = $locale ?: static::getLocale(); @@ -190,20 +185,28 @@ public static function setTranslator($name, callable $loader, $locale = null) * * @param string $name The domain of the translation messages. * @param string|null $locale The locale for the translator. - * @return \Aura\Intl\TranslatorInterface The configured translator. + * @return \Cake\I18n\Translator The configured translator. + * @throws \Cake\I18n\Exception\I18nException */ - public static function getTranslator($name = 'default', $locale = null) + public static function getTranslator(string $name = 'default', ?string $locale = null): Translator { $translators = static::translators(); + $currentLocale = null; if ($locale) { $currentLocale = $translators->getLocale(); $translators->setLocale($locale); } $translator = $translators->get($name); + if ($translator === null) { + throw new I18nException(sprintf( + 'Translator for domain `%s` could not be found.', + $name, + )); + } - if (isset($currentLocale)) { + if ($currentLocale !== null) { $translators->setLocale($currentLocale); } @@ -218,18 +221,18 @@ public static function getTranslator($name = 'default', $locale = null) * * Registering loaders is useful when you need to lazily use translations in multiple * different locales for the same domain, and don't want to use the built-in - * translation service based of `gettext` files. + * translation service based on `gettext` files. * * Loader objects will receive two arguments: The domain name that needs to be * built, and the locale that is requested. These objects can assemble the messages - * from any source, but must return an `Aura\Intl\Package` object. + * from any source, but must return an `Cake\I18n\Package` object. * * ### Example: * * ``` * use Cake\I18n\MessagesFileLoader; * I18n::config('my_domain', function ($name, $locale) { - * // Load src/Locale/$locale/filename.po + * // Load resources/locales/$locale/filename.po * $fileLoader = new MessagesFileLoader('filename', $locale, 'po'); * return $fileLoader(); * }); @@ -238,7 +241,7 @@ public static function getTranslator($name = 'default', $locale = null) * You can also assemble the package object yourself: * * ``` - * use Aura\Intl\Package; + * use Cake\I18n\Package; * I18n::config('my_domain', function ($name, $locale) { * $package = new Package('default'); * $messages = (...); // Fetch messages for locale from external service. @@ -253,33 +256,11 @@ public static function getTranslator($name = 'default', $locale = null) * instance to be used for assembling a new translator. * @return void */ - public static function config($name, callable $loader) + public static function config(string $name, callable $loader): void { static::translators()->registerLoader($name, $loader); } - /** - * Sets the default locale to use for future translator instances. - * This also affects the `intl.default_locale` PHP setting. - * - * When called with no arguments it will return the currently configure - * locale as stored in the `intl.default_locale` PHP setting. - * - * @deprecated 3.5 Use setLocale() and getLocale(). - * @param string|null $locale The name of the locale to set as default. - * @return string|null The name of the default locale. - */ - public static function locale($locale = null) - { - if (!empty($locale)) { - static::setLocale($locale); - - return null; - } - - return self::getLocale(); - } - /** * Sets the default locale to use for future translator instances. * This also affects the `intl.default_locale` PHP setting. @@ -287,7 +268,7 @@ public static function locale($locale = null) * @param string $locale The name of the locale to set as default. * @return void */ - public static function setLocale($locale) + public static function setLocale(string $locale): void { static::getDefaultLocale(); Locale::setDefault($locale); @@ -297,12 +278,12 @@ public static function setLocale($locale) } /** - * Will return the currently configure locale as stored in the + * Will return the currently configured locale as stored in the * `intl.default_locale` PHP setting. * * @return string The name of the default locale. */ - public static function getLocale() + public static function getLocale(): string { static::getDefaultLocale(); $current = Locale::getDefault(); @@ -314,19 +295,6 @@ public static function getLocale() return $current; } - /** - * This returns the default locale before any modifications, i.e. - * the value as stored in the `intl.default_locale` PHP setting before - * any manipulation by this class. - * - * @deprecated 3.5 Use getDefaultLocale() - * @return string - */ - public static function defaultLocale() - { - return static::getDefaultLocale(); - } - /** * Returns the default locale. * @@ -336,30 +304,9 @@ public static function defaultLocale() * * @return string */ - public static function getDefaultLocale() - { - if (static::$_defaultLocale === null) { - static::$_defaultLocale = Locale::getDefault() ?: static::DEFAULT_LOCALE; - } - - return static::$_defaultLocale; - } - - /** - * Sets the name of the default messages formatter to use for future - * translator instances. - * - * By default the `default` and `sprintf` formatters are available. - * - * If called with no arguments, it will return the currently configured value. - * - * @deprecated 3.5 Use getDefaultFormatter() and setDefaultFormatter(). - * @param string|null $name The name of the formatter to use. - * @return string The name of the formatter. - */ - public static function defaultFormatter($name = null) + public static function getDefaultLocale(): string { - return static::translators()->defaultFormatter($name); + return static::$_defaultLocale ??= Locale::getDefault() ?: static::DEFAULT_LOCALE; } /** @@ -367,20 +314,20 @@ public static function defaultFormatter($name = null) * * @return string The name of the formatter. */ - public static function getDefaultFormatter() + public static function getDefaultFormatter(): string { return static::translators()->defaultFormatter(); } /** * Sets the name of the default messages formatter to use for future - * translator instances. By default the `default` and `sprintf` formatters + * translator instances. By default, the `default` and `sprintf` formatters * are available. * * @param string $name The name of the formatter to use. * @return void */ - public static function setDefaultFormatter($name) + public static function setDefaultFormatter(string $name): void { static::translators()->defaultFormatter($name); } @@ -391,7 +338,7 @@ public static function setDefaultFormatter($name) * @param bool $enable flag to enable or disable fallback * @return void */ - public static function useFallback($enable = true) + public static function useFallback(bool $enable = true): void { static::translators()->useFallback($enable); } @@ -402,7 +349,7 @@ public static function useFallback($enable = true) * * @return void */ - public static function clear() + public static function clear(): void { static::$_collection = null; } diff --git a/src/I18n/LICENSE.txt b/src/I18n/LICENSE.txt index 0c4b7932c31..b938c9e8ed3 100644 --- a/src/I18n/LICENSE.txt +++ b/src/I18n/LICENSE.txt @@ -1,7 +1,7 @@ The MIT License (MIT) CakePHP(tm) : The Rapid Development PHP Framework (https://cakephp.org) -Copyright (c) 2005-2016, Cake Software Foundation, Inc. (https://cakefoundation.org) +Copyright (c) 2005-2020, Cake Software Foundation, Inc. (https://cakefoundation.org) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/I18n/MessagesFileLoader.php b/src/I18n/MessagesFileLoader.php index 3fb0d60bfaa..8a57871f7d1 100644 --- a/src/I18n/MessagesFileLoader.php +++ b/src/I18n/MessagesFileLoader.php @@ -1,4 +1,6 @@ _name = $name; + // If space is not added after slash, the character after it remains lowercased + $pluginName = Inflector::camelize(str_replace('/', '/ ', $this->_name)); + if (strpos($this->_name, '.')) { + [$this->_plugin, $this->_name] = pluginSplit($pluginName); + } elseif (Plugin::isLoaded($pluginName)) { + $this->_plugin = $pluginName; + } $this->_locale = $locale; $this->_extension = $extension; } @@ -101,41 +118,28 @@ public function __construct($name, $locale, $extension = 'po') * Loads the translation file and parses it. Returns an instance of a translations * package containing the messages loaded from the file. * - * @return \Aura\Intl\Package|false - * @throws \RuntimeException if no file parser class could be found for the specified + * @return \Cake\I18n\Package|false + * @throws \Cake\Core\Exception\CakeException if no file parser class could be found for the specified * file extension. */ - public function __invoke() + public function __invoke(): Package|false { $folders = $this->translationsFolders(); - $ext = $this->_extension; - $file = false; - - $fileName = $this->_name; - $pos = strpos($fileName, '/'); - if ($pos !== false) { - $fileName = substr($fileName, $pos + 1); - } - foreach ($folders as $folder) { - $path = $folder . $fileName . ".$ext"; - if (is_file($path)) { - $file = $path; - break; - } - } - + $file = $this->translationFile($folders, $this->_name, $this->_extension); if (!$file) { return false; } - $name = ucfirst($ext); + $name = ucfirst($this->_extension); $class = App::className($name, 'I18n\Parser', 'FileParser'); if (!$class) { - throw new RuntimeException(sprintf('Could not find class %s', "{$name}FileParser")); + throw new CakeException(sprintf('Could not find class `%s`.', "{$name}FileParser")); } - $messages = (new $class)->parse($file); + /** @var \Cake\I18n\Parser\MoFileParser|\Cake\I18n\Parser\PoFileParser $object */ + $object = new $class(); + $messages = $object->parse($file); $package = new Package('default'); $package->setMessages($messages); @@ -146,22 +150,34 @@ public function __invoke() * Returns the folders where the file should be looked for according to the locale * and package name. * - * @return array The list of folders where the translation file should be looked for + * @return array The list of folders where the translation file should be looked for */ - public function translationsFolders() + public function translationsFolders(): array { $locale = Locale::parseLocale($this->_locale) + ['region' => null]; $folders = [ - implode('_', [$locale['language'], $locale['region']]), - $locale['language'] + $locale['language'], + // gettext compatible paths, see https://www.php.net/manual/en/function.gettext.php + $locale['language'] . DIRECTORY_SEPARATOR . 'LC_MESSAGES', ]; + if ($locale['region']) { + $languageRegion = implode('_', [$locale['language'], $locale['region']]); + $folders[] = $languageRegion; + // gettext compatible paths, see https://www.php.net/manual/en/function.gettext.php + $folders[] = $languageRegion . DIRECTORY_SEPARATOR . 'LC_MESSAGES'; + } $searchPaths = []; - $localePaths = App::path('Locale'); - if (empty($localePaths) && defined('APP')) { - $localePaths[] = APP . 'Locale' . DIRECTORY_SEPARATOR; + $localePaths = App::path('locales'); + if (!$localePaths && defined('ROOT')) { + $localePaths[] = ROOT . DIRECTORY_SEPARATOR + . 'resources' . DIRECTORY_SEPARATOR + . 'locales' . DIRECTORY_SEPARATOR; + } + if ($this->_plugin && Plugin::isLoaded($this->_plugin)) { + $localePaths[] = App::path('locales', $this->_plugin)[0]; } foreach ($localePaths as $path) { foreach ($folders as $folder) { @@ -169,15 +185,29 @@ public function translationsFolders() } } - // If space is not added after slash, the character after it remains lowercased - $pluginName = Inflector::camelize(str_replace('/', '/ ', $this->_name)); - if (Plugin::loaded($pluginName)) { - $basePath = Plugin::classPath($pluginName) . 'Locale' . DIRECTORY_SEPARATOR; - foreach ($folders as $folder) { - $searchPaths[] = $basePath . $folder . DIRECTORY_SEPARATOR; + return $searchPaths; + } + + /** + * @param array $folders Folders + * @param string $name File name + * @param string $ext File extension + * @return string|null File if found + */ + protected function translationFile(array $folders, string $name, string $ext): ?string + { + $file = null; + + $name = str_replace('/', '_', $name); + + foreach ($folders as $folder) { + $path = "{$folder}{$name}.{$ext}"; + if (is_file($path)) { + $file = $path; + break; } } - return $searchPaths; + return $file; } } diff --git a/src/I18n/Middleware/LocaleSelectorMiddleware.php b/src/I18n/Middleware/LocaleSelectorMiddleware.php index 421fe85f6d8..d9726698ca6 100644 --- a/src/I18n/Middleware/LocaleSelectorMiddleware.php +++ b/src/I18n/Middleware/LocaleSelectorMiddleware.php @@ -1,4 +1,6 @@ getHeaderLine('Accept-Language')); if (!$locale) { - return $next($request, $response); + return $handler->handle($request); + } + if ($this->locales !== ['*']) { + $locale = Locale::lookup($this->locales, $locale, true); } - if (in_array($locale, $this->locales) || $this->locales === ['*']) { + if ($locale) { I18n::setLocale($locale); } - return $next($request, $response); + return $handler->handle($request); } } diff --git a/src/I18n/Number.php b/src/I18n/Number.php index 0027bd9edbc..23248629b27 100644 --- a/src/I18n/Number.php +++ b/src/I18n/Number.php @@ -1,4 +1,6 @@ > */ - protected static $_formatters = []; + protected static array $_formatters = []; /** * Default currency used by Number::currency() * * @var string|null */ - protected static $_defaultCurrency; + protected static ?string $_defaultCurrency = null; + + /** + * Default currency format used by Number::currency() + * + * @var string|null + */ + protected static ?string $_defaultCurrencyFormat = null; + + /** + * Default units used by Number::toReadableSize() + * + * @var bool + */ + protected static bool $useIecUnits = false; /** * Formats a number with a level of precision. @@ -61,40 +84,63 @@ class Number * * - `locale`: The locale name to use for formatting the number, e.g. fr_FR * - * @param float $value A floating point number. + * @param string|float|int $value A floating point number. * @param int $precision The precision of the returned number. - * @param array $options Additional options + * @param array $options Additional options * @return string Formatted float. - * @link https://book.cakephp.org/3.0/en/core-libraries/number.html#formatting-floating-point-numbers + * @link https://book.cakephp.org/5/en/core-libraries/number.html#formatting-floating-point-numbers */ - public static function precision($value, $precision = 3, array $options = []) + public static function precision(string|float|int $value, int $precision = 3, array $options = []): string { $formatter = static::formatter(['precision' => $precision, 'places' => $precision] + $options); - return $formatter->format($value); + return (string)$formatter->format((float)$value); } /** - * Returns a formatted-for-humans file size. + * Returns a formatted-for-humans file size. By default, the units are exponents of ten (KB, MB, etc.). + * setUseIecUnits() can be used to swap to ISO/IEC 80000-13 units (KiB, MiB, etc). + * 1 KiB = 1024 Bytes + * 1 KB = 1000 Bytes * - * @param int $size Size in bytes + * @param string|float|int $size Size in bytes + * @param bool|null $useIecUnits Whether to use exponent of two or ten for units (KiB, MiB, etc. or KB, MB, etc.) * @return string Human readable size - * @link https://book.cakephp.org/3.0/en/core-libraries/number.html#interacting-with-human-readable-values + * @link https://book.cakephp.org/5/en/core-libraries/number.html#interacting-with-human-readable-values */ - public static function toReadableSize($size) + public static function toReadableSize(string|float|int $size, ?bool $useIecUnits = null): string { - switch (true) { - case $size < 1024: - return __dn('cake', '{0,number,integer} Byte', '{0,number,integer} Bytes', $size, $size); - case round($size / 1024) < 1024: - return __d('cake', '{0,number,#,###.##} KB', $size / 1024); - case round($size / 1024 / 1024, 2) < 1024: - return __d('cake', '{0,number,#,###.##} MB', $size / 1024 / 1024); - case round($size / 1024 / 1024 / 1024, 2) < 1024: - return __d('cake', '{0,number,#,###.##} GB', $size / 1024 / 1024 / 1024); - default: - return __d('cake', '{0,number,#,###.##} TB', $size / 1024 / 1024 / 1024 / 1024); - } + $useIec = $useIecUnits ?? static::$useIecUnits; + + $units = $useIec + ? ['KiB', 'MiB', 'GiB', 'TiB'] + : ['KB', 'MB', 'GB', 'TB']; + + $divisor = $useIec ? 1024 : 1000; + + $size = (int)$size; + + return match (true) { + $size < $divisor => __dn('cake', '{0,number,integer} Byte', '{0,number,integer} Bytes', $size, $size), + round($size / $divisor) < $divisor => __d('cake', '{0,number,#,###.##} {1}', $size / $divisor, $units[0]), + round($size / pow($divisor, 2), 2) < $divisor => + __d('cake', '{0,number,#,###.##} {1}', $size / pow($divisor, 2), $units[1]), + round($size / pow($divisor, 3), 2) < $divisor => + __d('cake', '{0,number,#,###.##} {1}', $size / pow($divisor, 3), $units[2]), + default => __d('cake', '{0,number,#,###.##} {1}', $size / pow($divisor, 4), $units[3]), + }; + } + + /** + * Setter for units to use in toReadableSize(). If set to true, it will use IEC units, as defined in ISO/IEC 80000-13 + * (KiB, MiB, etc.). Else it will use natural units (MB, KB, etc). + * + * @param bool $useIec Whether to use exponents of two or ten for units (KiB, MiB, etc. or KB, MB, etc.) {@link toReadableSize()} + * @return void + */ + public static function setUseIecUnits(bool $useIec): void + { + static::$useIecUnits = $useIec; } /** @@ -105,20 +151,20 @@ public static function toReadableSize($size) * - `multiply`: Multiply the input value by 100 for decimal percentages. * - `locale`: The locale name to use for formatting the number, e.g. fr_FR * - * @param float $value A floating point number + * @param string|float|int $value A floating point number * @param int $precision The precision of the returned number - * @param array $options Options + * @param array $options Options * @return string Percentage string - * @link https://book.cakephp.org/3.0/en/core-libraries/number.html#formatting-percentages + * @link https://book.cakephp.org/5/en/core-libraries/number.html#formatting-percentages */ - public static function toPercentage($value, $precision = 2, array $options = []) + public static function toPercentage(string|float|int $value, int $precision = 2, array $options = []): string { - $options += ['multiply' => false]; - if ($options['multiply']) { - $value *= 100; + $options += ['multiply' => false, 'type' => NumberFormatter::PERCENT]; + if (!$options['multiply']) { + $value = (float)$value / 100; } - return static::precision($value, $precision, $options) . '%'; + return static::precision($value, $precision, $options); } /** @@ -128,21 +174,21 @@ public static function toPercentage($value, $precision = 2, array $options = []) * * - `places` - Minimum number or decimals to use, e.g 0 * - `precision` - Maximum Number of decimal places to use, e.g. 2 - * - `pattern` - An ICU number pattern to use for formatting the number. e.g #,###.00 + * - `pattern` - An ICU number pattern to use for formatting the number. e.g #,##0.00 * - `locale` - The locale name to use for formatting the number, e.g. fr_FR * - `before` - The string to place before whole numbers, e.g. '[' * - `after` - The string to place after decimal numbers, e.g. ']' * - * @param float $value A floating point number. - * @param array $options An array with options. + * @param string|float|int $value A floating point number. + * @param array $options An array with options. * @return string Formatted number */ - public static function format($value, array $options = []) + public static function format(string|float|int $value, array $options = []): string { $formatter = static::formatter($options); $options += ['before' => '', 'after' => '']; - return $options['before'] . $formatter->format($value) . $options['after']; + return $options['before'] . $formatter->format((float)$value) . $options['after']; } /** @@ -155,14 +201,19 @@ public static function format($value, array $options = []) * numbers representing money. * * @param string $value A numeric string. - * @param array $options An array with options. - * @return float point number + * @param array $options An array with options. + * @return float|null Parsed float or null if parsing failed. */ - public static function parseFloat($value, array $options = []) + public static function parseFloat(string $value, array $options = []): ?float { $formatter = static::formatter($options); + $result = $formatter->parse($value, NumberFormatter::TYPE_DOUBLE); + + if ($result === false) { + return null; + } - return (float)$formatter->parse($value, NumberFormatter::TYPE_DOUBLE); + return (float)$result; } /** @@ -176,14 +227,14 @@ public static function parseFloat($value, array $options = []) * - `before` - The string to place before whole numbers, e.g. '[' * - `after` - The string to place after decimal numbers, e.g. ']' * - * @param float $value A floating point number - * @param array $options Options list. + * @param string|float|int $value A floating point number + * @param array $options Options list. * @return string formatted delta */ - public static function formatDelta($value, array $options = []) + public static function formatDelta(string|float|int $value, array $options = []): string { $options += ['places' => 0]; - $value = number_format($value, $options['places'], '.', ''); + $value = number_format((float)$value, $options['places'], '.', ''); $sign = $value > 0 ? '+' : ''; $options['before'] = isset($options['before']) ? $options['before'] . $sign : $sign; @@ -204,64 +255,99 @@ public static function formatDelta($value, array $options = []) * - `zero` - The text to use for zero values, can be a string or a number. e.g. 0, 'Free!' * - `places` - Number of decimal places to use. e.g. 2 * - `precision` - Maximum Number of decimal places to use, e.g. 2 - * - `pattern` - An ICU number pattern to use for formatting the number. e.g #,###.00 - * - `useIntlCode` - Whether or not to replace the currency symbol with the international + * - `roundingMode` - Rounding mode to use. e.g. NumberFormatter::ROUND_HALF_UP. + * When not set locale default will be used + * - `pattern` - An ICU number pattern to use for formatting the number. e.g #,##0.00 + * - `useIntlCode` - Whether to replace the currency symbol with the international * currency code. * - * @param float $value Value to format. + * @param string|float|int $value Value to format. * @param string|null $currency International currency name such as 'USD', 'EUR', 'JPY', 'CAD' - * @param array $options Options list. + * @param array $options Options list. * @return string Number formatted as a currency. */ - public static function currency($value, $currency = null, array $options = []) + public static function currency(string|float|int $value, ?string $currency = null, array $options = []): string { $value = (float)$value; - $currency = $currency ?: static::defaultCurrency(); + $currency = $currency ?: static::getDefaultCurrency(); if (isset($options['zero']) && !$value) { return $options['zero']; } - $formatter = static::formatter(['type' => static::FORMAT_CURRENCY] + $options); + $formatter = static::formatter(['type' => static::getDefaultCurrencyFormat()] + $options); $abs = abs($value); if (!empty($options['fractionSymbol']) && $abs > 0 && $abs < 1) { $value *= 100; - $pos = isset($options['fractionPosition']) ? $options['fractionPosition'] : 'after'; + /** @var string $pos */ + $pos = $options['fractionPosition'] ?? 'after'; return static::format($value, ['precision' => 0, $pos => $options['fractionSymbol']]); } - $before = isset($options['before']) ? $options['before'] : null; - $after = isset($options['after']) ? $options['after'] : null; + $before = $options['before'] ?? ''; + $after = $options['after'] ?? ''; + $value = $formatter->formatCurrency($value, $currency); - return $before . $formatter->formatCurrency($value, $currency) . $after; + return $before . $value . $after; } /** - * Getter/setter for default currency + * Getter for default currency * - * @param string|bool|null $currency Default currency string to be used by currency() - * if $currency argument is not provided. If boolean false is passed, it will clear the - * currently stored value - * @return string|null Currency + * @return string Currency */ - public static function defaultCurrency($currency = null) + public static function getDefaultCurrency(): string { - if (!empty($currency)) { - return self::$_defaultCurrency = $currency; - } - - if ($currency === false) { - return self::$_defaultCurrency = null; - } - - if (empty(self::$_defaultCurrency)) { + if (static::$_defaultCurrency === null) { $locale = ini_get('intl.default_locale') ?: static::DEFAULT_LOCALE; $formatter = new NumberFormatter($locale, NumberFormatter::CURRENCY); - self::$_defaultCurrency = $formatter->getTextAttribute(NumberFormatter::CURRENCY_CODE); + + $currency = $formatter->getTextAttribute(NumberFormatter::CURRENCY_CODE); + if ($currency === false) { + throw new CakeException('Failed to get currency code from the formatter'); + } + + return static::$_defaultCurrency = $currency; } - return self::$_defaultCurrency; + return static::$_defaultCurrency; + } + + /** + * Setter for default currency + * + * @param string|null $currency Default currency string to be used by {@link currency()} + * if $currency argument is not provided. If null is passed, it will clear the + * currently stored value + * @return void + */ + public static function setDefaultCurrency(?string $currency = null): void + { + static::$_defaultCurrency = $currency; + } + + /** + * Getter for default currency format + * + * @return string Currency Format + */ + public static function getDefaultCurrencyFormat(): string + { + return static::$_defaultCurrencyFormat ??= static::FORMAT_CURRENCY; + } + + /** + * Setter for default currency format + * + * @param string|null $currencyFormat Default currency format to be used by currency() + * if $currencyFormat argument is not provided. If null is passed, it will clear the + * currently stored value + * @return void + */ + public static function setDefaultCurrencyFormat(?string $currencyFormat = null): void + { + static::$_defaultCurrencyFormat = $currencyFormat; } /** @@ -277,16 +363,19 @@ public static function defaultCurrency($currency = null) * numbers representing money or a NumberFormatter constant. * - `places` - Number of decimal places to use. e.g. 2 * - `precision` - Maximum Number of decimal places to use, e.g. 2 - * - `pattern` - An ICU number pattern to use for formatting the number. e.g #,###.00 - * - `useIntlCode` - Whether or not to replace the currency symbol with the international + * - `roundingMode` - Rounding mode to use. e.g. NumberFormatter::ROUND_HALF_UP. + * When not set locale default will be used + * - `pattern` - An ICU number pattern to use for formatting the number. e.g #,##0.00 + * - `useIntlCode` - Whether to replace the currency symbol with the international * currency code. * - * @param array $options An array with options. + * @param array $options An array with options. * @return \NumberFormatter The configured formatter instance */ - public static function formatter($options = []) + public static function formatter(array $options = []): NumberFormatter { - $locale = isset($options['locale']) ? $options['locale'] : ini_get('intl.default_locale'); + /** @var string $locale */ + $locale = $options['locale'] ?? ini_get('intl.default_locale'); if (!$locale) { $locale = static::DEFAULT_LOCALE; @@ -294,28 +383,18 @@ public static function formatter($options = []) $type = NumberFormatter::DECIMAL; if (!empty($options['type'])) { - $type = $options['type']; + $type = (int)$options['type']; if ($options['type'] === static::FORMAT_CURRENCY) { $type = NumberFormatter::CURRENCY; + } elseif ($options['type'] === static::FORMAT_CURRENCY_ACCOUNTING) { + $type = NumberFormatter::CURRENCY_ACCOUNTING; } } - if (!isset(static::$_formatters[$locale][$type])) { - static::$_formatters[$locale][$type] = new NumberFormatter($locale, $type); - } + static::$_formatters[$locale][$type] ??= new NumberFormatter($locale, $type); + /** @var \NumberFormatter $formatter */ $formatter = static::$_formatters[$locale][$type]; - - $options = array_intersect_key($options, [ - 'places' => null, - 'precision' => null, - 'pattern' => null, - 'useIntlCode' => null - ]); - if (empty($options)) { - return $formatter; - } - $formatter = clone $formatter; return static::_setAttributes($formatter, $options); @@ -326,14 +405,14 @@ public static function formatter($options = []) * * @param string $locale The locale name to use for formatting the number, e.g. fr_FR * @param int $type The formatter type to construct. Defaults to NumberFormatter::DECIMAL. - * @param array $options See Number::formatter() for possible options. + * @param array $options See Number::formatter() for possible options. * @return void */ - public static function config($locale, $type = NumberFormatter::DECIMAL, array $options = []) + public static function config(string $locale, int $type = NumberFormatter::DECIMAL, array $options = []): void { static::$_formatters[$locale][$type] = static::_setAttributes( new NumberFormatter($locale, $type), - $options + $options, ); } @@ -341,10 +420,10 @@ public static function config($locale, $type = NumberFormatter::DECIMAL, array $ * Set formatter attributes * * @param \NumberFormatter $formatter Number formatter instance. - * @param array $options See Number::formatter() for possible options. + * @param array $options See Number::formatter() for possible options. * @return \NumberFormatter */ - protected static function _setAttributes(NumberFormatter $formatter, array $options = []) + protected static function _setAttributes(NumberFormatter $formatter, array $options = []): NumberFormatter { if (isset($options['places'])) { $formatter->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, $options['places']); @@ -354,6 +433,10 @@ protected static function _setAttributes(NumberFormatter $formatter, array $opti $formatter->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, $options['precision']); } + if (isset($options['roundingMode'])) { + $formatter->setAttribute(NumberFormatter::ROUNDING_MODE, $options['roundingMode']); + } + if (!empty($options['pattern'])) { $formatter->setPattern($options['pattern']); } @@ -380,12 +463,12 @@ protected static function _setAttributes(NumberFormatter $formatter, array $opti * * For all other options see formatter(). * - * @param int|float $value An integer - * @param array $options An array with options. + * @param float|int $value An integer + * @param array $options An array with options. * @return string */ - public static function ordinal($value, array $options = []) + public static function ordinal(float|int $value, array $options = []): string { - return static::formatter(['type' => NumberFormatter::ORDINAL] + $options)->format($value); + return (string)static::formatter(['type' => NumberFormatter::ORDINAL] + $options)->format($value); } } diff --git a/src/I18n/Package.php b/src/I18n/Package.php new file mode 100644 index 00000000000..f9209c2e2c2 --- /dev/null +++ b/src/I18n/Package.php @@ -0,0 +1,160 @@ + + */ + protected array $messages = []; + + /** + * The name of a fallback package to use when a message key does not + * exist. + * + * @var string|null + */ + protected ?string $fallback = null; + + /** + * The name of the formatter to use when formatting translated messages. + * + * @var string + */ + protected string $formatter; + + /** + * Constructor. + * + * @param string $formatter The name of the formatter to use. + * @param string|null $fallback The name of the fallback package to use. + * @param array $messages The messages in this package. + */ + public function __construct( + string $formatter = 'default', + ?string $fallback = null, + array $messages = [], + ) { + $this->formatter = $formatter; + $this->fallback = $fallback; + $this->messages = $messages; + } + + /** + * Sets the messages for this package. + * + * @param array $messages The messages for this package. + * @return void + */ + public function setMessages(array $messages): void + { + $this->messages = $messages; + } + + /** + * Adds one message for this package. + * + * @param string $key the key of the message + * @param array|string $message the actual message + * @return void + */ + public function addMessage(string $key, array|string $message): void + { + $this->messages[$key] = $message; + } + + /** + * Adds new messages for this package. + * + * @param array $messages The messages to add in this package. + * @return void + */ + public function addMessages(array $messages): void + { + $this->messages = array_merge($this->messages, $messages); + } + + /** + * Gets the messages for this package. + * + * @return array + */ + public function getMessages(): array + { + return $this->messages; + } + + /** + * Gets the message of the given key for this package. + * + * @param string $key the key of the message to return + * @return array|string|false The message translation, or false if not found. + */ + public function getMessage(string $key): array|string|false + { + return $this->messages[$key] ?? false; + } + + /** + * Sets the formatter name for this package. + * + * @param string $formatter The formatter name for this package. + * @return void + */ + public function setFormatter(string $formatter): void + { + $this->formatter = $formatter; + } + + /** + * Gets the formatter name for this package. + * + * @return string + */ + public function getFormatter(): string + { + return $this->formatter; + } + + /** + * Sets the fallback package name. + * + * @param string|null $fallback The fallback package name. + * @return void + */ + public function setFallback(?string $fallback): void + { + $this->fallback = $fallback; + } + + /** + * Gets the fallback package name. + * + * @return string|null + */ + public function getFallback(): ?string + { + return $this->fallback; + } +} diff --git a/src/I18n/PackageLocator.php b/src/I18n/PackageLocator.php new file mode 100644 index 00000000000..7d26d5a2efa --- /dev/null +++ b/src/I18n/PackageLocator.php @@ -0,0 +1,112 @@ +> + */ + protected array $registry = []; + + /** + * Tracks whether a registry entry has been converted from a + * callable to a Package object. + * + * @var array> + */ + protected array $converted = []; + + /** + * Constructor. + * + * @param array> $registry A registry of packages. + * @see PackageLocator::$registry + */ + public function __construct(array $registry = []) + { + foreach ($registry as $name => $locales) { + foreach ($locales as $locale => $spec) { + $this->set($name, $locale, $spec); + } + } + } + + /** + * Sets a Package loader. + * + * @param string $name The package name. + * @param string $locale The locale for the package. + * @param \Cake\I18n\Package|callable $spec A callable that returns a package or Package instance. + * @return void + */ + public function set(string $name, string $locale, Package|callable $spec): void + { + $this->registry[$name][$locale] = $spec; + $this->converted[$name][$locale] = $spec instanceof Package; + } + + /** + * Gets a Package object. + * + * @param string $name The package name. + * @param string $locale The locale for the package. + * @return \Cake\I18n\Package + */ + public function get(string $name, string $locale): Package + { + if (!isset($this->registry[$name][$locale])) { + throw new I18nException(sprintf('Package `%s` with locale `%s` is not registered.', $name, $locale)); + } + + if (!$this->converted[$name][$locale]) { + $func = $this->registry[$name][$locale]; + assert(is_callable($func)); + $this->registry[$name][$locale] = $func(); + $this->converted[$name][$locale] = true; + } + + /** @var \Cake\I18n\Package */ + return $this->registry[$name][$locale]; + } + + /** + * Check if a Package object for given name and locale exists in registry. + * + * @param string $name The package name. + * @param string $locale The locale for the package. + * @return bool + */ + public function has(string $name, string $locale): bool + { + return isset($this->registry[$name][$locale]); + } +} diff --git a/src/I18n/Parser/MoFileParser.php b/src/I18n/Parser/MoFileParser.php index c8037e077d8..52d228ddd4e 100644 --- a/src/I18n/Parser/MoFileParser.php +++ b/src/I18n/Parser/MoFileParser.php @@ -1,4 +1,6 @@ _readLong($stream, $isBigEndian); + if ($length < 1) { + throw new CakeException('Length must be > 0'); + } + $offset = $this->_readLong($stream, $isBigEndian); fseek($stream, $offset); - $translated = fread($stream, $length); + $translated = (string)fread($stream, $length); - if ($pluralId !== null || strpos($translated, "\000") !== false) { + if ($pluralId !== null || str_contains($translated, "\000")) { $translated = explode("\000", $translated); - $plurals = $pluralId !== null ? array_map('stripcslashes', $translated) : null; + $plurals = $pluralId !== null ? $translated : null; $translated = $translated[0]; } - $singular = stripcslashes($translated); + $singular = $translated; if ($context !== null) { $messages[$singularId]['_context'][$context] = $singular; if ($pluralId !== null) { @@ -134,9 +142,9 @@ public function parse($resource) continue; } - $messages[$singularId] = $singular; + $messages[$singularId]['_context'][''] = $singular; if ($pluralId !== null) { - $messages[$pluralId] = $plurals; + $messages[$pluralId]['_context'][''] = $plurals; } } @@ -146,17 +154,18 @@ public function parse($resource) } /** - * Reads an unsigned long from stream respecting endianess. + * Reads an unsigned long from stream respecting endianness. * * @param resource $stream The File being read. - * @param bool $isBigEndian Whether or not the current platform is Big Endian + * @param bool $isBigEndian Whether the current platform is Big Endian * @return int */ - protected function _readLong($stream, $isBigEndian) + protected function _readLong($stream, bool $isBigEndian): int { - $result = unpack($isBigEndian ? 'N1' : 'V1', fread($stream, 4)); + /** @var array $result */ + $result = unpack($isBigEndian ? 'N1' : 'V1', (string)fread($stream, 4)); $result = current($result); - return (int)substr($result, -8); + return (int)substr((string)$result, -8); } } diff --git a/src/I18n/Parser/PoFileParser.php b/src/I18n/Parser/PoFileParser.php index b54ca32d845..8298db0f6ee 100644 --- a/src/I18n/Parser/PoFileParser.php +++ b/src/I18n/Parser/PoFileParser.php @@ -1,4 +1,6 @@ [], - 'translated' => null + 'translated' => null, ]; $messages = []; $item = $defaults; + /** @var array $stage */ + $stage = []; while ($line = fgets($stream)) { $line = trim($line); @@ -89,28 +95,41 @@ public function parse($resource) // Whitespace indicated current item is done $this->_addMessage($messages, $item); $item = $defaults; - } elseif (substr($line, 0, 7) === 'msgid "') { + $stage = []; + } elseif (str_starts_with($line, 'msgid "')) { // We start a new msg so save previous $this->_addMessage($messages, $item); $item['ids']['singular'] = substr($line, 7, -1); - } elseif (substr($line, 0, 8) === 'msgstr "') { + $stage = ['ids', 'singular']; + } elseif (str_starts_with($line, 'msgstr "')) { $item['translated'] = substr($line, 8, -1); - } elseif (substr($line, 0, 9) === 'msgctxt "') { + $stage = ['translated']; + } elseif (str_starts_with($line, 'msgctxt "')) { $item['context'] = substr($line, 9, -1); + $stage = ['context']; } elseif ($line[0] === '"') { - $continues = isset($item['translated']) ? 'translated' : 'ids'; - - if (is_array($item[$continues])) { - end($item[$continues]); - $item[$continues][key($item[$continues])] .= substr($line, 1, -1); - } else { - $item[$continues] .= substr($line, 1, -1); + switch (count($stage)) { + case 2: + assert(isset($stage[0])); + assert(isset($stage[1])); + $item[$stage[0]][$stage[1]] .= substr($line, 1, -1); + break; + + case 1: + assert(isset($stage[0])); + $item[$stage[0]] .= substr($line, 1, -1); + break; } - } elseif (substr($line, 0, 14) === 'msgid_plural "') { + } elseif (str_starts_with($line, 'msgid_plural "')) { $item['ids']['plural'] = substr($line, 14, -1); - } elseif (substr($line, 0, 7) === 'msgstr[') { + $stage = ['ids', 'plural']; + } elseif (str_starts_with($line, 'msgstr[')) { $size = strpos($line, ']'); - $item['translated'][(int)substr($line, 7, 1)] = substr($line, $size + 3, -1); + assert(is_int($size)); + + $row = (int)substr($line, 7, 1); + $item['translated'][$row] = substr($line, $size + 3, -1); + $stage = ['translated', $row]; } } // save last item @@ -127,21 +146,21 @@ public function parse($resource) * @param array $item The current item being inspected * @return void */ - protected function _addMessage(array &$messages, array $item) + protected function _addMessage(array &$messages, array $item): void { if (empty($item['ids']['singular']) && empty($item['ids']['plural'])) { return; } $singular = stripcslashes($item['ids']['singular']); - $context = isset($item['context']) ? $item['context'] : null; + $context = $item['context'] ?? null; $translation = $item['translated']; if (is_array($translation)) { $translation = $translation[0]; } - $translation = stripcslashes($translation); + $translation = stripcslashes((string)$translation); if ($context !== null && !isset($messages[$singular]['_context'][$context])) { $messages[$singular]['_context'][$context] = $translation; @@ -155,8 +174,7 @@ protected function _addMessage(array &$messages, array $item) ksort($plurals); // Make sure every index is filled. - end($plurals); - $count = key($plurals); + $count = (int)array_key_last($plurals); // Fill missing spots with an empty string. $empties = array_fill(0, $count + 1, ''); diff --git a/src/I18n/PluralRules.php b/src/I18n/PluralRules.php index 8a420ce7c41..014a482d1ab 100644 --- a/src/I18n/PluralRules.php +++ b/src/I18n/PluralRules.php @@ -1,4 +1,6 @@ plurals group used to determine * which plural rules apply to the language * - * @var array + * @var array */ - protected static $_rulesMap = [ + protected static array $_rulesMap = [ 'af' => 1, 'am' => 2, 'ar' => 13, @@ -47,14 +55,14 @@ class PluralRules 'el' => 1, 'en' => 1, 'eo' => 1, - 'es' => 1, + 'es' => 17, 'et' => 1, 'eu' => 1, 'fa' => 1, 'fi' => 1, 'fil' => 2, 'fo' => 1, - 'fr' => 2, + 'fr' => 16, 'fur' => 1, 'fy' => 1, 'ga' => 5, @@ -68,7 +76,7 @@ class PluralRules 'hu' => 1, 'id' => 0, 'is' => 15, - 'it' => 1, + 'it' => 17, 'ja' => 0, 'jv' => 0, 'ka' => 0, @@ -100,8 +108,8 @@ class PluralRules 'pap' => 1, 'pl' => 11, 'ps' => 1, - 'pt_pt' => 2, - 'pt' => 1, + 'pt_PT' => 17, + 'pt' => 16, 'ro' => 12, 'ru' => 3, 'sk' => 4, @@ -116,7 +124,7 @@ class PluralRules 'th' => 0, 'ti' => 2, 'tk' => 1, - 'tr' => 0, + 'tr' => 1, 'uk' => 3, 'ur' => 1, 'vi' => 0, @@ -125,76 +133,106 @@ class PluralRules 'zu' => 1, ]; + /** + * A map of locale => function that overrides the above map. + * Such functions directly resolve the plural form number. + * + * @var array + */ + protected static array $callableRulesMap = []; + /** * Returns the plural form number for the passed locale corresponding * to the countable provided in $n. * * @param string $locale The locale to get the rule calculated for. - * @param int|float $n The number to apply the rules to. + * @param int $n The number to apply the rules to. * @return int The plural rule number that should be used. - * @link http://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html - * @link https://developer.mozilla.org/en-US/docs/Mozilla/Localization/Localization_and_Plurals#List_of_Plural_Rules + * @link https://php-gettext.github.io/Languages/#47 */ - public static function calculate($locale, $n) + public static function calculate(string $locale, int $n): int { - $locale = strtolower($locale); + $locale = Locale::canonicalize($locale); - if (!isset(static::$_rulesMap[$locale])) { + if ($locale === null) { + throw new InvalidArgumentException('Invalid locale provided'); + } + + if (!isset(static::$_rulesMap[$locale]) && !isset(static::$callableRulesMap[$locale])) { $locale = explode('_', $locale)[0]; } + if (isset(static::$callableRulesMap[$locale])) { + return static::$callableRulesMap[$locale]($n); + } + if (!isset(static::$_rulesMap[$locale])) { return 0; } - switch (static::$_rulesMap[$locale]) { - case 0: - return 0; - case 1: - return $n == 1 ? 0 : 1; - case 2: - return $n > 1 ? 1 : 0; - case 3: - return $n % 10 == 1 && $n % 100 != 11 ? 0 : - (($n % 10 >= 2 && $n % 10 <= 4) && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2); - case 4: - return $n == 1 ? 0 : - ($n >= 2 && $n <= 4 ? 1 : 2); - case 5: - return $n == 1 ? 0 : - ($n == 2 ? 1 : ($n < 7 ? 2 : ($n < 11 ? 3 : 4))); - case 6: - return $n % 10 == 1 && $n % 100 != 11 ? 0 : - ($n % 10 >= 2 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2); - case 7: - return $n % 100 == 1 ? 1 : - ($n % 100 == 2 ? 2 : ($n % 100 == 3 || $n % 100 == 4 ? 3 : 0)); - case 8: - return $n % 10 == 1 ? 0 : ($n % 10 == 2 ? 1 : 2); - case 9: - return $n == 1 ? 0 : - ($n == 0 || ($n % 100 > 0 && $n % 100 <= 10) ? 1 : - ($n % 100 > 10 && $n % 100 < 20 ? 2 : 3)); - case 10: - return $n % 10 == 1 && $n % 100 != 11 ? 0 : ($n != 0 ? 1 : 2); - case 11: - return $n == 1 ? 0 : - ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2); - case 12: - return $n == 1 ? 0 : - ($n == 0 || $n % 100 > 0 && $n % 100 < 20 ? 1 : 2); - case 13: - return $n == 0 ? 0 : - ($n == 1 ? 1 : - ($n == 2 ? 2 : + return match (static::$_rulesMap[$locale]) { + 0 => 0, + 1 => $n === 1 ? 0 : 1, + 2 => $n > 1 ? 1 : 0, + 3 => $n % 10 === 1 && $n % 100 !== 11 ? 0 : + (($n % 10 >= 2 && $n % 10 <= 4) && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2), + 4 => $n === 1 ? 0 : + ($n >= 2 && $n <= 4 ? 1 : 2), + 5 => $n === 1 ? 0 : + ($n === 2 ? 1 : ($n < 7 ? 2 : ($n < 11 ? 3 : 4))), + 6 => $n % 10 === 1 && $n % 100 !== 11 ? 0 : + ($n % 10 >= 2 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2), + 7 => $n % 100 === 1 ? 1 : + ($n % 100 === 2 ? 2 : ($n % 100 === 3 || $n % 100 === 4 ? 3 : 0)), + 8 => $n % 10 === 1 ? 0 : ($n % 10 === 2 ? 1 : 2), + 9 => $n === 1 ? 0 : + ($n === 0 || ($n % 100 > 0 && $n % 100 <= 10) ? 1 : + ($n % 100 > 10 && $n % 100 < 20 ? 2 : 3)), + 10 => $n % 10 === 1 && $n % 100 !== 11 ? 0 : ($n !== 0 ? 1 : 2), + 11 => $n === 1 ? 0 : + ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2), + 12 => $n === 1 ? 0 : + ($n === 0 || $n % 100 > 0 && $n % 100 < 20 ? 1 : 2), + 13 => $n === 0 ? 0 : + ($n === 1 ? 1 : + ($n === 2 ? 2 : ($n % 100 >= 3 && $n % 100 <= 10 ? 3 : - ($n % 100 >= 11 ? 4 : 5)))); - case 14: - return $n == 1 ? 0 : - ($n == 2 ? 1 : - ($n != 8 && $n != 11 ? 2 : 3)); - case 15: - return ($n % 10 != 1 || $n % 100 == 11) ? 1 : 0; + ($n % 100 >= 11 ? 4 : 5)))), + 14 => $n === 1 ? 0 : + ($n === 2 ? 1 : + ($n !== 8 && $n !== 11 ? 2 : 3)), + 15 => $n % 10 !== 1 || $n % 100 === 11 ? 1 : 0, + 16 => $n === 0 || $n === 1 ? 0 : ($n % 1000000 === 0 ? 1 : 2), + 17 => $n === 1 ? 0 : ($n !== 0 && $n % 1000000 === 0 ? 1 : 2), + default => throw new CakeException('Unable to find plural rule number.'), + }; + } + + /** + * Set a custom plural rule. + * + * @param string $locale The locale on which the plural rule is applied. + * @param \Closure(int): int $resolver A function that takes a plural number and + * returns the plural form number for $locale. + * @throws \InvalidArgumentException If $locale is invalid. + * @return void + */ + public static function setRule(string $locale, Closure $resolver): void + { + $canonicalLocale = Locale::canonicalize($locale); + + if ($canonicalLocale === null) { + throw new InvalidArgumentException(sprintf('Invalid locale `%s` provided', $locale)); } + + static::$callableRulesMap[$canonicalLocale] = $resolver; + } + + /** + * Remove any custom rule previously set. + */ + public static function resetRules(): void + { + static::$callableRulesMap = []; } } diff --git a/src/I18n/README.md b/src/I18n/README.md index 4c961163106..41a9dd1a426 100644 --- a/src/I18n/README.md +++ b/src/I18n/README.md @@ -29,8 +29,9 @@ I18n::setLocale('en_US'); use Cake\Core\Configure; Configure::write('App.paths.locales', ['/path/with/trailing/slash/']); +``` -Please refer to the [CakePHP Manual](https://book.cakephp.org/3.0/en/core-libraries/internationalization-and-localization.html#language-files) for details +Please refer to the [CakePHP Manual](https://book.cakephp.org/5/en/core-libraries/internationalization-and-localization.html#language-files) for details about expected folder structure and file naming. ### Translating a Message @@ -49,7 +50,7 @@ Hi Charles, your balance on the Jan 13, 2014, 11:12 AM is $ 1,354.37 ```php use Cake\I18n\I18n; -use Aura\Intl\Package; +use Cake\I18n\Package; I18n::translator('animals', 'fr_FR', function () { $package = new Package( @@ -91,12 +92,12 @@ echo Number::currency(123456.7890, 'EUR'); ## Documentation Please make sure you check the [official I18n -documentation](https://book.cakephp.org/3.0/en/core-libraries/internationalization-and-localization.html). +documentation](https://book.cakephp.org/5/en/core-libraries/internationalization-and-localization.html). The [documentation for the Time -class](https://book.cakephp.org/3.0/en/core-libraries/time.html) contains +class](https://book.cakephp.org/5/en/core-libraries/time.html) contains instructions on how to configure and output time strings for selected locales. The [documentation for the Number -class](https://book.cakephp.org/3.0/en/core-libraries/number.html) shows how to +class](https://book.cakephp.org/5/en/core-libraries/number.html) shows how to use the `Number` class for displaying numbers in specific locales. diff --git a/src/I18n/RelativeTimeFormatter.php b/src/I18n/RelativeTimeFormatter.php index 8d2d7d58f54..56fd9b96a2b 100644 --- a/src/I18n/RelativeTimeFormatter.php +++ b/src/I18n/RelativeTimeFormatter.php @@ -1,4 +1,6 @@ now($date->getTimezone()); + public function diffForHumans( + ChronosDate|DateTimeInterface $first, + ChronosDate|DateTimeInterface|null $second = null, + bool $absolute = false, + ): string { + $isNow = $second === null; + if ($second === null) { + if ($first instanceof ChronosDate) { + $second = Date::now(); + } else { + $second = DateTime::now($first->getTimezone()); + } } - $diffInterval = $date->diff($other); + assert( + ($first instanceof ChronosDate && $second instanceof ChronosDate) || + ($first instanceof DateTimeInterface && $second instanceof DateTimeInterface), + ); + + $diffInterval = $first->diff($second); switch (true) { - case ($diffInterval->y > 0): + case $diffInterval->y > 0: $count = $diffInterval->y; $message = __dn('cake', '{0} year', '{0} years', $count, $count); break; - case ($diffInterval->m > 0): + case $diffInterval->m > 0: $count = $diffInterval->m; $message = __dn('cake', '{0} month', '{0} months', $count, $count); break; - case ($diffInterval->d > 0): + case $diffInterval->d > 0: $count = $diffInterval->d; - if ($count >= ChronosInterface::DAYS_PER_WEEK) { - $count = (int)($count / ChronosInterface::DAYS_PER_WEEK); + if ($count >= DateTime::DAYS_PER_WEEK) { + $count = (int)($count / DateTime::DAYS_PER_WEEK); $message = __dn('cake', '{0} week', '{0} weeks', $count, $count); } else { $message = __dn('cake', '{0} day', '{0} days', $count, $count); } break; - case ($diffInterval->h > 0): + case $diffInterval->h > 0: $count = $diffInterval->h; $message = __dn('cake', '{0} hour', '{0} hours', $count, $count); break; - case ($diffInterval->i > 0): + case $diffInterval->i > 0: $count = $diffInterval->i; $message = __dn('cake', '{0} minute', '{0} minutes', $count, $count); break; @@ -84,22 +99,24 @@ public function diffForHumans(ChronosInterface $date, ChronosInterface $other = } /** - * Format a into a relative timestring. + * Format a time into a relative timestring. * - * @param \DateTimeInterface $time The time instance to format. - * @param array $options Array of options. + * @param \Cake\I18n\DateTime|\Cake\I18n\Date $time The time instance to format. + * @param array $options Array of options. * @return string Relative time string. - * @see \Cake\I18n\Time::timeAgoInWords() + * @see \Cake\I18n\DateTime::timeAgoInWords() */ - public function timeAgoInWords(DateTimeInterface $time, array $options = []) + public function timeAgoInWords(DateTime|Date $time, array $options = []): string { - $options = $this->_options($options, FrozenTime::class); - if ($options['timezone'] && $time instanceof ChronosInterface) { - $time = $time->timezone($options['timezone']); + $options = $this->_options($options, DateTime::class); + if ($time instanceof DateTime && $options['timezone']) { + $time = $time->setTimezone($options['timezone']); } - $now = $options['from']->format('U'); - $inSeconds = $time->format('U'); + /** @var \Cake\Chronos\Chronos $from */ + $from = $options['from']; + $now = (int)$from->format('U'); + $inSeconds = (int)$time->format('U'); $backwards = ($inSeconds > $now); $futureTime = $now; @@ -114,12 +131,12 @@ public function timeAgoInWords(DateTimeInterface $time, array $options = []) return __d('cake', 'just now', 'just now'); } - if ($diff > abs($now - (new FrozenTime($options['end']))->format('U'))) { + if ($diff > abs($now - (int)(new DateTime($options['end']))->format('U'))) { return sprintf($options['absoluteString'], $time->i18nFormat($options['format'])); } $diffData = $this->_diffData($futureTime, $pastTime, $backwards, $options); - list($fNum, $fWord, $years, $months, $weeks, $days, $hours, $minutes, $seconds) = array_values($diffData); + [$fNum, $fWord, $years, $months, $weeks, $days, $hours, $minutes, $seconds] = array_values($diffData); $relativeDate = []; if ($fNum >= 1 && $years > 0) { @@ -154,7 +171,7 @@ public function timeAgoInWords(DateTimeInterface $time, array $options = []) 'day' => __d('cake', 'about a day ago'), 'week' => __d('cake', 'about a week ago'), 'month' => __d('cake', 'about a month ago'), - 'year' => __d('cake', 'about a year ago') + 'year' => __d('cake', 'about a year ago'), ]; return $relativeDate ? sprintf($options['relativeString'], $relativeDate) : $aboutAgo[$fWord]; @@ -171,7 +188,7 @@ public function timeAgoInWords(DateTimeInterface $time, array $options = []) 'day' => __d('cake', 'in about a day'), 'week' => __d('cake', 'in about a week'), 'month' => __d('cake', 'in about a month'), - 'year' => __d('cake', 'in about a year') + 'year' => __d('cake', 'in about a year'), ]; return $aboutIn[$fWord]; @@ -180,52 +197,74 @@ public function timeAgoInWords(DateTimeInterface $time, array $options = []) /** * Calculate the data needed to format a relative difference string. * - * @param \DateTime $futureTime The time from the future. - * @param \DateTime $pastTime The time from the past. - * @param bool $backwards Whether or not the difference was backwards. - * @param array $options An array of options. + * @param string|int $futureTime The timestamp from the future. + * @param string|int $pastTime The timestamp from the past. + * @param bool $backwards Whether the difference was backwards. + * @param array $options An array of options. * @return array An array of values. */ - protected function _diffData($futureTime, $pastTime, $backwards, $options) + protected function _diffData(string|int $futureTime, string|int $pastTime, bool $backwards, array $options): array { + $futureTime = (int)$futureTime; + $pastTime = (int)$pastTime; $diff = $futureTime - $pastTime; // If more than a week, then take into account the length of months if ($diff >= 604800) { - list($future['H'], $future['i'], $future['s'], $future['d'], $future['m'], $future['Y']) = explode('/', date('H/i/s/d/m/Y', $futureTime)); - - list($past['H'], $past['i'], $past['s'], $past['d'], $past['m'], $past['Y']) = explode('/', date('H/i/s/d/m/Y', $pastTime)); - $weeks = $days = $hours = $minutes = $seconds = 0; - - $years = $future['Y'] - $past['Y']; - $months = $future['m'] + ((12 * $years) - $past['m']); + $future = []; + [ + $future['H'], + $future['i'], + $future['s'], + $future['d'], + $future['m'], + $future['Y'], + ] = explode('/', date('H/i/s/d/m/Y', $futureTime)); + + $past = []; + [ + $past['H'], + $past['i'], + $past['s'], + $past['d'], + $past['m'], + $past['Y'], + ] = explode('/', date('H/i/s/d/m/Y', $pastTime)); + $weeks = 0; + $days = 0; + $hours = 0; + $minutes = 0; + $seconds = 0; + + $years = (int)$future['Y'] - (int)$past['Y']; + $months = (int)$future['m'] + (12 * $years) - (int)$past['m']; if ($months >= 12) { $years = floor($months / 12); - $months -= ($years * 12); + $months -= $years * 12; } - if ($future['m'] < $past['m'] && $future['Y'] - $past['Y'] === 1) { + if ((int)$future['m'] < (int)$past['m'] && (int)$future['Y'] - (int)$past['Y'] === 1) { $years--; } - if ($future['d'] >= $past['d']) { - $days = $future['d'] - $past['d']; + if ((int)$future['d'] >= (int)$past['d']) { + $days = (int)$future['d'] - (int)$past['d']; } else { - $daysInPastMonth = date('t', $pastTime); - $daysInFutureMonth = date('t', mktime(0, 0, 0, $future['m'] - 1, 1, $future['Y'])); + $daysInPastMonth = (int)date('t', $pastTime); + $daysInFutureMonth = (int)date('t', (int)mktime(0, 0, 0, (int)$future['m'] - 1, 1, (int)$future['Y'])); if (!$backwards) { - $days = ($daysInPastMonth - $past['d']) + $future['d']; + $days = $daysInPastMonth - (int)$past['d'] + (int)$future['d']; } else { - $days = ($daysInFutureMonth - $past['d']) + $future['d']; + $days = $daysInFutureMonth - (int)$past['d'] + (int)$future['d']; } - if ($future['m'] != $past['m']) { + if ($future['m'] !== $past['m']) { $months--; } } - if (!$months && $years >= 1 && $diff < ($years * 31536000)) { + if (!$months && $years >= 1 && $diff < $years * 31536000) { $months = 11; $years--; } @@ -237,19 +276,21 @@ protected function _diffData($futureTime, $pastTime, $backwards, $options) if ($days >= 7) { $weeks = floor($days / 7); - $days -= ($weeks * 7); + $days -= $weeks * 7; } } else { - $years = $months = $weeks = 0; + $years = 0; + $months = 0; + $weeks = 0; $days = floor($diff / 86400); - $diff -= ($days * 86400); + $diff -= $days * 86400; $hours = floor($diff / 3600); - $diff -= ($hours * 3600); + $diff -= $hours * 3600; $minutes = floor($diff / 60); - $diff -= ($minutes * 60); + $diff -= $minutes * 60; $seconds = $diff; } @@ -268,28 +309,44 @@ protected function _diffData($futureTime, $pastTime, $backwards, $options) $fWord = $options['accuracy']['minute']; } - $fNum = str_replace(['year', 'month', 'week', 'day', 'hour', 'minute', 'second'], [1, 2, 3, 4, 5, 6, 7], $fWord); - - return [$fNum, $fWord, $years, $months, $weeks, $days, $hours, $minutes, $seconds]; + $fNum = str_replace( + ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'], + ['1', '2', '3', '4', '5', '6', '7'], + $fWord, + ); + + return [ + $fNum, + $fWord, + (int)$years, + (int)$months, + (int)$weeks, + (int)$days, + (int)$hours, + (int)$minutes, + (int)$seconds, + ]; } /** - * Format a into a relative date string. + * Format a date into a relative date string. * - * @param \DateTimeInterface $date The date to format. - * @param array $options Array of options. + * @param \Cake\I18n\DateTime|\Cake\I18n\Date $date The date to format. + * @param array $options Array of options. * @return string Relative date string. * @see \Cake\I18n\Date::timeAgoInWords() */ - public function dateAgoInWords(DateTimeInterface $date, array $options = []) + public function dateAgoInWords(DateTime|Date $date, array $options = []): string { - $options = $this->_options($options, FrozenDate::class); - if ($options['timezone'] && $date instanceof ChronosInterface) { - $date = $date->timezone($options['timezone']); + $options = $this->_options($options, Date::class); + if ($date instanceof DateTime && $options['timezone']) { + $date = $date->setTimezone($options['timezone']); } - $now = $options['from']->format('U'); - $inSeconds = $date->format('U'); + /** @var \Cake\Chronos\Chronos $from */ + $from = $options['from']; + $now = (int)$from->format('U'); + $inSeconds = (int)$date->format('U'); $backwards = ($inSeconds > $now); $futureTime = $now; @@ -304,12 +361,12 @@ public function dateAgoInWords(DateTimeInterface $date, array $options = []) return __d('cake', 'today'); } - if ($diff > abs($now - (new FrozenDate($options['end']))->format('U'))) { + if ($diff > abs($now - (int)(new Date($options['end']))->format('U'))) { return sprintf($options['absoluteString'], $date->i18nFormat($options['format'])); } $diffData = $this->_diffData($futureTime, $pastTime, $backwards, $options); - list($fNum, $fWord, $years, $months, $weeks, $days) = array_values($diffData); + [$fNum, $fWord, $years, $months, $weeks, $days] = array_values($diffData); $relativeDate = []; if ($fNum >= 1 && $years > 0) { @@ -332,7 +389,7 @@ public function dateAgoInWords(DateTimeInterface $date, array $options = []) 'day' => __d('cake', 'about a day ago'), 'week' => __d('cake', 'about a week ago'), 'month' => __d('cake', 'about a month ago'), - 'year' => __d('cake', 'about a year ago') + 'year' => __d('cake', 'about a year ago'), ]; return $relativeDate ? sprintf($options['relativeString'], $relativeDate) : $aboutAgo[$fWord]; @@ -346,7 +403,7 @@ public function dateAgoInWords(DateTimeInterface $date, array $options = []) 'day' => __d('cake', 'in about a day'), 'week' => __d('cake', 'in about a week'), 'month' => __d('cake', 'in about a month'), - 'year' => __d('cake', 'in about a year') + 'year' => __d('cake', 'in about a year'), ]; return $aboutIn[$fWord]; @@ -355,11 +412,11 @@ public function dateAgoInWords(DateTimeInterface $date, array $options = []) /** * Build the options for relative date formatting. * - * @param array $options The options provided by the user. - * @param string $class The class name to use for defaults. - * @return array Options with defaults applied. + * @param array $options The options provided by the user. + * @param class-string<\Cake\I18n\Date>|class-string<\Cake\I18n\DateTime> $class The class name to use for defaults. + * @return array Options with defaults applied. */ - protected function _options($options, $class) + protected function _options(array $options, string $class): array { $options += [ 'from' => $class::now(), diff --git a/src/I18n/Time.php b/src/I18n/Time.php index 4cfdaee6290..8913e2c64b3 100644 --- a/src/I18n/Time.php +++ b/src/I18n/Time.php @@ -1,4 +1,6 @@ 'day', - 'month' => 'day', - 'week' => 'day', - 'day' => 'hour', - 'hour' => 'minute', - 'minute' => 'minute', - 'second' => 'second', - ]; + public static string|int $niceFormat = IntlDateFormatter::MEDIUM; /** - * The end of relative time telling + * Sets the default format used when type converting instances of this type to string * - * @var string - * @see \Cake\I18n\Time::timeAgoInWords() - */ - public static $wordEnd = '+1 month'; - - /** - * serialise the value as a Unix Timestamp + * The format should be either the formatting constants from IntlDateFormatter as + * described in (https://secure.php.net/manual/en/class.intldateformatter.php) or a pattern + * as specified in (https://unicode-org.github.io/icu-docs/apidoc/released/icu4c/classSimpleDateFormat.html#details) * - * @var string + * @param string|int $format Format. + * @return void + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint */ - const UNIX_TIMESTAMP_FORMAT = 'unixTimestampFormat'; - - /** - * {@inheritDoc} - */ - public function __construct($time = null, $tz = null) + public static function setToStringFormat($format): void { - if ($time instanceof DateTimeInterface) { - $tz = $time->getTimezone(); - $time = $time->format('Y-m-d H:i:s'); - } - - if (is_numeric($time)) { - $time = '@' . $time; - } - parent::__construct($time, $tz); + static::$_toStringFormat = $format; } /** - * Returns a nicely formatted date string for this object. + * Resets the format used to the default when converting an instance of this type to + * a string * - * The format to be used is stored in the static property `Time::niceFormat`. - * - * @param string|\DateTimeZone|null $timezone Timezone string or DateTimeZone object - * in which the date will be displayed. The timezone stored for this object will not - * be changed. - * @param string|null $locale The locale name in which the date should be displayed (e.g. pt-BR) - * @return string Formatted date string + * @return void */ - public function nice($timezone = null, $locale = null) + public static function resetToStringFormat(): void { - return $this->i18nFormat(static::$niceFormat, $timezone, $locale); + static::setToStringFormat(IntlDateFormatter::SHORT); } /** - * Returns true if this object represents a date within the current week + * Sets the default format used when converting this object to JSON * - * @return bool - */ - public function isThisWeek() - { - return static::now($this->getTimezone())->format('W o') == $this->format('W o'); - } - - /** - * Returns true if this object represents a date within the current month + * The format should be either the formatting constants from IntlDateFormatter as + * described in (https://secure.php.net/manual/en/class.intldateformatter.php) or a pattern + * as specified in (http://www.icu-project.org/apiref/icu4c/classSimpleDateFormat.html#details) * - * @return bool - */ - public function isThisMonth() - { - return static::now($this->getTimezone())->format('m Y') == $this->format('m Y'); - } - - /** - * Returns true if this object represents a date within the current year + * Alternatively, the format can provide a callback. In this case, the callback + * can receive this object and return a formatted string. * - * @return bool + * @see \Cake\I18n\Time::i18nFormat() + * @param \Closure|string|int $format Format. + * @return void */ - public function isThisYear() + public static function setJsonEncodeFormat(Closure|string|int $format): void { - return static::now($this->getTimezone())->format('Y') == $this->format('Y'); + static::$_jsonEncodeFormat = $format; } /** - * Returns the quarter + * Returns a new Time object after parsing the provided $time string based on + * the passed or configured date time format. This method is locale dependent, + * Any string passed to this function will be interpreted as a locale + * dependent string. * - * @param bool $range Range. - * @return int|array 1, 2, 3, or 4 quarter of year, or array if $range true + * When no $format is provided, the IntlDateFormatter::SHORT format will be used. + * + * If it was impossible to parse the provided time, null will be returned. + * + * Example: + * + * ``` + * $time = Time::parseTime('11:23pm'); + * ``` + * + * @param string $time The time string to parse. + * @param string|int|null $format Any format accepted by IntlDateFormatter. + * @return static|null */ - public function toQuarter($range = false) + public static function parseTime(string $time, string|int|null $format = null): ?static { - $quarter = ceil($this->format('m') / 3); - if ($range === false) { - return $quarter; + $format ??= [IntlDateFormatter::NONE, IntlDateFormatter::SHORT]; + if (is_int($format)) { + $format = [IntlDateFormatter::NONE, $format]; } - $year = $this->format('Y'); - switch ($quarter) { - case 1: - return [$year . '-01-01', $year . '-03-31']; - case 2: - return [$year . '-04-01', $year . '-06-30']; - case 3: - return [$year . '-07-01', $year . '-09-30']; - case 4: - return [$year . '-10-01', $year . '-12-31']; - } + return static::_parseDateTime($time, $format); } /** - * Returns a UNIX timestamp. + * Returns a formatted string for this time object using the preferred format and + * language for the specified locale. * - * @return string UNIX timestamp - */ - public function toUnixString() - { - return $this->format('U'); - } - - /** - * Returns either a relative or a formatted absolute date depending - * on the difference between the current time and this object. + * It is possible to specify the desired format for the string to be displayed. + * You can either pass `IntlDateFormatter` constants as the first argument of this + * function, or pass a full ICU date formatting string as specified in the following + * resource: https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax. * - * ### Options: + * ### Examples * - * - `from` => another Time object representing the "now" time - * - `format` => a fall back format if the relative time is longer than the duration specified by end - * - `accuracy` => Specifies how accurate the date should be described (array) - * - year => The format if years > 0 (default "day") - * - month => The format if months > 0 (default "day") - * - week => The format if weeks > 0 (default "day") - * - day => The format if weeks > 0 (default "hour") - * - hour => The format if hours > 0 (default "minute") - * - minute => The format if minutes > 0 (default "minute") - * - second => The format if seconds > 0 (default "second") - * - `end` => The end of relative time telling - * - `relativeString` => The `printf` compatible string when outputting relative time - * - `absoluteString` => The `printf` compatible string when outputting absolute time - * - `timezone` => The user timezone the timestamp should be formatted in. + * ``` + * $time = new Time('23:10:10'); + * $time->i18nFormat(); + * $time->i18nFormat(\IntlDateFormatter::FULL); + * $time->i18nFormat("HH':'mm':'ss"); + * ``` * - * Relative dates look something like this: + * You can control the default format used through `Time::setToStringFormat()`. * - * - 3 weeks, 4 days ago - * - 15 seconds ago + * You can read about the available IntlDateFormatter constants at + * https://secure.php.net/manual/en/class.intldateformatter.php * - * Default date formatting is d/M/YY e.g: on 18/2/09. Formatting is done internally using - * `i18nFormat`, see the method for the valid formatting strings + * Should you need to use a different locale for displaying this time object, + * pass a locale string as the third parameter to this function. * - * The returned string includes 'ago' or 'on' and assumes you'll properly add a word - * like 'Posted ' before the function output. + * ### Examples * - * NOTE: If the difference is one week or more, the lowest level of accuracy is day + * ``` + * $time = new Time('2014-04-20'); + * $time->i18nFormat('de-DE'); + * $time->i18nFormat(\IntlDateFormatter::FULL, 'de-DE'); + * ``` * - * @param array $options Array of options. - * @return string Relative time string. - */ - public function timeAgoInWords(array $options = []) - { - return static::diffFormatter()->timeAgoInWords($this, $options); - } - - /** - * Get list of timezone identifiers + * You can control the default locale used through `DateTime::setDefaultLocale()`. + * If empty, the default will be taken from the `intl.default_locale` ini config. * - * @param int|string|null $filter A regex to filter identifier - * Or one of DateTimeZone class constants - * @param string|null $country A two-letter ISO 3166-1 compatible country code. - * This option is only used when $filter is set to DateTimeZone::PER_COUNTRY - * @param bool|array $options If true (default value) groups the identifiers list by primary region. - * Otherwise, an array containing `group`, `abbr`, `before`, and `after` - * keys. Setting `group` and `abbr` to true will group results and append - * timezone abbreviation in the display value. Set `before` and `after` - * to customize the abbreviation wrapper. - * @return array List of timezone identifiers - * @since 2.2 + * @param string|int|null $format Format string. + * @param string|null $locale The locale name in which the time should be displayed (e.g. pt-BR) + * @return string|int Formatted and translated time string */ - public static function listTimezones($filter = null, $country = null, $options = []) - { - if (is_bool($options)) { - $options = [ - 'group' => $options, - ]; - } - $defaults = [ - 'group' => true, - 'abbr' => false, - 'before' => ' - ', - 'after' => null, - ]; - $options += $defaults; - $group = $options['group']; - - $regex = null; - if (is_string($filter)) { - $regex = $filter; - $filter = null; - } - if ($filter === null) { - $filter = DateTimeZone::ALL; - } - $identifiers = DateTimeZone::listIdentifiers($filter, $country); - - if ($regex) { - foreach ($identifiers as $key => $tz) { - if (!preg_match($regex, $tz)) { - unset($identifiers[$key]); - } - } + public function i18nFormat( + string|int|null $format = null, + ?string $locale = null, + ): string|int { + if ($format === DateTime::UNIX_TIMESTAMP_FORMAT) { + throw new InvalidArgumentException('UNIT_TIMESTAMP_FORMAT is not supported for Time.'); } - if ($group) { - $groupedIdentifiers = []; - $now = time(); - $before = $options['before']; - $after = $options['after']; - foreach ($identifiers as $key => $tz) { - $abbr = null; - if ($options['abbr']) { - $dateTimeZone = new DateTimeZone($tz); - $trans = $dateTimeZone->getTransitions($now, $now); - $abbr = isset($trans[0]['abbr']) ? - $before . $trans[0]['abbr'] . $after : - null; - } - $item = explode('/', $tz, 2); - if (isset($item[1])) { - $groupedIdentifiers[$item[0]][$tz] = $item[1] . $abbr; - } else { - $groupedIdentifiers[$item[0]] = [$tz => $item[0] . $abbr]; - } - } - - return $groupedIdentifiers; - } + $format ??= static::$_toStringFormat; + $format = is_int($format) ? [IntlDateFormatter::NONE, $format] : $format; + $locale = $locale ?: DateTime::getDefaultLocale(); - return array_combine($identifiers, $identifiers); + return $this->_formatObject($this->toNative(), $format, $locale); } /** - * Returns true this instance will happen within the specified interval + * Returns a nicely formatted date string for this object. * - * This overridden method provides backwards compatible behavior for integers, - * or strings with trailing spaces. This behavior is *deprecated* and will be - * removed in future versions of CakePHP. + * The format to be used is stored in the static property `Time::$niceFormat`. * - * @param string|int $timeInterval the numeric value with space then time type. - * Example of valid types: 6 hours, 2 days, 1 minute. - * @return bool + * @param string|null $locale The locale name in which the date should be displayed (e.g. pt-BR) + * @return string Formatted date string */ - public function wasWithinLast($timeInterval) + public function nice(?string $locale = null): string { - $tmp = trim($timeInterval); - if (is_numeric($tmp)) { - $timeInterval = $tmp . ' days'; - } - - return parent::wasWithinLast($timeInterval); + return (string)$this->i18nFormat(static::$niceFormat, $locale); } /** - * Returns true this instance happened within the specified interval + * Returns a string that should be serialized when converting this object to JSON * - * This overridden method provides backwards compatible behavior for integers, - * or strings with trailing spaces. This behavior is *deprecated* and will be - * removed in future versions of CakePHP. - * - * @param string|int $timeInterval the numeric value with space then time type. - * Example of valid types: 6 hours, 2 days, 1 minute. - * @return bool + * @return string|int */ - public function isWithinNext($timeInterval) + public function jsonSerialize(): mixed { - $tmp = trim($timeInterval); - if (is_numeric($tmp)) { - $timeInterval = $tmp . ' days'; + if (static::$_jsonEncodeFormat instanceof Closure) { + return call_user_func(static::$_jsonEncodeFormat, $this); } - return parent::isWithinNext($timeInterval); + return $this->i18nFormat(static::$_jsonEncodeFormat); + } + + /** + * @inheritDoc + */ + public function __toString(): string + { + return (string)$this->i18nFormat(); } } diff --git a/src/I18n/Translator.php b/src/I18n/Translator.php index 880471a0317..f1e6bda1f83 100644 --- a/src/I18n/Translator.php +++ b/src/I18n/Translator.php @@ -1,39 +1,115 @@ locale = $locale; + $this->package = $package; + $this->formatter = $formatter; + $this->fallback = $fallback; + } + + /** + * Gets the message translation by its key. * + * @param string $key The message key. + * @return mixed The message translation string, or false if not found. + */ + protected function getMessage(string $key): mixed + { + $message = $this->package->getMessage($key); + if ($message) { + return $message; + } + + if ($this->fallback) { + $message = $this->fallback->getMessage($key); + if ($message) { + $this->package->addMessage($key, $message); + + return $message; + } + } + + return false; + } + + /** + * Translates the message formatting any placeholders * * @param string $key The message key. * @param array $tokensValues Token values to interpolate into the * message. * @return string The translated message with tokens replaced. */ - public function translate($key, array $tokensValues = []) + public function translate(string $key, array $tokensValues = []): string { if (isset($tokensValues['_count'])) { $message = $this->getMessage(static::PLURAL_PREFIX . $key); @@ -53,7 +129,7 @@ public function translate($key, array $tokensValues = []) } // Check for missing/invalid context - if (isset($message['_context'])) { + if (is_array($message) && isset($message['_context'])) { $message = $this->resolveContext($key, $message, $tokensValues); unset($tokensValues['_context']); } @@ -74,15 +150,22 @@ public function translate($key, array $tokensValues = []) // Resolve plural form. if (is_array($message)) { - $count = isset($tokensValues['_count']) ? $tokensValues['_count'] : 0; - $form = PluralRules::calculate($this->locale, $count); - $message = isset($message[$form]) ? $message[$form] : (string)end($message); + $count = $tokensValues['_count'] ?? 0; + $form = PluralRules::calculate($this->locale, (int)$count); + $message = $message[$form] ?? (string)end($message); } - if (strlen($message) === 0) { + if ($message === '') { $message = $key; + + // If singular haven't been translated, fallback to the key. + if (isset($tokensValues['_singular']) && $tokensValues['_count'] === 1) { + $message = $tokensValues['_singular']; + } } + unset($tokensValues['_count'], $tokensValues['_singular']); + return $this->formatter->format($this->locale, $message, $tokensValues); } @@ -90,13 +173,13 @@ public function translate($key, array $tokensValues = []) * Resolve a message's context structure. * * @param string $key The message key being handled. - * @param string|array $message The message content. + * @param array $message The message content. * @param array $vars The variables containing the `_context` key. - * @return string + * @return array|string */ - protected function resolveContext($key, $message, array $vars) + protected function resolveContext(string $key, array $message, array $vars): array|string { - $context = isset($vars['_context']) ? $vars['_context'] : null; + $context = $vars['_context'] ?? null; // No or missing context, fallback to the key/first message if ($context === null) { @@ -115,4 +198,14 @@ protected function resolveContext($key, $message, array $vars) return $message['_context'][$context]; } + + /** + * Returns the translator package + * + * @return \Cake\I18n\Package + */ + public function getPackage(): Package + { + return $this->package; + } } diff --git a/src/I18n/TranslatorFactory.php b/src/I18n/TranslatorFactory.php deleted file mode 100644 index 79c37019655..00000000000 --- a/src/I18n/TranslatorFactory.php +++ /dev/null @@ -1,63 +0,0 @@ -class; - if ($fallback !== null && get_class($fallback) !== $class) { - throw new RuntimeException(sprintf( - 'Translator fallback class %s does not match Cake\I18n\Translator, try clearing your _cake_core_ cache.', - get_class($fallback) - )); - } - - return new $class($locale, $package, $formatter, $fallback); - } -} diff --git a/src/I18n/TranslatorRegistry.php b/src/I18n/TranslatorRegistry.php index df06dc61be7..4bf67620259 100644 --- a/src/I18n/TranslatorRegistry.php +++ b/src/I18n/TranslatorRegistry.php @@ -1,4 +1,6 @@ >> */ - protected $_loaders = []; + protected array $registry = []; /** - * Fallback loader name + * Cache key prefix segment used to isolate translations, e.g. per tenant. + * + * A string is applied as-is. A Closure is invoked on every get() call and + * must return a string. An empty result disables prefixing and restores + * the legacy cache key format. + * + * @var \Closure|string + */ + protected Closure|string $cacheKeyPrefix = ''; + + /** + * The current locale code. * * @var string */ - protected $_fallbackLoader = '_fallback'; + protected string $locale; + + /** + * A package locator. + * + * @var \Cake\I18n\PackageLocator + */ + protected PackageLocator $packages; + + /** + * A formatter locator. + * + * @var \Cake\I18n\FormatterLocator + */ + protected FormatterLocator $formatters; + + /** + * A list of loader functions indexed by domain name. Loaders are + * callables that are invoked as a default for building translation + * packages where none can be found for the combination of translator + * name and locale. + * + * @var array + */ + protected array $_loaders = []; /** * The name of the default formatter to use for newly created @@ -50,130 +107,294 @@ class TranslatorRegistry extends TranslatorLocator * * @var string */ - protected $_defaultFormatter = 'default'; + protected string $_defaultFormatter = 'default'; /** * Use fallback-domain for translation loaders. * * @var bool */ - protected $_useFallback = true; + protected bool $_useFallback = true; /** * A CacheEngine object that is used to remember translator across * requests. * - * @var \Cake\Cache\CacheEngine + * @var (\Psr\SimpleCache\CacheInterface&\Cake\Cache\CacheEngineInterface)|null */ protected $_cacher; /** * Constructor. * - * @param \Aura\Intl\PackageLocator $packages The package locator. - * @param \Aura\Intl\FormatterLocator $formatters The formatter locator. - * @param \Cake\I18n\TranslatorFactory $factory A translator factory to - * create translator objects for the locale and package. + * @param \Cake\I18n\PackageLocator $packages The package locator. + * @param \Cake\I18n\FormatterLocator $formatters The formatter locator. * @param string $locale The default locale code to use. */ public function __construct( PackageLocator $packages, FormatterLocator $formatters, - TranslatorFactory $factory, - $locale + string $locale, ) { - parent::__construct($packages, $formatters, $factory, $locale); + $this->packages = $packages; + $this->formatters = $formatters; + $this->setLocale($locale); - $this->registerLoader($this->_fallbackLoader, function ($name, $locale) { - $chain = new ChainMessagesLoader([ + $this->registerLoader(static::FALLBACK_LOADER, function ($name, $locale) { + $loader = new ChainMessagesLoader([ new MessagesFileLoader($name, $locale, 'mo'), - new MessagesFileLoader($name, $locale, 'po') + new MessagesFileLoader($name, $locale, 'po'), ]); - // \Aura\Intl\Package by default uses formatter configured with key "basic". - // and we want to make sure the cake domain always uses the default formatter $formatter = $name === 'cake' ? 'default' : $this->_defaultFormatter; - $chain = function () use ($formatter, $chain) { - $package = $chain(); - $package->setFormatter($formatter); - - return $package; - }; + $package = $loader(); + $package->setFormatter($formatter); - return $chain; + return $package; }); } + /** + * Sets the default locale code. + * + * @param string $locale The new locale code. + * @return void + */ + public function setLocale(string $locale): void + { + $this->locale = $locale; + } + + /** + * Returns the default locale code. + * + * @return string + */ + public function getLocale(): string + { + return $this->locale; + } + + /** + * Returns the translator packages + * + * @return \Cake\I18n\PackageLocator + */ + public function getPackages(): PackageLocator + { + return $this->packages; + } + + /** + * An object of type FormatterLocator + * + * @return \Cake\I18n\FormatterLocator + */ + public function getFormatters(): FormatterLocator + { + return $this->formatters; + } + /** * Sets the CacheEngine instance used to remember translators across * requests. * - * @param \Cake\Cache\CacheEngine $cacher The cacher instance. + * @param \Psr\SimpleCache\CacheInterface&\Cake\Cache\CacheEngineInterface $cacher The cacher instance. * @return void */ - public function setCacher(CacheEngine $cacher) + public function setCacher(CacheInterface&CacheEngineInterface $cacher): void { $this->_cacher = $cacher; } + /** + * Sets a prefix segment added to translator cache keys and in-memory + * lookup buckets. + * + * Intended for isolating translations in multi-tenant applications where + * dynamic loaders may produce different messages for the same domain and + * locale per tenant. + * + * Accepts either a string (static prefix) or a Closure that returns a + * string. Closures are resolved on every {@see get()} call so the current + * tenant identifier can be pulled from user-land without pushing state + * into this class. An empty result disables prefixing and keeps the + * legacy cache key format `translations.{domain}.{locale}`. + * + * The Closure receives the requested package name and resolved locale + * (`function (string $name, string $locale): string`), so callers may + * vary the prefix per package (e.g. skip prefixing for shared packages) + * or per locale. Each distinct resolved prefix produces its own + * in-memory bucket, so closures that vary by `$name`/`$locale` will + * fragment the registry accordingly. + * + * Prefix values must match `[A-Za-z0-9._-]+` to stay safe across every + * built-in cache engine. + * + * Unrelated to the gettext message context used by {@see __x()}. + * + * @param \Closure|string $prefix Static prefix or a Closure returning one. + * @return void + * @throws \InvalidArgumentException If a non-empty string prefix contains invalid characters. + */ + public function setCacheKeyPrefix(Closure|string $prefix): void + { + if (is_string($prefix) && $prefix !== '') { + $this->assertValidPrefix($prefix); + } + + $this->cacheKeyPrefix = $prefix; + } + + /** + * Drops all in-memory translator instances. + * + * Does not touch the persistent cache or any configured prefix/cacher. + * Intended for long-running workers that switch tenants between jobs + * and want to bound memory growth. + * + * @return void + */ + public function clear(): void + { + $this->registry = []; + } + + /** + * Resolves the current cache key prefix value. + * + * @param string $name The translator package name being resolved. + * @param string $locale The locale being resolved. + * @return string The resolved prefix or an empty string when none is set. + * @throws \InvalidArgumentException If a Closure prefix returns an invalid value. + */ + protected function resolveCacheKeyPrefix(string $name, string $locale): string + { + if ($this->cacheKeyPrefix instanceof Closure) { + $value = ($this->cacheKeyPrefix)($name, $locale); + if ($value === '') { + return ''; + } + $this->assertValidPrefix($value); + + return $value; + } + + return $this->cacheKeyPrefix; + } + + /** + * Validates a prefix string. + * + * @param string $prefix The value to check. + * @return void + * @throws \InvalidArgumentException When the value does not match the allowed pattern. + */ + protected function assertValidPrefix(string $prefix): void + { + if (!preg_match(static::PREFIX_PATTERN, $prefix)) { + throw new InvalidArgumentException(sprintf( + 'Translator cache key prefix `%s` contains invalid characters. Allowed: A-Z, a-z, 0-9, `.`, `_`, `-`.', + $prefix, + )); + } + } + /** * Gets a translator from the registry by package for a locale. * * @param string $name The translator package to retrieve. * @param string|null $locale The locale to use; if empty, uses the default * locale. - * @return \Aura\Intl\TranslatorInterface|null A translator object. - * @throws \Aura\Intl\Exception If no translator with that name could be found + * @return \Cake\I18n\Translator|null A translator object. + * @throws \Cake\I18n\Exception\I18nException If no translator with that name could be found * for the given locale. */ - public function get($name, $locale = null) + public function get(string $name, ?string $locale = null): ?Translator { - if (!$name) { - return null; - } + $locale ??= $this->getLocale(); - if ($locale === null) { - $locale = $this->getLocale(); - } + $prefix = $this->resolveCacheKeyPrefix($name, $locale); + $bucket = $prefix !== '' ? $prefix : static::DEFAULT_BUCKET; - if (isset($this->registry[$name][$locale])) { - return $this->registry[$name][$locale]; + if (isset($this->registry[$bucket][$name][$locale])) { + return $this->registry[$bucket][$name][$locale]; } - if (!$this->_cacher) { - return $this->registry[$name][$locale] = $this->_getTranslator($name, $locale); + if ($this->_cacher === null) { + return $this->registry[$bucket][$name][$locale] = $this->_getTranslator($name, $locale); } - $key = "translations.$name.$locale"; - $translator = $this->_cacher->read($key); - if (!$translator || !$translator->getPackage()) { + // Cache keys cannot contain / if they go to file engine. + $keyName = str_replace('/', '.', $name); + $key = $prefix !== '' + ? "translations.{$prefix}.{$keyName}.{$locale}" + : "translations.{$keyName}.{$locale}"; + /** @var \Cake\I18n\Translator|null $translator */ + $translator = $this->_cacher->get($key); + + if (!$translator) { $translator = $this->_getTranslator($name, $locale); - $this->_cacher->write($key, $translator); + $this->_cacher->set($key, $translator); } - return $this->registry[$name][$locale] = $translator; + return $this->registry[$bucket][$name][$locale] = $translator; } /** * Gets a translator from the registry by package for a locale. * * @param string $name The translator package to retrieve. - * @param string|null $locale The locale to use; if empty, uses the default + * @param string $locale The locale to use; if empty, uses the default * locale. - * @return \Aura\Intl\TranslatorInterface A translator object. + * @return \Cake\I18n\Translator A translator object. */ - protected function _getTranslator($name, $locale) + protected function _getTranslator(string $name, string $locale): Translator { - try { - return parent::get($name, $locale); - } catch (Exception $e) { + if ($this->packages->has($name, $locale)) { + return $this->createInstance($name, $locale); + } + + if (isset($this->_loaders[$name])) { + $package = $this->_loaders[$name]($name, $locale); + } else { + $package = $this->_loaders[static::FALLBACK_LOADER]($name, $locale); + } + + // Support __invoke() wrapper classes + if (!$package instanceof Package && is_callable($package)) { + deprecationWarning( + '5.3.0', + 'Using a callable as a package loader is deprecated. ' . + 'Please return an instance of \Cake\I18n\Package instead.', + ); + + $package = $package(); } - if (!isset($this->_loaders[$name])) { - $this->registerLoader($name, $this->_partialLoader()); + $package = $this->setFallbackPackage($name, $package); + $this->packages->set($name, $locale, $package); + + return $this->createInstance($name, $locale); + } + + /** + * Create translator instance. + * + * @param string $name The translator package to retrieve. + * @param string $locale The locale to use; if empty, uses the default locale. + * @return \Cake\I18n\Translator A translator object. + */ + protected function createInstance(string $name, string $locale): Translator + { + $package = $this->packages->get($name, $locale); + $fallback = $package->getFallback(); + if ($fallback !== null) { + $fallback = $this->get($fallback, $locale); } + $formatter = $this->formatters->get($package->getFormatter()); - return $this->_getFromLoader($name, $locale); + return new Translator($locale, $package, $formatter, $fallback); } /** @@ -187,7 +408,7 @@ protected function _getTranslator($name, $locale) * @param callable $loader A callable object that should return a Package * @return void */ - public function registerLoader($name, callable $loader) + public function registerLoader(string $name, callable $loader): void { $this->_loaders[$name] = $loader; } @@ -201,7 +422,7 @@ public function registerLoader($name, callable $loader) * @param string|null $name The name of the formatter to use. * @return string The name of the formatter. */ - public function defaultFormatter($name = null) + public function defaultFormatter(?string $name = null): string { if ($name === null) { return $this->_defaultFormatter; @@ -216,60 +437,32 @@ public function defaultFormatter($name = null) * @param bool $enable flag to enable or disable fallback * @return void */ - public function useFallback($enable = true) + public function useFallback(bool $enable = true): void { $this->_useFallback = $enable; } /** - * Returns a new translator instance for the given name and locale - * based of conventions. + * Set fallback domain for package. * - * @param string $name The translation package name. - * @param string $locale The locale to create the translator for. - * @return \Aura\Intl\Translator + * @param string $name The name of the package. + * @param \Cake\I18n\Package $package Package instance + * @return \Cake\I18n\Package */ - protected function _fallbackLoader($name, $locale) + public function setFallbackPackage(string $name, Package $package): Package { - return $this->_loaders[$this->_fallbackLoader]($name, $locale); - } - - /** - * Returns a function that can be used as a loader for the registerLoaderMethod - * - * @return callable - */ - protected function _partialLoader() - { - return function ($name, $locale) { - return $this->_fallbackLoader($name, $locale); - }; - } - - /** - * Registers a new package by passing the register loaded function for the - * package name. - * - * @param string $name The name of the translator package - * @param string $locale The locale that should be built the package for - * @return \Aura\Intl\TranslatorInterface A translator object. - */ - protected function _getFromLoader($name, $locale) - { - $loader = $this->_loaders[$name]($name, $locale); - $package = $loader; - - if (!is_callable($loader)) { - $loader = function () use ($package) { - return $package; - }; + if ($package->getFallback()) { + return $package; } - $loader = $this->setLoaderFallback($name, $loader); + $fallbackDomain = null; + if ($this->_useFallback && $name !== 'default') { + $fallbackDomain = 'default'; + } - $this->packages->set($name, $locale, $loader); + $package->setFallback($fallbackDomain); - return parent::get($name, $locale); + return $package; } /** @@ -279,14 +472,15 @@ protected function _getFromLoader($name, $locale) * @param callable $loader invokable loader * @return callable loader */ - public function setLoaderFallback($name, callable $loader) + public function setLoaderFallback(string $name, callable $loader): callable { $fallbackDomain = 'default'; if (!$this->_useFallback || $name === $fallbackDomain) { return $loader; } - $loader = function () use ($loader, $fallbackDomain) { - /* @var \Aura\Intl\Package $package */ + + return function () use ($loader, $fallbackDomain) { + /** @var \Cake\I18n\Package $package */ $package = $loader(); if (!$package->getFallback()) { $package->setFallback($fallbackDomain); @@ -294,7 +488,5 @@ public function setLoaderFallback($name, callable $loader) return $package; }; - - return $loader; } } diff --git a/src/I18n/composer.json b/src/I18n/composer.json index 8d7c8660e6f..03de5b80611 100644 --- a/src/I18n/composer.json +++ b/src/I18n/composer.json @@ -28,14 +28,10 @@ "source": "https://github.com/cakephp/i18n" }, "require": { - "php": ">=5.6.0", + "php": ">=8.2", "ext-intl": "*", - "cakephp/core": "^3.0.0", - "cakephp/chronos": "^1.0.0", - "aura/intl": "^3.0.0" - }, - "suggest": { - "cakephp/cache": "Require this if you want automatic caching of translators" + "cakephp/core": "^5.4.0", + "cakephp/chronos": "^3.3" }, "autoload": { "psr-4": { @@ -44,5 +40,15 @@ "files": [ "functions.php" ] + }, + "suggest": { + "cakephp/cache": "Require this if you want automatic caching of translators" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" + } } } diff --git a/src/I18n/functions.php b/src/I18n/functions.php index 65f46ab6829..51d2f87b55e 100644 --- a/src/I18n/functions.php +++ b/src/I18n/functions.php @@ -1,4 +1,6 @@ translate($singular, $args); +/** + * Returns a translated string if one is found; Otherwise, the submitted message. + * + * @param string $singular Text to translate. + * @param mixed ...$args Array with arguments or multiple arguments in function. + * @return string The translated text. + * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#global-functions + */ +function __(string $singular, mixed ...$args): string +{ + if (!$singular) { + return ''; + } + if (isset($args[0]) && is_array($args[0])) { + $args = $args[0]; } + return I18n::getTranslator()->translate($singular, $args); } -if (!function_exists('__n')) { - /** - * Returns correct plural form of message identified by $singular and $plural for count $count. - * Some languages have more than one form for plural messages dependent on the count. - * - * @param string $singular Singular text to translate. - * @param string $plural Plural text. - * @param int $count Count. - * @param array ...$args Array with arguments or multiple arguments in function. - * @return string|null Plural form of translated string, or null if invalid. - * @link https://book.cakephp.org/3.0/en/core-libraries/global-constants-and-functions.html#__n - */ - function __n($singular, $plural, $count, ...$args) - { - if (!$singular) { - return null; - } - if (isset($args[0]) && is_array($args[0])) { - $args = $args[0]; - } - - return I18n::getTranslator()->translate( - $plural, - ['_count' => $count, '_singular' => $singular] + $args - ); +/** + * Returns correct plural form of message identified by $singular and $plural for count $count. + * Some languages have more than one form for plural messages dependent on the count. + * + * @param string $singular Singular text to translate. + * @param string $plural Plural text. + * @param int $count Count. + * @param mixed ...$args Array with arguments or multiple arguments in function. + * @return string Plural form of translated string. + * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#n + */ +function __n(string $singular, string $plural, int $count, mixed ...$args): string +{ + if (!$singular) { + return ''; + } + if (isset($args[0]) && is_array($args[0])) { + $args = $args[0]; } + return I18n::getTranslator()->translate( + $plural, + ['_count' => $count, '_singular' => $singular] + $args, + ); } -if (!function_exists('__d')) { - /** - * Allows you to override the current domain for a single message lookup. - * - * @param string $domain Domain. - * @param string $msg String to translate. - * @param array ...$args Array with arguments or multiple arguments in function. - * @return string|null Translated string. - * @link https://book.cakephp.org/3.0/en/core-libraries/global-constants-and-functions.html#__d - */ - function __d($domain, $msg, ...$args) - { - if (!$msg) { - return null; - } - if (isset($args[0]) && is_array($args[0])) { - $args = $args[0]; - } +/** + * Allows you to override the current domain for a single message lookup. + * + * @param string $domain Domain. + * @param string $msg String to translate. + * @param mixed ...$args Array with arguments or multiple arguments in function. + * @return string Translated string. + * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#d + */ +function __d(string $domain, string $msg, mixed ...$args): string +{ + if (!$msg) { + return ''; + } + if (isset($args[0]) && is_array($args[0])) { + $args = $args[0]; + } - return I18n::getTranslator($domain)->translate($msg, $args); + return I18n::getTranslator($domain)->translate($msg, $args); +} + +/** + * Allows you to override the current domain for a single plural message lookup. + * Returns correct plural form of message identified by $singular and $plural for count $count + * from domain $domain. + * + * @param string $domain Domain. + * @param string $singular Singular string to translate. + * @param string $plural Plural. + * @param int $count Count. + * @param mixed ...$args Array with arguments or multiple arguments in function. + * @return string Plural form of translated string. + * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#dn + */ +function __dn(string $domain, string $singular, string $plural, int $count, mixed ...$args): string +{ + if (!$singular) { + return ''; + } + if (isset($args[0]) && is_array($args[0])) { + $args = $args[0]; } + return I18n::getTranslator($domain)->translate( + $plural, + ['_count' => $count, '_singular' => $singular] + $args, + ); } -if (!function_exists('__dn')) { - /** - * Allows you to override the current domain for a single plural message lookup. - * Returns correct plural form of message identified by $singular and $plural for count $count - * from domain $domain. - * - * @param string $domain Domain. - * @param string $singular Singular string to translate. - * @param string $plural Plural. - * @param int $count Count. - * @param array ...$args Array with arguments or multiple arguments in function. - * @return string|null Plural form of translated string. - * @link https://book.cakephp.org/3.0/en/core-libraries/global-constants-and-functions.html#__dn - */ - function __dn($domain, $singular, $plural, $count, ...$args) - { - if (!$singular) { - return null; - } - if (isset($args[0]) && is_array($args[0])) { - $args = $args[0]; - } +/** + * Returns a translated string if one is found; Otherwise, the submitted message. + * The context is a unique identifier for the translations string that makes it unique + * within the same domain. + * + * @param string $context Context of the text. + * @param string $singular Text to translate. + * @param mixed ...$args Array with arguments or multiple arguments in function. + * @return string Translated string. + * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#x + */ +function __x(string $context, string $singular, mixed ...$args): string +{ + if (!$singular) { + return ''; + } + if (isset($args[0]) && is_array($args[0])) { + $args = $args[0]; + } + + return I18n::getTranslator()->translate($singular, ['_context' => $context] + $args); +} - return I18n::getTranslator($domain)->translate( - $plural, - ['_count' => $count, '_singular' => $singular] + $args - ); +/** + * Returns correct plural form of message identified by $singular and $plural for count $count. + * Some languages have more than one form for plural messages dependent on the count. + * The context is a unique identifier for the translations string that makes it unique + * within the same domain. + * + * @param string $context Context of the text. + * @param string $singular Singular text to translate. + * @param string $plural Plural text. + * @param int $count Count. + * @param mixed ...$args Array with arguments or multiple arguments in function. + * @return string Plural form of translated string. + * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#xn + */ +function __xn(string $context, string $singular, string $plural, int $count, mixed ...$args): string +{ + if (!$singular) { + return ''; + } + if (isset($args[0]) && is_array($args[0])) { + $args = $args[0]; } + return I18n::getTranslator()->translate( + $plural, + ['_count' => $count, '_singular' => $singular, '_context' => $context] + $args, + ); } -if (!function_exists('__x')) { - /** - * Returns a translated string if one is found; Otherwise, the submitted message. - * The context is a unique identifier for the translations string that makes it unique - * within the same domain. - * - * @param string $context Context of the text. - * @param string $singular Text to translate. - * @param array ...$args Array with arguments or multiple arguments in function. - * @return string|null Translated string. - * @link https://book.cakephp.org/3.0/en/core-libraries/global-constants-and-functions.html#__x - */ - function __x($context, $singular, ...$args) - { - if (!$singular) { - return null; - } - if (isset($args[0]) && is_array($args[0])) { - $args = $args[0]; - } +/** + * Allows you to override the current domain for a single message lookup. + * The context is a unique identifier for the translations string that makes it unique + * within the same domain. + * + * @param string $domain Domain. + * @param string $context Context of the text. + * @param string $msg String to translate. + * @param mixed ...$args Array with arguments or multiple arguments in function. + * @return string Translated string. + * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#dx + */ +function __dx(string $domain, string $context, string $msg, mixed ...$args): string +{ + if (!$msg) { + return ''; + } + if (isset($args[0]) && is_array($args[0])) { + $args = $args[0]; + } - return I18n::getTranslator()->translate($singular, ['_context' => $context] + $args); + return I18n::getTranslator($domain)->translate( + $msg, + ['_context' => $context] + $args, + ); +} + +/** + * Returns correct plural form of message identified by $singular and $plural for count $count. + * Allows you to override the current domain for a single message lookup. + * The context is a unique identifier for the translations string that makes it unique + * within the same domain. + * + * @param string $domain Domain. + * @param string $context Context of the text. + * @param string $singular Singular text to translate. + * @param string $plural Plural text. + * @param int $count Count. + * @param mixed ...$args Array with arguments or multiple arguments in function. + * @return string Plural form of translated string. + * @link https://book.cakephp.org/5/en/core-libraries/global-constants-and-functions.html#dxn + */ +function __dxn( + string $domain, + string $context, + string $singular, + string $plural, + int $count, + mixed ...$args, +): string { + if (!$singular) { + return ''; + } + if (isset($args[0]) && is_array($args[0])) { + $args = $args[0]; } + return I18n::getTranslator($domain)->translate( + $plural, + ['_count' => $count, '_singular' => $singular, '_context' => $context] + $args, + ); } -if (!function_exists('__xn')) { - /** - * Returns correct plural form of message identified by $singular and $plural for count $count. - * Some languages have more than one form for plural messages dependent on the count. - * The context is a unique identifier for the translations string that makes it unique - * within the same domain. - * - * @param string $context Context of the text. - * @param string $singular Singular text to translate. - * @param string $plural Plural text. - * @param int $count Count. - * @param array ...$args Array with arguments or multiple arguments in function. - * @return string|null Plural form of translated string. - * @link https://book.cakephp.org/3.0/en/core-libraries/global-constants-and-functions.html#__xn - */ - function __xn($context, $singular, $plural, $count, ...$args) - { - if (!$singular) { +/** + * Converts a value to a DateTime object. + * + * integer - value is treated as a Unix timestamp + * float - value is treated as a Unix timestamp with microseconds + * string - value is treated as an Atom-formatted timestamp, unless otherwise specified + * Other values returns as null. + * + * @param mixed $value The value to convert to DateTime. + * @param string $format The datetime format the value is in. Defaults to Atom (ex: 1970-01-01T12:00:00+00:00) format. + * @return \Cake\I18n\DateTime|null Returns a DateTime object if parsing is successful, or NULL otherwise. + * @since 5.1.0 + */ +function toDateTime(mixed $value, string $format = DateTimeInterface::ATOM): ?DateTime +{ + if ($value instanceof DateTime) { + return $value; + } + + if ( + $value instanceof DateTimeInterface || + $value instanceof Date + ) { + return DateTime::parse($value); + } + + if (is_numeric($value)) { + try { + return DateTime::createFromTimestamp((float)$value); + } catch (Throwable) { return null; } - if (isset($args[0]) && is_array($args[0])) { - $args = $args[0]; - } + } - return I18n::getTranslator()->translate( - $plural, - ['_count' => $count, '_singular' => $singular, '_context' => $context] + $args - ); + if (is_string($value)) { + try { + return DateTime::createFromFormat($format, $value); + } catch (Throwable) { + return null; + } } + return null; } -if (!function_exists('__dx')) { - /** - * Allows you to override the current domain for a single message lookup. - * The context is a unique identifier for the translations string that makes it unique - * within the same domain. - * - * @param string $domain Domain. - * @param string $context Context of the text. - * @param string $msg String to translate. - * @param array ...$args Array with arguments or multiple arguments in function. - * @return string|null Translated string. - * @link https://book.cakephp.org/3.0/en/core-libraries/global-constants-and-functions.html#__dx - */ - function __dx($domain, $context, $msg, ...$args) - { - if (!$msg) { - return null; - } - if (isset($args[0]) && is_array($args[0])) { - $args = $args[0]; - } +/** + * Converts a value to a Date object. + * + * integer - value is treated as a Unix timestamp + * float - value is treated as a Unix timestamp with microseconds + * string - value is treated as a I18N short formatted date, unless otherwise specified + * Other values returns as null. + * + * @param mixed $value The value to convert to Date. + * @param string $format The date format the value is in. Defaults to Short (ex: 1970-01-01) format. + * @return \Cake\I18n\Date|null Returns a Date object if parsing is successful, or NULL otherwise. + * @since 5.1.0 + */ +function toDate(mixed $value, string $format = 'Y-m-d'): ?Date +{ + if ($value instanceof Date) { + return $value; + } - return I18n::getTranslator($domain)->translate( - $msg, - ['_context' => $context] + $args - ); + if ($value instanceof DateTimeInterface) { + return Date::parse($value); } -} + if (is_numeric($value)) { + try { + $datetime = DateTime::createFromTimestamp((float)$value); -if (!function_exists('__dxn')) { - /** - * Returns correct plural form of message identified by $singular and $plural for count $count. - * Allows you to override the current domain for a single message lookup. - * The context is a unique identifier for the translations string that makes it unique - * within the same domain. - * - * @param string $domain Domain. - * @param string $context Context of the text. - * @param string $singular Singular text to translate. - * @param string $plural Plural text. - * @param int $count Count. - * @param array ...$args Array with arguments or multiple arguments in function. - * @return string|null Plural form of translated string. - * @link https://book.cakephp.org/3.0/en/core-libraries/global-constants-and-functions.html#__dxn - */ - function __dxn($domain, $context, $singular, $plural, $count, ...$args) - { - if (!$singular) { + return Date::create($datetime->year, $datetime->month, $datetime->day); + } catch (Throwable) { return null; } - if (isset($args[0]) && is_array($args[0])) { - $args = $args[0]; - } + } + + if (is_string($value)) { + try { + $datetime = DateTime::createFromFormat($format, $value); - return I18n::getTranslator($domain)->translate( - $plural, - ['_count' => $count, '_singular' => $singular, '_context' => $context] + $args - ); + return Date::parse($datetime); + } catch (Throwable) { + return null; + } } + return null; } diff --git a/src/I18n/functions_global.php b/src/I18n/functions_global.php new file mode 100644 index 00000000000..6ae9934452d --- /dev/null +++ b/src/I18n/functions_global.php @@ -0,0 +1,225 @@ +resource; + } + + /** + * Get the unique token identifying the lock owner. + * + * This token is used to verify ownership when releasing + * or refreshing the lock. + * + * @return string + */ + public function getToken(): string + { + return $this->token; + } + + /** + * Get the time-to-live in seconds. + * + * @return int + */ + public function getTtl(): int + { + return $this->ttl; + } + + /** + * Get the timestamp when the lock was acquired. + * + * @return float + */ + public function getAcquiredAt(): float + { + return $this->acquiredAt; + } + + /** + * Release the lock with the engine that acquired it. + * + * @return bool True if the lock was released, false otherwise. + */ + public function release(): bool + { + if ($this->released || $this->engine === null) { + return false; + } + + $released = $this->engine->release($this); + if ($released) { + $this->released = true; + } + + return $released; + } + + /** + * Refresh the lock with the engine that acquired it. + * + * @param int|null $ttl New TTL in seconds. If null, uses the original TTL. + * @return bool True if the lock was refreshed, false otherwise. + */ + public function refresh(?int $ttl = null): bool + { + if ($this->released || $this->engine === null) { + return false; + } + + return $this->engine->refresh($this, $ttl); + } + + /** + * Check whether the lock has already been released. + * + * @return bool + */ + public function isReleased(): bool + { + return $this->released; + } + + /** + * Check if the lock has expired based on its TTL. + * + * Note: This is a local check based on the original TTL. + * The actual lock state in the backend may differ due to + * clock skew or manual intervention. + * + * @return bool True if the lock has likely expired. + */ + public function isExpired(): bool + { + return microtime(true) - $this->acquiredAt >= $this->ttl; + } + + /** + * Get remaining time until expiration in seconds. + * + * @return float Remaining seconds, may be negative if expired. + */ + public function getRemainingTtl(): float + { + return $this->ttl - (microtime(true) - $this->acquiredAt); + } + + /** + * Attempt to release the lock when it falls out of scope. + */ + public function __destruct() + { + $this->release(); + } +} diff --git a/src/Lock/Engine/FileLockEngine.php b/src/Lock/Engine/FileLockEngine.php new file mode 100644 index 00000000000..08efc4c9d43 --- /dev/null +++ b/src/Lock/Engine/FileLockEngine.php @@ -0,0 +1,300 @@ + + */ + protected array $_handles = []; + + /** + * Default configuration. + * + * @var array + */ + protected array $_defaultConfig = [ + 'path' => '', + 'prefix' => 'lock_', + 'ttl' => 300, + ]; + + /** + * Initialize the file lock engine. + * + * @param array $config Configuration options. + * @return bool True if initialization was successful. + */ + public function init(array $config = []): bool + { + parent::init($config); + + if (empty($this->_config['path'])) { + $this->_config['path'] = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'cake_locks'; + } + + // Ensure lock directory exists + if (!is_dir($this->_config['path'])) { + mkdir($this->_config['path'], 0777, true); + } + + return true; + } + + /** + * Get the file path for a lock. + * + * @param string $resource The resource identifier. + * @return string The lock file path. + */ + protected function getLockFile(string $resource): string + { + $key = $this->key($resource); + // Make filename safe + $safeKey = preg_replace('/[^a-zA-Z0-9_-]/', '_', $key); + + return $this->_config['path'] . DIRECTORY_SEPARATOR . $safeKey . '.lock'; + } + + /** + * Acquire a lock for the given resource. + * + * Uses flock() with LOCK_EX | LOCK_NB for non-blocking exclusive lock. + * + * @param string $resource The resource identifier to lock. + * @param int $ttl Time-to-live in seconds (used for stale file cleanup). + * @return \Cake\Lock\AcquiredLock|null Returns an AcquiredLock on success, null on failure. + */ + public function acquire(string $resource, int $ttl = 300): ?AcquiredLock + { + $file = $this->getLockFile($resource); + $token = $this->generateToken(); + + // Clean up stale lock file if it exists and is old + $this->cleanupStaleLock($file, $ttl); + + $handle = fopen($file, 'c+'); + if ($handle === false) { + return null; + } + + // Try to acquire exclusive lock (non-blocking) + if (!flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); + + return null; + } + + // Write lock metadata + ftruncate($handle, 0); + rewind($handle); + $data = json_encode([ + 'token' => $token, + 'ttl' => $ttl, + 'acquired_at' => microtime(true), + ]); + assert($data !== false); + fwrite($handle, $data); + fflush($handle); + + // Store handle for later release + $this->_handles[$resource] = $handle; + + return new AcquiredLock($resource, $token, $ttl, microtime(true), $this); + } + + /** + * Clean up a stale lock file if it's older than TTL. + * + * @param string $file The lock file path. + * @param int $ttl TTL in seconds. + * @return void + */ + protected function cleanupStaleLock(string $file, int $ttl): void + { + if (!file_exists($file)) { + return; + } + + $mtime = filemtime($file); + if ($mtime !== false && (time() - $mtime) > $ttl) { + @unlink($file); + } + } + + /** + * Release a lock. + * + * @param \Cake\Lock\AcquiredLock $lock The lock instance to release. + * @return bool True if the lock was released, false otherwise. + */ + public function release(AcquiredLock $lock): bool + { + $resource = $lock->getResource(); + + if (!isset($this->_handles[$resource])) { + return false; + } + + $handle = $this->_handles[$resource]; + + // Verify ownership + rewind($handle); + $content = stream_get_contents($handle); + if ($content !== false) { + $data = json_decode($content, true); + if (isset($data['token']) && $data['token'] !== $lock->getToken()) { + fclose($handle); + unset($this->_handles[$resource]); + + return false; + } + } + + // Close handle to release the advisory lock. + fclose($handle); + unset($this->_handles[$resource]); + + // Remove lock file + $file = $this->getLockFile($resource); + @unlink($file); + + return true; + } + + /** + * Check if a resource is currently locked. + * + * @param string $resource The resource identifier to check. + * @return bool True if the resource is locked, false otherwise. + */ + public function isLocked(string $resource): bool + { + $file = $this->getLockFile($resource); + + if (!file_exists($file)) { + return false; + } + + $handle = fopen($file, 'r'); + if ($handle === false) { + return false; + } + + // Try to acquire lock - if it fails, the resource is locked + $locked = !flock($handle, LOCK_EX | LOCK_NB); + fclose($handle); + + return $locked; + } + + /** + * Refresh a lock's TTL. + * + * Updates the lock file's metadata with a new TTL. + * + * @param \Cake\Lock\AcquiredLock $lock The lock instance to refresh. + * @param int|null $ttl New TTL in seconds. If null, uses the original TTL. + * @return bool True if the lock was refreshed, false otherwise. + */ + public function refresh(AcquiredLock $lock, ?int $ttl = null): bool + { + $resource = $lock->getResource(); + + if (!isset($this->_handles[$resource])) { + return false; + } + + $handle = $this->_handles[$resource]; + $ttl ??= $lock->getTtl(); + + // Update lock metadata + ftruncate($handle, 0); + rewind($handle); + $data = json_encode([ + 'token' => $lock->getToken(), + 'ttl' => $ttl, + 'acquired_at' => microtime(true), + ]); + assert($data !== false); + fwrite($handle, $data); + fflush($handle); + + // Touch the file to update mtime for stale detection + touch($this->getLockFile($resource)); + + return true; + } + + /** + * Force release a lock without ownership verification. + * + * @param string $resource The resource identifier to force release. + * @return bool True if the lock was released, false otherwise. + */ + public function forceRelease(string $resource): bool + { + $file = $this->getLockFile($resource); + + // Close handle if we have one + if (isset($this->_handles[$resource])) { + fclose($this->_handles[$resource]); + unset($this->_handles[$resource]); + } + + // Remove lock file + if (file_exists($file)) { + return @unlink($file); + } + + return true; + } + + /** + * Destructor to clean up open handles. + */ + public function __destruct() + { + foreach ($this->_handles as $handle) { + fclose($handle); + } + $this->_handles = []; + } +} diff --git a/src/Lock/Engine/MemcachedLockEngine.php b/src/Lock/Engine/MemcachedLockEngine.php new file mode 100644 index 00000000000..02986c8a223 --- /dev/null +++ b/src/Lock/Engine/MemcachedLockEngine.php @@ -0,0 +1,208 @@ + + */ + protected array $_defaultConfig = [ + 'servers' => [['127.0.0.1', 11211]], + 'prefix' => 'lock_', + 'ttl' => 300, + 'persistent' => false, + ]; + + /** + * Initialize the Memcached lock engine. + * + * @param array $config Configuration options. + * @return bool True if initialization was successful. + * @throws \Cake\Core\Exception\CakeException If memcached extension is not loaded. + */ + public function init(array $config = []): bool + { + if (!extension_loaded('memcached')) { + throw new CakeException('The `memcached` extension must be enabled to use MemcachedLockEngine.'); + } + + parent::init($config); + + return $this->_connect(); + } + + /** + * Connect to Memcached servers. + * + * @return bool True if connection was successful. + */ + protected function _connect(): bool + { + if ($this->_config['persistent']) { + $this->_memcached = new Memcached((string)$this->_config['persistent']); + } else { + $this->_memcached = new Memcached(); + } + + // Only add servers if not already added (for persistent connections) + if ($this->_memcached->getServerList() === []) { + $servers = []; + foreach ($this->_config['servers'] as $server) { + $servers[] = [$server[0], (int)($server[1] ?? 11211), 1]; + } + $this->_memcached->addServers($servers); + } + + // Verify connection by getting version + $versions = $this->_memcached->getVersion(); + + return $versions !== false && $versions !== []; + } + + /** + * Acquire a lock for the given resource. + * + * Uses Memcached add() which only succeeds if the key doesn't exist. + * + * @param string $resource The resource identifier to lock. + * @param int $ttl Time-to-live in seconds. + * @return \Cake\Lock\AcquiredLock|null Returns an AcquiredLock on success, null on failure. + */ + public function acquire(string $resource, int $ttl = 300): ?AcquiredLock + { + $key = $this->key($resource); + $token = $this->generateToken(); + + // add() only succeeds if key doesn't exist - atomic operation + $result = $this->_memcached->add($key, $token, $ttl); + + if ($result === true) { + return new AcquiredLock($resource, $token, $ttl, microtime(true), $this); + } + + return null; + } + + /** + * Release a lock. + * + * Note: This uses CAS (Check-And-Set) to ensure only the owner can release. + * However, there's a small race window between get and cas. + * + * @param \Cake\Lock\AcquiredLock $lock The lock instance to release. + * @return bool True if the lock was released, false otherwise. + */ + public function release(AcquiredLock $lock): bool + { + $key = $this->key($lock->getResource()); + + // Get value to verify ownership + $value = $this->_memcached->get($key, null, Memcached::GET_EXTENDED); + + if ($value === false) { + return false; + } + + // Check if we own the lock + if ($value['value'] !== $lock->getToken()) { + return false; + } + + // Delete with CAS to ensure atomicity + return $this->_memcached->delete($key); + } + + /** + * Check if a resource is currently locked. + * + * @param string $resource The resource identifier to check. + * @return bool True if the resource is locked, false otherwise. + */ + public function isLocked(string $resource): bool + { + $key = $this->key($resource); + $this->_memcached->get($key); + + return $this->_memcached->getResultCode() !== Memcached::RES_NOTFOUND; + } + + /** + * Refresh a lock's TTL. + * + * Uses touch() to extend the TTL if we own the lock. + * + * @param \Cake\Lock\AcquiredLock $lock The lock instance to refresh. + * @param int|null $ttl New TTL in seconds. If null, uses the original TTL. + * @return bool True if the lock was refreshed, false otherwise. + */ + public function refresh(AcquiredLock $lock, ?int $ttl = null): bool + { + $key = $this->key($lock->getResource()); + $ttl ??= $lock->getTtl(); + + // Verify ownership first + $value = $this->_memcached->get($key); + if ($value !== $lock->getToken()) { + return false; + } + + // Touch to extend TTL + return $this->_memcached->touch($key, $ttl); + } + + /** + * Force release a lock without ownership verification. + * + * @param string $resource The resource identifier to force release. + * @return bool True if the lock was released, false otherwise. + */ + public function forceRelease(string $resource): bool + { + $key = $this->key($resource); + + return $this->_memcached->delete($key); + } +} diff --git a/src/Lock/Engine/NullLockEngine.php b/src/Lock/Engine/NullLockEngine.php new file mode 100644 index 00000000000..8c14b16bdaf --- /dev/null +++ b/src/Lock/Engine/NullLockEngine.php @@ -0,0 +1,101 @@ +generateToken(), $ttl, microtime(true), $this); + } + + /** + * Release a lock. + * + * Always succeeds. + * + * @param \Cake\Lock\AcquiredLock $lock The lock instance to release. + * @return bool Always returns true. + */ + public function release(AcquiredLock $lock): bool + { + return true; + } + + /** + * Check if a resource is currently locked. + * + * Always returns false (nothing is ever locked). + * + * @param string $resource The resource identifier to check. + * @return bool Always returns false. + */ + public function isLocked(string $resource): bool + { + return false; + } + + /** + * Refresh a lock's TTL. + * + * Always succeeds. + * + * @param \Cake\Lock\AcquiredLock $lock The lock instance to refresh. + * @param int|null $ttl New TTL in seconds. + * @return bool Always returns true. + */ + public function refresh(AcquiredLock $lock, ?int $ttl = null): bool + { + return true; + } + + /** + * Force release a lock. + * + * Always succeeds. + * + * @param string $resource The resource identifier to force release. + * @return bool Always returns true. + */ + public function forceRelease(string $resource): bool + { + return true; + } +} diff --git a/src/Lock/Engine/RedisLockEngine.php b/src/Lock/Engine/RedisLockEngine.php new file mode 100644 index 00000000000..5da5a939d2b --- /dev/null +++ b/src/Lock/Engine/RedisLockEngine.php @@ -0,0 +1,361 @@ +:` seed nodes for Redis Cluster. Presence + * of this option (or `clusterName`) switches the engine to cluster mode. + * - `clusterName`: Named cluster entry configured via `redis.clusters.seeds`. + * - `failover`: Cluster failover mode (`distribute`, `distribute_slaves`, + * `error`, `none`). Cluster only. + * - `tls`: When true, enables TLS for cluster connections. Cluster only. + */ +class RedisLockEngine extends LockEngine +{ + /** + * Redis connection. + * + * @var \Redis|\RedisCluster + */ + protected Redis|RedisCluster $_redis; + + /** + * Default configuration. + * + * @var array + */ + protected array $_defaultConfig = [ + 'host' => '127.0.0.1', + 'port' => 6379, + 'password' => false, + 'database' => 0, + 'timeout' => 0, + 'readTimeout' => 0, + 'persistent' => true, + 'prefix' => 'lock_', + 'ttl' => 300, + 'nodes' => [], + 'clusterName' => null, + 'failover' => null, + 'tls' => false, + ]; + + /** + * Initialize the Redis lock engine. + * + * @param array $config Configuration options. + * @return bool True if initialization was successful. + * @throws \Cake\Core\Exception\CakeException If redis extension is not loaded. + */ + public function init(array $config = []): bool + { + if (!extension_loaded('redis')) { + throw new CakeException('The `redis` extension must be enabled to use RedisLockEngine.'); + } + + parent::init($config); + + return $this->_connect(); + } + + /** + * Connect to Redis server or cluster. + * + * @return bool True if connection was successful. + */ + protected function _connect(): bool + { + if (!empty($this->_config['nodes']) || !empty($this->_config['clusterName'])) { + return $this->connectRedisCluster(); + } + + return $this->connectRedis(); + } + + /** + * Connect to a single Redis server. + * + * @return bool True if connection was successful. + */ + protected function connectRedis(): bool + { + $this->_redis = new Redis(); + + try { + if ($this->_config['persistent']) { + $connected = $this->_redis->pconnect( + $this->_config['host'], + $this->_config['port'], + (float)$this->_config['timeout'], + 'lock_' . $this->_config['database'], + ); + } else { + $connected = $this->_redis->connect( + $this->_config['host'], + $this->_config['port'], + (float)$this->_config['timeout'], + ); + } + + if (!$connected) { + return false; + } + + if ($this->_config['password'] !== false && !$this->_redis->auth($this->_config['password'])) { + return false; + } + + if ($this->_config['database'] !== 0) { + $this->_redis->select((int)$this->_config['database']); + } + + return true; + } catch (RedisException) { + return false; + } + } + + /** + * Connect to a Redis Cluster. + * + * @return bool True if connection was successful. + */ + protected function connectRedisCluster(): bool + { + if (empty($this->_config['nodes']) && empty($this->_config['clusterName'])) { + // @codeCoverageIgnoreStart + if (class_exists(Log::class)) { + Log::error('RedisLockEngine requires nodes or a clusterName in cluster mode'); + } + + return false; + // @codeCoverageIgnoreEnd + } + + // @codeCoverageIgnoreStart + $ssl = []; + if ($this->_config['tls']) { + $map = [ + 'ssl_ca' => 'cafile', + 'ssl_key' => 'local_pk', + 'ssl_cert' => 'local_cert', + 'verify_peer' => 'verify_peer', + 'verify_peer_name' => 'verify_peer_name', + 'allow_self_signed' => 'allow_self_signed', + ]; + + foreach ($map as $configKey => $sslOption) { + if (array_key_exists($configKey, $this->_config)) { + $ssl[$sslOption] = $this->_config[$configKey]; + } + } + } + // @codeCoverageIgnoreEnd + + try { + $this->_redis = new RedisCluster( + $this->_config['clusterName'], + $this->_config['nodes'] ?: null, + (float)$this->_config['timeout'], + (float)$this->_config['readTimeout'], + (bool)$this->_config['persistent'], + $this->_config['password'], + $this->_config['tls'] ? ['ssl' => $ssl] : null, // @codeCoverageIgnore + ); + } catch (RedisClusterException $e) { + // @codeCoverageIgnoreStart + if (class_exists(Log::class)) { + Log::error('RedisLockEngine could not connect to the redis cluster. Got error: ' . $e->getMessage()); + } + + return false; + // @codeCoverageIgnoreEnd + } + + $failover = match ($this->_config['failover']) { + RedisCluster::FAILOVER_DISTRIBUTE, 'distribute' => RedisCluster::FAILOVER_DISTRIBUTE, + RedisCluster::FAILOVER_DISTRIBUTE_SLAVES, 'distribute_slaves' => RedisCluster::FAILOVER_DISTRIBUTE_SLAVES, + RedisCluster::FAILOVER_ERROR, 'error' => RedisCluster::FAILOVER_ERROR, + RedisCluster::FAILOVER_NONE, 'none' => RedisCluster::FAILOVER_NONE, + default => null, + }; + + if ($failover !== null) { + $this->_redis->setOption(RedisCluster::OPT_SLAVE_FAILOVER, $failover); + } + + return true; + } + + /** + * Acquire a lock for the given resource. + * + * Uses Redis SET with NX (only set if not exists) and EX (expiry in seconds) + * for atomic lock acquisition. + * + * @param string $resource The resource identifier to lock. + * @param int $ttl Time-to-live in seconds. + * @return \Cake\Lock\AcquiredLock|null Returns an AcquiredLock on success, null on failure. + */ + public function acquire(string $resource, int $ttl = 300): ?AcquiredLock + { + $key = $this->key($resource); + $token = $this->generateToken(); + + try { + // SET key value EX seconds NX - atomic set if not exists with expiry + $result = $this->_redis->set($key, $token, ['NX', 'EX' => $ttl]); + + if ($result === true) { + return new AcquiredLock($resource, $token, $ttl, microtime(true), $this); + } + + return null; + } catch (RedisException | RedisClusterException) { + return null; + } + } + + /** + * Release a lock. + * + * Uses a Lua script for atomic check-and-delete to ensure + * only the lock owner can release the lock. + * + * @param \Cake\Lock\AcquiredLock $lock The lock instance to release. + * @return bool True if the lock was released, false otherwise. + */ + public function release(AcquiredLock $lock): bool + { + $key = $this->key($lock->getResource()); + + // Lua script for atomic check-and-delete + // Only delete if the token matches (we own the lock) + $script = <<<'LUA' + if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) + else + return 0 + end + LUA; + + try { + $result = $this->_redis->eval($script, [$key, $lock->getToken()], 1); + + return $result === 1; + } catch (RedisException | RedisClusterException) { + return false; + } + } + + /** + * Check if a resource is currently locked. + * + * @param string $resource The resource identifier to check. + * @return bool True if the resource is locked, false otherwise. + */ + public function isLocked(string $resource): bool + { + $key = $this->key($resource); + + try { + return $this->_redis->exists($key) === 1; + /** @phpstan-ignore catch.neverThrown */ + } catch (RedisException | RedisClusterException) { + return false; + } + } + + /** + * Refresh a lock's TTL. + * + * Uses a Lua script to atomically verify ownership and extend TTL. + * + * @param \Cake\Lock\AcquiredLock $lock The lock instance to refresh. + * @param int|null $ttl New TTL in seconds. If null, uses the original TTL. + * @return bool True if the lock was refreshed, false otherwise. + */ + public function refresh(AcquiredLock $lock, ?int $ttl = null): bool + { + $key = $this->key($lock->getResource()); + $ttl ??= $lock->getTtl(); + + // Lua script for atomic check-and-expire + $script = <<<'LUA' + if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("EXPIRE", KEYS[1], ARGV[2]) + else + return 0 + end + LUA; + + try { + $result = $this->_redis->eval($script, [$key, $lock->getToken(), $ttl], 1); + + return $result === 1; + } catch (RedisException | RedisClusterException) { + return false; + } + } + + /** + * Force release a lock without ownership verification. + * + * @param string $resource The resource identifier to force release. + * @return bool True if the lock was released, false otherwise. + */ + public function forceRelease(string $resource): bool + { + $key = $this->key($resource); + + try { + return $this->_redis->del($key) >= 0; + /** @phpstan-ignore catch.neverThrown */ + } catch (RedisException | RedisClusterException) { + return false; + } + } +} diff --git a/src/Lock/Exception/InvalidArgumentException.php b/src/Lock/Exception/InvalidArgumentException.php new file mode 100644 index 00000000000..a0eee42e808 --- /dev/null +++ b/src/Lock/Exception/InvalidArgumentException.php @@ -0,0 +1,26 @@ + \Cake\Lock\Engine\RedisLockEngine::class, + * 'host' => '127.0.0.1', + * 'port' => 6379, + * ]); + * ``` + * + * ### Usage examples + * + * Prefer `synchronized()` when you can, as it guarantees prompt release: + * + * ``` + * $result = Lock::synchronized('my-resource', function () { + * // Critical section + * return $computedValue; + * }); + * ``` + * + * Acquiring and releasing a lock: + * + * ``` + * $lock = Lock::acquire('my-resource'); + * if ($lock !== null) { + * try { + * // Critical section + * } finally { + * $lock->release(); + * } + * } + * ``` + */ +class Lock +{ + use StaticConfigTrait; + + /** + * DSN class map for lock engines. + * + * @var array + * @phpstan-var array + */ + protected static array $_dsnClassMap = [ + 'file' => Engine\FileLockEngine::class, + 'memcached' => Engine\MemcachedLockEngine::class, + 'null' => Engine\NullLockEngine::class, + 'redis' => Engine\RedisLockEngine::class, + ]; + + /** + * Lock Registry for managing engine instances. + * + * @var \Cake\Lock\LockRegistry<\Cake\Lock\LockEngine> + */ + protected static LockRegistry $_registry; + + /** + * Returns the Lock Registry instance. + * + * @return \Cake\Lock\LockRegistry<\Cake\Lock\LockEngine> + */ + public static function getRegistry(): LockRegistry + { + return static::$_registry ??= new LockRegistry(); + } + + /** + * Sets the Lock Registry instance. + * + * @param \Cake\Lock\LockRegistry<\Cake\Lock\LockEngine> $registry Injectable registry object. + * @return void + */ + public static function setRegistry(LockRegistry $registry): void + { + static::$_registry = $registry; + } + + /** + * Build and get a lock engine instance. + * + * @param string $name Name of the configuration. + * @throws \Cake\Lock\Exception\InvalidArgumentException When configuration doesn't exist. + * @throws \RuntimeException If engine loading fails. + * @return void + */ + protected static function _buildEngine(string $name): void + { + $registry = static::getRegistry(); + + if (empty(static::$_config[$name]['className'])) { + throw new InvalidArgumentException( + sprintf('The `%s` lock configuration does not exist.', $name), + ); + } + + $config = static::$_config[$name]; + + try { + $registry->load($name, $config); + } catch (RuntimeException $e) { + $registry->set($name, new NullLockEngine()); + trigger_error($e->getMessage(), E_USER_WARNING); + } + } + + /** + * Get a lock engine instance. + * + * @param string $config The name of the configured lock backend. + * @return \Cake\Lock\LockInterface + */ + public static function engine(string $config): LockInterface + { + $registry = static::getRegistry(); + + if ($registry->has($config)) { + return $registry->get($config); + } + + static::_buildEngine($config); + + return $registry->get($config); + } + + /** + * Acquire a lock for the given resource. + * + * Prefer `synchronized()` when possible. The returned lock can be released + * directly and will make a best-effort attempt to release itself on destruction. + * + * @param string $resource The resource identifier to lock. + * @param int|null $ttl Time-to-live in seconds. Null uses engine default. + * @param string $config Configuration name. Defaults to 'default'. + * @return \Cake\Lock\AcquiredLock|null Returns an AcquiredLock on success, null on failure. + */ + public static function acquire(string $resource, ?int $ttl = null, string $config = 'default'): ?AcquiredLock + { + $ttl ??= static::getConfig($config)['ttl'] ?? 300; + + return static::engine($config)->acquire($resource, $ttl); + } + + /** + * Acquire a lock, waiting up to $timeout seconds if necessary. + * + * @param string $resource The resource identifier to lock. + * @param int|null $ttl Time-to-live in seconds for the lock. + * @param int $timeout Maximum time in seconds to wait for the lock. + * @param int $retryInterval Milliseconds to wait between retry attempts. + * @param string $config Configuration name. Defaults to 'default'. + * @return \Cake\Lock\AcquiredLock|null Returns an AcquiredLock on success, null on timeout. + */ + public static function acquireBlocking( + string $resource, + ?int $ttl = null, + int $timeout = 10, + int $retryInterval = 100, + string $config = 'default', + ): ?AcquiredLock { + $ttl ??= static::getConfig($config)['ttl'] ?? 300; + + return static::engine($config)->acquireBlocking($resource, $ttl, $timeout, $retryInterval); + } + + /** + * Release a lock. + * + * @param \Cake\Lock\AcquiredLock $lock The lock instance to release. + * @return bool True if the lock was released, false otherwise. + */ + public static function release(AcquiredLock $lock): bool + { + return $lock->release(); + } + + /** + * Check if a resource is currently locked. + * + * @param string $resource The resource identifier to check. + * @param string $config Configuration name. Defaults to 'default'. + * @return bool True if the resource is locked, false otherwise. + */ + public static function isLocked(string $resource, string $config = 'default'): bool + { + return static::engine($config)->isLocked($resource); + } + + /** + * Refresh a lock's TTL. + * + * @param \Cake\Lock\AcquiredLock $lock The lock instance to refresh. + * @param int|null $ttl New TTL in seconds. If null, uses the original TTL. + * @return bool True if the lock was refreshed, false otherwise. + */ + public static function refresh(AcquiredLock $lock, ?int $ttl = null): bool + { + return $lock->refresh($ttl); + } + + /** + * Force release a lock without ownership verification. + * + * @param string $resource The resource identifier to force release. + * @param string $config Configuration name. Defaults to 'default'. + * @return bool True if the lock was released, false otherwise. + */ + public static function forceRelease(string $resource, string $config = 'default'): bool + { + return static::engine($config)->forceRelease($resource); + } + + /** + * Execute a callback with an acquired lock. + * + * This method provides a convenient way to execute code within a lock, + * automatically releasing the lock when the callback completes or throws. + * + * @template T + * @param string $resource The resource identifier to lock. + * @param \Closure $callback The callback to execute while holding the lock. + * @param int|null $ttl Time-to-live in seconds for the lock. + * @param int $timeout Maximum time in seconds to wait for the lock. + * @param string $config Configuration name. Defaults to 'default'. + * @return T|null Returns the callback result, or null if lock couldn't be acquired. + * @phpstan-param \Closure(): T $callback + * @phpstan-return T|null + */ + public static function synchronized( + string $resource, + Closure $callback, + ?int $ttl = null, + int $timeout = 10, + string $config = 'default', + ): mixed { + $lock = static::acquireBlocking($resource, $ttl, $timeout, config: $config); + if ($lock === null) { + return null; + } + + try { + return $callback(); + } finally { + $lock->release(); + } + } +} diff --git a/src/Lock/LockEngine.php b/src/Lock/LockEngine.php new file mode 100644 index 00000000000..5009e526538 --- /dev/null +++ b/src/Lock/LockEngine.php @@ -0,0 +1,130 @@ + + */ + protected array $_defaultConfig = [ + 'prefix' => 'lock_', + 'ttl' => 300, + ]; + + /** + * Initialize the lock engine. + * + * @param array $config Configuration options. + * @return bool True if initialization was successful. + */ + public function init(array $config = []): bool + { + $this->setConfig($config); + + return true; + } + + /** + * Generate a unique token for lock ownership. + * + * @return string A unique token string. + */ + protected function generateToken(): string + { + return bin2hex(random_bytes(16)); + } + + /** + * Generate the full key for a resource. + * + * @param string $resource The resource identifier. + * @return string The prefixed key. + * @throws \Cake\Lock\Exception\InvalidArgumentException If resource is invalid. + */ + protected function key(string $resource): string + { + $this->ensureValidResource($resource); + + $key = preg_replace('/[\s]+/', '_', $resource); + + return $this->getConfig('prefix') . $key; + } + + /** + * Ensure the resource identifier is valid. + * + * @param string $resource The resource to validate. + * @return void + * @throws \Cake\Lock\Exception\InvalidArgumentException If resource is invalid. + */ + protected function ensureValidResource(string $resource): void + { + if ($resource === '') { + throw new InvalidArgumentException('Lock resource must be a non-empty string.'); + } + } + + /** + * Acquire a lock, waiting up to $timeout seconds if necessary. + * + * This is a default blocking implementation that repeatedly + * attempts to acquire the lock. Engines may override this + * with more efficient implementations. + * + * @param string $resource The resource identifier to lock. + * @param int $ttl Time-to-live in seconds for the lock. + * @param int $timeout Maximum time in seconds to wait for the lock. + * @param int $retryInterval Milliseconds to wait between retry attempts. + * @return \Cake\Lock\AcquiredLock|null Returns an AcquiredLock on success, null on timeout. + */ + public function acquireBlocking( + string $resource, + int $ttl = 300, + int $timeout = 10, + int $retryInterval = 100, + ): ?AcquiredLock { + $deadline = microtime(true) + $timeout; + + while (microtime(true) < $deadline) { + $lock = $this->acquire($resource, $ttl); + if ($lock !== null) { + return $lock; + } + + usleep($retryInterval * 1000); + } + + return null; + } +} diff --git a/src/Lock/LockInterface.php b/src/Lock/LockInterface.php new file mode 100644 index 00000000000..8cbe380b6a6 --- /dev/null +++ b/src/Lock/LockInterface.php @@ -0,0 +1,96 @@ + + */ +class LockRegistry extends ObjectRegistry +{ + /** + * Resolve a lock engine classname. + * + * @param string $class Partial classname to resolve. + * @return class-string|null Either the correct classname or null. + */ + protected function _resolveClassName(string $class): ?string + { + /** @var class-string|null */ + return App::className($class, 'Lock/Engine', 'LockEngine'); + } + + /** + * Throws an exception when a lock engine is missing. + * + * @param string $class The classname that is missing. + * @param string|null $plugin The plugin the lock engine is missing in. + * @return void + * @throws \BadMethodCallException + */ + protected function _throwMissingClassError(string $class, ?string $plugin): void + { + throw new BadMethodCallException(sprintf('Lock engine `%s` is not available.', $class)); + } + + /** + * Create the lock engine instance. + * + * @param TEngine|class-string $class The classname or object to make. + * @param string $alias The alias of the object. + * @param array $config An array of settings for the lock engine. + * @return TEngine The constructed LockEngine. + * @throws \Cake\Lock\Exception\LockException When the lock engine cannot be initialized. + */ + protected function _create(object|string $class, string $alias, array $config): LockEngine + { + if (is_object($class)) { + $instance = $class; + } else { + $instance = new $class($config); + } + unset($config['className']); + + assert($instance instanceof LockEngine, 'Lock engines must extend `' . LockEngine::class . '`.'); + + if (!$instance->init($config)) { + throw new LockException( + sprintf( + 'Lock engine `%s` is not properly configured.', + $instance::class, + ), + ); + } + + return $instance; + } + + /** + * Remove a single adapter from the registry. + * + * @param string $name The adapter name. + * @return $this + */ + public function unload(string $name) + { + unset($this->_loaded[$name]); + + return $this; + } +} diff --git a/src/Log/Engine/ArrayLog.php b/src/Log/Engine/ArrayLog.php new file mode 100644 index 00000000000..2c14fd72df5 --- /dev/null +++ b/src/Log/Engine/ArrayLog.php @@ -0,0 +1,87 @@ + + */ + protected array $_defaultConfig = [ + 'levels' => [], + 'scopes' => [], + 'formatter' => [ + 'className' => DefaultFormatter::class, + 'includeDate' => false, + ], + ]; + + /** + * Captured messages + * + * @var array + */ + protected array $content = []; + + /** + * Implements writing to the internal storage. + * + * @param mixed $level The severity level of log you are making. + * @param \Stringable|string $message The message you want to log. + * @param array $context Additional information about the logged message + * @return void + * @see \Cake\Log\Log::$_levels + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint + */ + public function log($level, Stringable|string $message, array $context = []): void + { + $message = $this->interpolate($message, $context); + $this->content[] = $this->formatter->format($level, $message, $context); + } + + /** + * Read the internal storage + * + * @return array + */ + public function read(): array + { + return $this->content; + } + + /** + * Reset internal storage. + * + * @return void + */ + public function clear(): void + { + $this->content = []; + } +} diff --git a/src/Log/Engine/BaseLog.php b/src/Log/Engine/BaseLog.php index c35e533631a..19594c88f00 100644 --- a/src/Log/Engine/BaseLog.php +++ b/src/Log/Engine/BaseLog.php @@ -1,4 +1,6 @@ */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ 'levels' => [], - 'scopes' => [] + 'scopes' => [], + 'formatter' => DefaultFormatter::class, ]; + /** + * @var \Cake\Log\Formatter\AbstractFormatter + */ + protected AbstractFormatter $formatter; + /** * __construct method * - * @param array $config Configuration array + * @param array $config Configuration array */ public function __construct(array $config = []) { $this->setConfig($config); - if (!is_array($this->_config['scopes']) && $this->_config['scopes'] !== false) { + // Backwards compatibility shim as we can't deprecate using false because of how 4.x merges configuration. + if ($this->_config['scopes'] === false) { + deprecationWarning('5.0.0', 'Using `false` to disable logging scopes is deprecated. Use `null` instead.'); + $this->_config['scopes'] = null; + } + if ($this->_config['scopes'] !== null) { $this->_config['scopes'] = (array)$this->_config['scopes']; } - if (!is_array($this->_config['levels'])) { - $this->_config['levels'] = (array)$this->_config['levels']; - } + $this->_config['levels'] = (array)$this->_config['levels']; if (!empty($this->_config['types']) && empty($this->_config['levels'])) { $this->_config['levels'] = (array)$this->_config['types']; } + + /** @var \Cake\Log\Formatter\AbstractFormatter|array|class-string<\Cake\Log\Formatter\AbstractFormatter> $formatter */ + $formatter = $this->_config['formatter'] ?? DefaultFormatter::class; + if (!is_object($formatter)) { + if (is_array($formatter)) { + /** @var class-string<\Cake\Log\Formatter\AbstractFormatter> $class */ + $class = $formatter['className']; + $options = $formatter; + } else { + $class = $formatter; + $options = []; + } + $formatter = new $class($options); + } + + $this->formatter = $formatter; } /** * Get the levels this logger is interested in. * - * @return array + * @return array */ - public function levels() + public function levels(): array { return $this->_config['levels']; } @@ -74,42 +103,94 @@ public function levels() /** * Get the scopes this logger is interested in. * - * @return array + * @return array|null */ - public function scopes() + public function scopes(): ?array { return $this->_config['scopes']; } /** - * Converts to string the provided data so it can be logged. The context - * can optionally be used by log engines to interpolate variables - * or add additional info to the logged message. + * Replaces placeholders in message string with context values. * - * @param mixed $data The data to be converted to string and logged. - * @param array $context Additional logging information for the message. + * @param \Stringable|string $message Formatted message. + * @param array $context Context for placeholder values. * @return string */ - protected function _format($data, array $context = []) + protected function interpolate(Stringable|string $message, array $context = []): string { - if (is_string($data)) { - return $data; - } - - $isObject = is_object($data); + $message = (string)$message; - if ($isObject && $data instanceof EntityInterface) { - return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + if (!str_contains($message, '{') && !str_contains($message, '}')) { + return $message; } - if ($isObject && method_exists($data, '__toString')) { - return (string)$data; + $found = preg_match_all( + '/(?getArrayCopy(), $jsonFlags); + continue; + } + + if ($value instanceof Serializable) { + $replacements['{' . $key . '}'] = $value->serialize(); + continue; + } + + if (is_object($value)) { + if (method_exists($value, 'toArray')) { + $replacements['{' . $key . '}'] = json_encode($value->toArray(), $jsonFlags); + continue; + } + + if ($value instanceof Serializable) { + $replacements['{' . $key . '}'] = serialize($value); + continue; + } + + if ($value instanceof Stringable) { + $replacements['{' . $key . '}'] = (string)$value; + continue; + } + + if (method_exists($value, '__debugInfo')) { + $replacements['{' . $key . '}'] = json_encode($value->__debugInfo(), $jsonFlags); + continue; + } + } + + $replacements['{' . $key . '}'] = sprintf('[unhandled value of type %s]', get_debug_type($value)); } - return print_r($data, true); + return str_replace(array_keys($replacements), $replacements, $message); } } diff --git a/src/Log/Engine/ConsoleLog.php b/src/Log/Engine/ConsoleLog.php index 0afc1dfe025..49dbc7fcd98 100644 --- a/src/Log/Engine/ConsoleLog.php +++ b/src/Log/Engine/ConsoleLog.php @@ -1,4 +1,6 @@ */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ 'stream' => 'php://stderr', 'levels' => null, 'scopes' => [], - 'outputAs' => 'see constructor' + 'outputAs' => null, + 'formatter' => [ + 'className' => DefaultFormatter::class, + 'includeTags' => true, + ], ]; /** @@ -40,7 +47,7 @@ class ConsoleLog extends BaseLog * * @var \Cake\Console\ConsoleOutput */ - protected $_output; + protected ConsoleOutput $_output; /** * Constructs a new Console Logger. @@ -51,20 +58,13 @@ class ConsoleLog extends BaseLog * - `scopes` string or array, scopes the engine is interested in * - `stream` the path to save logs on. * - `outputAs` integer or ConsoleOutput::[RAW|PLAIN|COLOR] + * - `dateFormat` PHP date() format. * - * @param array $config Options for the FileLog, see above. + * @param array $config Options for the FileLog, see above. * @throws \InvalidArgumentException */ public function __construct(array $config = []) { - if ((DIRECTORY_SEPARATOR === '\\' && !(bool)env('ANSICON') && env('ConEmuANSI') !== 'ON') || - (function_exists('posix_isatty') && !posix_isatty($this->_output)) - ) { - $this->_defaultConfig['outputAs'] = ConsoleOutput::PLAIN; - } else { - $this->_defaultConfig['outputAs'] = ConsoleOutput::COLOR; - } - parent::__construct($config); $config = $this->_config; @@ -75,22 +75,25 @@ public function __construct(array $config = []) } else { throw new InvalidArgumentException('`stream` not a ConsoleOutput nor string'); } - $this->_output->setOutputAs($config['outputAs']); + + if (isset($config['outputAs'])) { + $this->_output->setOutputAs($config['outputAs']); + } } /** * Implements writing to console. * - * @param string $level The severity level of log you are making. - * @param string $message The message you want to log. + * @param mixed $level The severity level of log you are making. + * @param \Stringable|string $message The message you want to log. * @param array $context Additional information about the logged message - * @return bool success of write. + * @return void + * @see \Cake\Log\Log::$_levels + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint */ - public function log($level, $message, array $context = []) + public function log($level, Stringable|string $message, array $context = []): void { - $message = $this->_format($message, $context); - $output = date('Y-m-d H:i:s') . ' ' . ucfirst($level) . ': ' . $message; - - return $this->_output->write(sprintf('<%s>%s', $level, $output, $level)); + $message = $this->interpolate($message, $context); + $this->_output->write($this->formatter->format($level, $message, $context)); } } diff --git a/src/Log/Engine/FileLog.php b/src/Log/Engine/FileLog.php index 5e424be3421..5538d1e541b 100644 --- a/src/Log/Engine/FileLog.php +++ b/src/Log/Engine/FileLog.php @@ -1,4 +1,6 @@ */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ 'path' => null, 'file' => null, 'types' => null, @@ -51,51 +54,50 @@ class FileLog extends BaseLog 'rotate' => 10, 'size' => 10485760, // 10MB 'mask' => null, + 'dirMask' => 0777, + 'formatter' => [ + 'className' => DefaultFormatter::class, + ], ]; /** * Path to save log files on. * - * @var string|null + * @var string */ - protected $_path; + protected string $_path; /** * The name of the file to save logs into. * * @var string|null */ - protected $_file; + protected ?string $_file = null; /** * Max file size, used for log file rotation. * * @var int|null */ - protected $_size; + protected ?int $_size = null; /** * Sets protected properties based on config provided * - * @param array $config Configuration array + * @param array $config Configuration array */ public function __construct(array $config = []) { parent::__construct($config); - if (!empty($this->_config['path'])) { - $this->_path = $this->_config['path']; - } - if ($this->_path !== null && - Configure::read('debug') && - !is_dir($this->_path) - ) { - mkdir($this->_path, 0775, true); + $this->_path = $this->getConfig('path', sys_get_temp_dir() . DIRECTORY_SEPARATOR); + if (!is_dir($this->_path)) { + mkdir($this->_path, $this->_config['dirMask'] ^ umask(), true); } if (!empty($this->_config['file'])) { $this->_file = $this->_config['file']; - if (substr($this->_file, -4) !== '.log') { + if (!str_ends_with($this->_file, '.log')) { $this->_file .= '.log'; } } @@ -112,16 +114,18 @@ public function __construct(array $config = []) /** * Implements writing to log files. * - * @param string $level The severity level of the message being written. - * See Cake\Log\Log::$_levels for list of possible levels. - * @param string $message The message you want to log. + * @param mixed $level The severity level of the message being written. + * @param \Stringable|string $message The message you want to log. * @param array $context Additional information about the logged message - * @return bool success of write. + * @return void + * @see \Cake\Log\Log::$_levels + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint */ - public function log($level, $message, array $context = []) + public function log($level, Stringable|string $message, array $context = []): void { - $message = $this->_format($message, $context); - $output = date('Y-m-d H:i:s') . ' ' . ucfirst($level) . ': ' . $message . "\n"; + $message = $this->interpolate($message, $context); + $message = $this->formatter->format($level, $message, $context); + $filename = $this->_getFilename($level); if ($this->_size) { $this->_rotateFile($filename); @@ -130,23 +134,23 @@ public function log($level, $message, array $context = []) $pathname = $this->_path . $filename; $mask = $this->_config['mask']; if (!$mask) { - return file_put_contents($pathname, $output, FILE_APPEND); + file_put_contents($pathname, $message . "\n", FILE_APPEND); + + return; } - $exists = file_exists($pathname); - $result = file_put_contents($pathname, $output, FILE_APPEND); + $exists = is_file($pathname); + file_put_contents($pathname, $message . "\n", FILE_APPEND); static $selfError = false; if (!$selfError && !$exists && !chmod($pathname, (int)$mask)) { $selfError = true; trigger_error(vsprintf( - 'Could not apply permission mask "%s" on log file "%s"', - [$mask, $pathname] + 'Could not apply permission mask `%s` on log file `%s`', + [$mask, $pathname], ), E_USER_WARNING); $selfError = false; } - - return $result; } /** @@ -155,7 +159,7 @@ public function log($level, $message, array $context = []) * @param string $level The level of log. * @return string File name */ - protected function _getFilename($level) + protected function _getFilename(string $level): string { $debugTypes = ['notice', 'info', 'debug']; @@ -163,7 +167,7 @@ protected function _getFilename($level) $filename = $this->_file; } elseif ($level === 'error' || $level === 'warning') { $filename = 'error.log'; - } elseif (in_array($level, $debugTypes)) { + } elseif (in_array($level, $debugTypes, true)) { $filename = 'debug.log'; } else { $filename = $level . '.log'; @@ -180,12 +184,13 @@ protected function _getFilename($level) * @return bool|null True if rotated successfully or false in case of error. * Null if file doesn't need to be rotated. */ - protected function _rotateFile($filename) + protected function _rotateFile(string $filename): ?bool { $filePath = $this->_path . $filename; clearstatcache(true, $filePath); - if (!file_exists($filePath) || + if ( + !is_file($filePath) || filesize($filePath) < $this->_size ) { return null; @@ -202,7 +207,7 @@ protected function _rotateFile($filename) if ($files) { $filesToDelete = count($files) - $rotate; while ($filesToDelete > 0) { - unlink(array_shift($files)); + unlink((string)array_shift($files)); $filesToDelete--; } } diff --git a/src/Log/Engine/SyslogLog.php b/src/Log/Engine/SyslogLog.php index 503c9ce2afc..543a2d51a9c 100644 --- a/src/Log/Engine/SyslogLog.php +++ b/src/Log/Engine/SyslogLog.php @@ -1,4 +1,6 @@ 'Syslog', * 'levels' => ['emergency', 'alert', 'critical', 'error'], - * 'format' => "%s: My-App - %s", - * 'prefix' => 'Web Server 01' + * 'prefix' => 'Web Server 01', * ]); * ``` * - * @var array + * @var array */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ 'levels' => [], 'scopes' => [], - 'format' => '%s: %s', 'flag' => LOG_ODELAY, 'prefix' => '', - 'facility' => LOG_USER + 'facility' => LOG_USER, + 'formatter' => [ + 'className' => DefaultFormatter::class, + 'includeDate' => false, + ], ]; /** * Used to map the string names back to their LOG_* constants * - * @var int[] + * @var array */ - protected $_levelMap = [ + protected array $_levelMap = [ 'emergency' => LOG_EMERG, 'alert' => LOG_ALERT, 'critical' => LOG_CRIT, @@ -70,7 +76,7 @@ class SyslogLog extends BaseLog 'warning' => LOG_WARNING, 'notice' => LOG_NOTICE, 'info' => LOG_INFO, - 'debug' => LOG_DEBUG + 'debug' => LOG_DEBUG, ]; /** @@ -78,7 +84,7 @@ class SyslogLog extends BaseLog * * @var bool */ - protected $_open = false; + protected bool $_open = false; /** * Writes a message to syslog @@ -86,12 +92,14 @@ class SyslogLog extends BaseLog * Map the $level back to a LOG_ constant value, split multi-line messages into multiple * log messages, pass all messages through the format defined in the configuration * - * @param string $level The severity level of log you are making. - * @param string $message The message you want to log. + * @param mixed $level The severity level of log you are making. + * @param \Stringable|string $message The message you want to log. * @param array $context Additional information about the logged message - * @return bool success of write. + * @return void + * @see \Cake\Log\Log::$_levels + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint */ - public function log($level, $message, array $context = []) + public function log($level, Stringable|string $message, array $context = []): void { if (!$this->_open) { $config = $this->_config; @@ -104,13 +112,10 @@ public function log($level, $message, array $context = []) $priority = $this->_levelMap[$level]; } - $messages = explode("\n", $this->_format($message, $context)); - foreach ($messages as $message) { - $message = sprintf($this->_config['format'], $level, $message); - $this->_write($priority, $message); + $lines = explode("\n", $this->interpolate($message, $context)); + foreach ($lines as $line) { + $this->_write($priority, $this->formatter->format($level, $line, $context)); } - - return true; } /** @@ -122,20 +127,20 @@ public function log($level, $message, array $context = []) * @param int $facility the stream or facility to log to * @return void */ - protected function _open($ident, $options, $facility) + protected function _open(string $ident, int $options, int $facility): void { openlog($ident, $options, $facility); } /** * Extracts the call to syslog() in order to run unit tests on it. This function - * will perform the actual write in the system logger + * will perform the actual write operation in the system logger * * @param int $priority Message priority. * @param string $message Message to log. * @return bool */ - protected function _write($priority, $message) + protected function _write(int $priority, string $message): bool { return syslog($priority, $message); } diff --git a/src/Log/Formatter/AbstractFormatter.php b/src/Log/Formatter/AbstractFormatter.php new file mode 100644 index 00000000000..5629d780030 --- /dev/null +++ b/src/Log/Formatter/AbstractFormatter.php @@ -0,0 +1,50 @@ + + */ + protected array $_defaultConfig = [ + ]; + + /** + * @param array $config Config options + */ + public function __construct(array $config = []) + { + $this->setConfig($config); + } + + /** + * Formats message. + * + * @param mixed $level Logging level + * @param string $message Message string + * @param array $context Message context + * @return string Formatted message + */ + abstract public function format(mixed $level, string $message, array $context = []): string; +} diff --git a/src/Log/Formatter/DefaultFormatter.php b/src/Log/Formatter/DefaultFormatter.php new file mode 100644 index 00000000000..75bc044f14d --- /dev/null +++ b/src/Log/Formatter/DefaultFormatter.php @@ -0,0 +1,50 @@ + + */ + protected array $_defaultConfig = [ + 'dateFormat' => 'Y-m-d H:i:s', + 'includeTags' => false, + 'includeDate' => true, + ]; + + /** + * @inheritDoc + */ + public function format($level, string $message, array $context = []): string + { + if ($this->_config['includeDate']) { + $message = sprintf('%s %s: %s', (new DateTime())->format($this->_config['dateFormat']), $level, $message); + } else { + $message = sprintf('%s: %s', $level, $message); + } + if ($this->_config['includeTags']) { + return sprintf('<%s>%s', $level, $message, $level); + } + + return $message; + } +} diff --git a/src/Log/Formatter/JsonFormatter.php b/src/Log/Formatter/JsonFormatter.php new file mode 100644 index 00000000000..55e322ec7fc --- /dev/null +++ b/src/Log/Formatter/JsonFormatter.php @@ -0,0 +1,42 @@ + + */ + protected array $_defaultConfig = [ + 'dateFormat' => DATE_ATOM, + 'flags' => JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, + 'appendNewline' => true, + ]; + + /** + * @inheritDoc + */ + public function format($level, string $message, array $context = []): string + { + $log = ['date' => date($this->_config['dateFormat']), 'level' => (string)$level, 'message' => $message]; + $json = json_encode($log, JSON_THROW_ON_ERROR | $this->_config['flags']); + + return $this->_config['appendNewline'] ? $json . "\n" : $json; + } +} diff --git a/src/Log/LICENSE.txt b/src/Log/LICENSE.txt index 0c4b7932c31..b938c9e8ed3 100644 --- a/src/Log/LICENSE.txt +++ b/src/Log/LICENSE.txt @@ -1,7 +1,7 @@ The MIT License (MIT) CakePHP(tm) : The Rapid Development PHP Framework (https://cakephp.org) -Copyright (c) 2005-2016, Cake Software Foundation, Inc. (https://cakefoundation.org) +Copyright (c) 2005-2020, Cake Software Foundation, Inc. (https://cakefoundation.org) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/Log/Log.php b/src/Log/Log.php index 894c2f03d51..ffecaf2b54f 100644 --- a/src/Log/Log.php +++ b/src/Log/Log.php @@ -1,4 +1,6 @@ */ - protected static $_dsnClassMap = [ - 'console' => 'Cake\Log\Engine\ConsoleLog', - 'file' => 'Cake\Log\Engine\FileLog', - 'syslog' => 'Cake\Log\Engine\SyslogLog', + protected static array $_dsnClassMap = [ + 'console' => Engine\ConsoleLog::class, + 'file' => Engine\FileLog::class, + 'syslog' => Engine\SyslogLog::class, ]; /** - * Internal flag for tracking whether or not configuration has been changed. + * Internal flag for tracking whether configuration has been changed. * * @var bool */ - protected static $_dirtyConfig = false; + protected static bool $_dirtyConfig = false; /** * LogEngineRegistry class * - * @var \Cake\Log\LogEngineRegistry|null + * @var \Cake\Log\LogEngineRegistry */ - protected static $_registry; + protected static LogEngineRegistry $_registry; /** * Handled log levels * - * @var array + * @var array */ - protected static $_levels = [ + protected static array $_levels = [ 'emergency', 'alert', 'critical', @@ -146,16 +150,16 @@ class Log 'warning', 'notice', 'info', - 'debug' + 'debug', ]; /** * Log levels as detailed in RFC 5424 * https://tools.ietf.org/html/rfc5424 * - * @var array + * @var array */ - protected static $_levelMap = [ + protected static array $_levelMap = [ 'emergency' => LOG_EMERG, 'alert' => LOG_ALERT, 'critical' => LOG_CRIT, @@ -167,37 +171,28 @@ class Log ]; /** - * Initializes registry and configurations + * Creates registry if doesn't exist and creates all defined logging + * adapters if config isn't loaded. * - * @return void + * @return \Cake\Log\LogEngineRegistry */ - protected static function _init() + protected static function getRegistry(): LogEngineRegistry { - if (empty(static::$_registry)) { - static::$_registry = new LogEngineRegistry(); - } + static::$_registry ??= new LogEngineRegistry(); + if (static::$_dirtyConfig) { - static::_loadConfig(); + foreach (static::$_config as $name => $properties) { + if (isset($properties['engine'])) { + $properties['className'] = $properties['engine']; + } + if (!static::$_registry->has((string)$name)) { + static::$_registry->load((string)$name, $properties); + } + } } static::$_dirtyConfig = false; - } - /** - * Load the defined configuration and create all the defined logging - * adapters. - * - * @return void - */ - protected static function _loadConfig() - { - foreach (static::$_config as $name => $properties) { - if (isset($properties['engine'])) { - $properties['className'] = $properties['engine']; - } - if (!static::$_registry->has($name)) { - static::$_registry->load($name, $properties); - } - } + return static::$_registry; } /** @@ -210,9 +205,11 @@ protected static function _loadConfig() * * @return void */ - public static function reset() + public static function reset(): void { - static::$_registry = null; + if (isset(static::$_registry)) { + static::$_registry->reset(); + } static::$_config = []; static::$_dirtyConfig = true; } @@ -223,9 +220,9 @@ public static function reset() * Call this method to obtain current * level configuration. * - * @return array active log levels + * @return array Active log levels */ - public static function levels() + public static function levels(): array { return static::$_levels; } @@ -265,12 +262,12 @@ public static function levels() * Log::setConfig($arrayOfConfig); * ``` * - * @param string|array $key The name of the logger config, or an array of multiple configs. - * @param array|null $config An array of name => config data for adapter. + * @param array|string $key The name of the logger config, or an array of multiple configs. + * @param \Psr\Log\LoggerInterface|\Closure|array|null $config An array of name => config data for adapter. * @return void * @throws \BadMethodCallException When trying to modify an existing config. */ - public static function setConfig($key, $config = null) + public static function setConfig(array|string $key, LoggerInterface|Closure|array|null $config = null): void { static::_setConfig($key, $config); static::$_dirtyConfig = true; @@ -280,20 +277,20 @@ public static function setConfig($key, $config = null) * Get a logging engine. * * @param string $name Key name of a configured adapter to get. - * @return \Cake\Log\Engine\BaseLog|false Instance of BaseLog or false if not found + * @return \Psr\Log\LoggerInterface|null Instance of LoggerInterface or null if not found */ - public static function engine($name) + public static function engine(string $name): ?LoggerInterface { - static::_init(); - if (static::$_registry->{$name}) { - return static::$_registry->{$name}; + $registry = static::getRegistry(); + if (!$registry->{$name}) { + return null; } - return false; + return $registry->{$name}; } /** - * Writes the given message and type to all of the configured log adapters. + * Writes the given message and type to all the configured log adapters. * Configured adapters are passed both the $level and $message variables. $level * is one of the following strings/values. * @@ -335,26 +332,25 @@ public static function engine($name) * then the logged message will be ignored and silently dropped. You can check if this has happened * by inspecting the return of write(). If false the message was not handled. * - * @param int|string $level The severity level of the message being written. + * @param string|int $level The severity level of the message being written. * The value must be an integer or string matching a known level. - * @param mixed $message Message content to log - * @param string|array $context Additional data to be used for logging the message. + * @param \Stringable|string $message Message content to log + * @param array|string $context Additional data to be used for logging the message. * The special `scope` key can be passed to be used for further filtering of the - * log engines to be used. If a string or a numerically index array is passed, it + * log engines to be used. If a string or a numerically indexed array is passed, it * will be treated as the `scope` key. - * See Cake\Log\Log::setConfig() for more information on logging scopes. + * See {@link \Cake\Log\Log::setConfig()} for more information on logging scopes. * @return bool Success * @throws \InvalidArgumentException If invalid level is passed. */ - public static function write($level, $message, $context = []) + public static function write(string|int $level, Stringable|string $message, array|string $context = []): bool { - static::_init(); - if (is_int($level) && in_array($level, static::$_levelMap)) { - $level = array_search($level, static::$_levelMap); + if (is_int($level) && in_array($level, static::$_levelMap, true)) { + $level = array_search($level, static::$_levelMap, true); } - if (!in_array($level, static::$_levels)) { - throw new InvalidArgumentException(sprintf('Invalid log level "%s"', $level)); + if (!in_array($level, static::$_levels, true)) { + throw new InvalidArgumentException(sprintf('Invalid log level `%s`', $level)); } $logged = false; @@ -364,20 +360,20 @@ public static function write($level, $message, $context = []) } $context += ['scope' => []]; - foreach (static::$_registry->loaded() as $streamName) { - $logger = static::$_registry->{$streamName}; - $levels = $scopes = null; + $registry = static::getRegistry(); + foreach ($registry->loaded() as $streamName) { + /** @var \Psr\Log\LoggerInterface $logger */ + $logger = $registry->{$streamName}; + $levels = null; + $scopes = null; if ($logger instanceof BaseLog) { $levels = $logger->levels(); $scopes = $logger->scopes(); } - if ($scopes === null) { - $scopes = []; - } - $correctLevel = empty($levels) || in_array($level, $levels); - $inScope = $scopes === false && empty($context['scope']) || $scopes === [] || + $correctLevel = empty($levels) || in_array($level, $levels, true); + $inScope = $scopes === null && empty($context['scope']) || $scopes === [] || is_array($scopes) && array_intersect((array)$context['scope'], $scopes); if ($correctLevel && $inScope) { @@ -392,15 +388,15 @@ public static function write($level, $message, $context = []) /** * Convenience method to log emergency messages * - * @param string $message log message - * @param string|array $context Additional data to be used for logging the message. + * @param \Stringable|string $message log message + * @param array|string $context Additional data to be used for logging the message. * The special `scope` key can be passed to be used for further filtering of the - * log engines to be used. If a string or a numerically index array is passed, it + * log engines to be used. If a string or a numerically indexed array is passed, it * will be treated as the `scope` key. - * See Cake\Log\Log::setConfig() for more information on logging scopes. + * See {@link \Cake\Log\Log::setConfig()} for more information on logging scopes. * @return bool Success */ - public static function emergency($message, $context = []) + public static function emergency(Stringable|string $message, array|string $context = []): bool { return static::write(__FUNCTION__, $message, $context); } @@ -408,15 +404,15 @@ public static function emergency($message, $context = []) /** * Convenience method to log alert messages * - * @param string $message log message - * @param string|array $context Additional data to be used for logging the message. + * @param \Stringable|string $message log message + * @param array|string $context Additional data to be used for logging the message. * The special `scope` key can be passed to be used for further filtering of the - * log engines to be used. If a string or a numerically index array is passed, it + * log engines to be used. If a string or a numerically indexed array is passed, it * will be treated as the `scope` key. - * See Cake\Log\Log::setConfig() for more information on logging scopes. + * See {@link \Cake\Log\Log::setConfig()} for more information on logging scopes. * @return bool Success */ - public static function alert($message, $context = []) + public static function alert(Stringable|string $message, array|string $context = []): bool { return static::write(__FUNCTION__, $message, $context); } @@ -424,15 +420,15 @@ public static function alert($message, $context = []) /** * Convenience method to log critical messages * - * @param string $message log message - * @param string|array $context Additional data to be used for logging the message. + * @param \Stringable|string $message log message + * @param array|string $context Additional data to be used for logging the message. * The special `scope` key can be passed to be used for further filtering of the - * log engines to be used. If a string or a numerically index array is passed, it + * log engines to be used. If a string or a numerically indexed array is passed, it * will be treated as the `scope` key. - * See Cake\Log\Log::setConfig() for more information on logging scopes. + * See {@link \Cake\Log\Log::setConfig()} for more information on logging scopes. * @return bool Success */ - public static function critical($message, $context = []) + public static function critical(Stringable|string $message, array|string $context = []): bool { return static::write(__FUNCTION__, $message, $context); } @@ -440,15 +436,15 @@ public static function critical($message, $context = []) /** * Convenience method to log error messages * - * @param string $message log message - * @param string|array $context Additional data to be used for logging the message. + * @param \Stringable|string $message log message + * @param array|string $context Additional data to be used for logging the message. * The special `scope` key can be passed to be used for further filtering of the - * log engines to be used. If a string or a numerically index array is passed, it + * log engines to be used. If a string or a numerically indexed array is passed, it * will be treated as the `scope` key. - * See Cake\Log\Log::setConfig() for more information on logging scopes. + * See {@link \Cake\Log\Log::setConfig()} for more information on logging scopes. * @return bool Success */ - public static function error($message, $context = []) + public static function error(Stringable|string $message, array|string $context = []): bool { return static::write(__FUNCTION__, $message, $context); } @@ -456,15 +452,15 @@ public static function error($message, $context = []) /** * Convenience method to log warning messages * - * @param string $message log message - * @param string|array $context Additional data to be used for logging the message. + * @param \Stringable|string $message log message + * @param array|string $context Additional data to be used for logging the message. * The special `scope` key can be passed to be used for further filtering of the - * log engines to be used. If a string or a numerically index array is passed, it + * log engines to be used. If a string or a numerically indexed array is passed, it * will be treated as the `scope` key. - * See Cake\Log\Log::setConfig() for more information on logging scopes. + * See {@link \Cake\Log\Log::setConfig()} for more information on logging scopes. * @return bool Success */ - public static function warning($message, $context = []) + public static function warning(Stringable|string $message, array|string $context = []): bool { return static::write(__FUNCTION__, $message, $context); } @@ -472,15 +468,15 @@ public static function warning($message, $context = []) /** * Convenience method to log notice messages * - * @param string $message log message - * @param string|array $context Additional data to be used for logging the message. + * @param \Stringable|string $message log message + * @param array|string $context Additional data to be used for logging the message. * The special `scope` key can be passed to be used for further filtering of the - * log engines to be used. If a string or a numerically index array is passed, it + * log engines to be used. If a string or a numerically indexed array is passed, it * will be treated as the `scope` key. - * See Cake\Log\Log::setConfig() for more information on logging scopes. + * See {@link \Cake\Log\Log::setConfig()} for more information on logging scopes. * @return bool Success */ - public static function notice($message, $context = []) + public static function notice(Stringable|string $message, array|string $context = []): bool { return static::write(__FUNCTION__, $message, $context); } @@ -488,15 +484,15 @@ public static function notice($message, $context = []) /** * Convenience method to log debug messages * - * @param string $message log message - * @param string|array $context Additional data to be used for logging the message. + * @param \Stringable|string $message log message + * @param array|string $context Additional data to be used for logging the message. * The special `scope` key can be passed to be used for further filtering of the - * log engines to be used. If a string or a numerically index array is passed, it + * log engines to be used. If a string or a numerically indexed array is passed, it * will be treated as the `scope` key. - * See Cake\Log\Log::setConfig() for more information on logging scopes. + * See {@link \Cake\Log\Log::setConfig()} for more information on logging scopes. * @return bool Success */ - public static function debug($message, $context = []) + public static function debug(Stringable|string $message, array|string $context = []): bool { return static::write(__FUNCTION__, $message, $context); } @@ -504,15 +500,15 @@ public static function debug($message, $context = []) /** * Convenience method to log info messages * - * @param string $message log message - * @param string|array $context Additional data to be used for logging the message. + * @param \Stringable|string $message log message + * @param array|string $context Additional data to be used for logging the message. * The special `scope` key can be passed to be used for further filtering of the - * log engines to be used. If a string or a numerically index array is passed, it + * log engines to be used. If a string or a numerically indexed array is passed, it * will be treated as the `scope` key. - * See Cake\Log\Log::setConfig() for more information on logging scopes. + * See {@link \Cake\Log\Log::setConfig()} for more information on logging scopes. * @return bool Success */ - public static function info($message, $context = []) + public static function info(Stringable|string $message, array|string $context = []): bool { return static::write(__FUNCTION__, $message, $context); } diff --git a/src/Log/LogEngineRegistry.php b/src/Log/LogEngineRegistry.php index 0fa534f7936..570badf42f5 100644 --- a/src/Log/LogEngineRegistry.php +++ b/src/Log/LogEngineRegistry.php @@ -1,4 +1,6 @@ */ class LogEngineRegistry extends ObjectRegistry { - /** * Resolve a logger classname. * * Part of the template method for Cake\Core\ObjectRegistry::load() * * @param string $class Partial classname to resolve. - * @return string|false Either the correct classname or false. + * @return class-string<\Psr\Log\LoggerInterface>|null Either the correct class name or null. */ - protected function _resolveClassName($class) + protected function _resolveClassName(string $class): ?string { - if (is_object($class)) { - return $class; - } - + /** @var class-string<\Psr\Log\LoggerInterface>|null */ return App::className($class, 'Log/Engine', 'Log'); } @@ -48,13 +48,13 @@ protected function _resolveClassName($class) * Part of the template method for Cake\Core\ObjectRegistry::load() * * @param string $class The classname that is missing. - * @param string $plugin The plugin the logger is missing in. + * @param string|null $plugin The plugin the logger is missing in. * @return void - * @throws \RuntimeException + * @throws \Cake\Core\Exception\CakeException */ - protected function _throwMissingClassError($class, $plugin) + protected function _throwMissingClassError(string $class, ?string $plugin): void { - throw new RuntimeException(sprintf('Could not load class %s', $class)); + throw new CakeException(sprintf('Could not load class `%s`.', $class)); } /** @@ -62,43 +62,35 @@ protected function _throwMissingClassError($class, $plugin) * * Part of the template method for Cake\Core\ObjectRegistry::load() * - * @param string|\Psr\Log\LoggerInterface $class The classname or object to make. + * @param \Psr\Log\LoggerInterface|callable|class-string<\Psr\Log\LoggerInterface> $class The classname or object to make. * @param string $alias The alias of the object. - * @param array $settings An array of settings to use for the logger. + * @param array $config An array of settings to use for the logger. * @return \Psr\Log\LoggerInterface The constructed logger class. - * @throws \RuntimeException when an object doesn't implement the correct interface. */ - protected function _create($class, $alias, $settings) + protected function _create(callable|object|string $class, string $alias, array $config): LoggerInterface { - if (is_callable($class)) { - $class = $class($alias); + if (is_string($class)) { + /** @var class-string<\Psr\Log\LoggerInterface> $class */ + return new $class($config); } - if (is_object($class)) { - $instance = $class; - } - - if (!isset($instance)) { - $instance = new $class($settings); - } - - if ($instance instanceof LoggerInterface) { - return $instance; + if (is_callable($class)) { + return $class($alias); } - throw new RuntimeException( - 'Loggers must implement Psr\Log\LoggerInterface.' - ); + return $class; } /** * Remove a single logger from the registry. * * @param string $name The logger name. - * @return void + * @return $this */ - public function unload($name) + public function unload(string $name) { unset($this->_loaded[$name]); + + return $this; } } diff --git a/src/Log/LogTrait.php b/src/Log/LogTrait.php index 31b943e87fd..6843f3300f0 100644 --- a/src/Log/LogTrait.php +++ b/src/Log/LogTrait.php @@ -1,4 +1,6 @@ 'FileLog', +Log::setConfig('local', [ + 'className' => 'File', 'levels' => ['notice', 'info', 'debug'], 'file' => '/path/to/file.log', ]); // Fully namespaced name. -Log::config('production', [ - 'className' => 'Cake\Log\Engine\SyslogLog', +Log::setConfig('production', [ + 'className' => \Cake\Log\Engine\SyslogLog::class, 'levels' => ['warning', 'error', 'critical', 'alert', 'emergency'], ]); ``` @@ -38,7 +36,7 @@ Log::config('production', [ It is also possible to create loggers by providing a closure. ```php -Log::config('special', function () { +Log::setConfig('special', function () { // Return any PSR-3 compatible logger return new MyPSR3CompatibleLogger(); }); @@ -47,7 +45,7 @@ Log::config('special', function () { Or by injecting an instance directly: ```php -Log::config('special', new MyPSR3CompatibleLogger()); +Log::setConfig('special', new MyPSR3CompatibleLogger()); ``` You can then use the `Log` class to pass messages to the logging backends: @@ -68,8 +66,8 @@ you can limit the logging engines that receive a particular message. ```php // Configure /logs/payments.log to receive all levels, but only // those with `payments` scope. -Log::config('payments', [ - 'className' => 'FileLog', +Log::setConfig('payments', [ + 'className' => 'File', 'levels' => ['error', 'info', 'warning'], 'scopes' => ['payments'], 'file' => '/logs/payments.log', @@ -80,4 +78,4 @@ Log::warning('this gets written only to payments.log', ['scope' => ['payments']] ## Documentation -Please make sure you check the [official documentation](https://book.cakephp.org/3.0/en/core-libraries/logging.html) +Please make sure you check the [official documentation](https://book.cakephp.org/5/en/core-libraries/logging.html) diff --git a/src/Log/composer.json b/src/Log/composer.json index 81fd94fa2fa..9fb156838bf 100644 --- a/src/Log/composer.json +++ b/src/Log/composer.json @@ -23,13 +23,23 @@ "source": "https://github.com/cakephp/log" }, "require": { - "php": ">=5.6.0", - "cakephp/core": "^3.0.0", - "psr/log": "^1.0.0" + "php": ">=8.2", + "cakephp/core": "^5.4.0", + "psr/log": "^3.0" }, "autoload": { "psr-4": { "Cake\\Log\\": "." } + }, + "provide": { + "psr/log-implementation": "^3.0" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" + } } } diff --git a/src/Mailer/AbstractTransport.php b/src/Mailer/AbstractTransport.php index 908b15d29e0..e72218490cd 100644 --- a/src/Mailer/AbstractTransport.php +++ b/src/Mailer/AbstractTransport.php @@ -1,4 +1,6 @@ */ - protected $_defaultConfig = []; + protected array $_defaultConfig = []; /** * Send mail * - * @param \Cake\Mailer\Email $email Email instance. - * @return array + * @param \Cake\Mailer\Message $message Email message. + * @return array{headers: string, message: string, ...} Contains 'headers' and 'message' keys. Additional keys allowed. */ - abstract public function send(Email $email); + abstract public function send(Message $message): array; /** * Constructor * - * @param array $config Configuration options. + * @param array $config Configuration options. */ - public function __construct($config = []) + public function __construct(array $config = []) { $this->setConfig($config); } /** - * Help to convert headers in string + * Check that at least one destination header is set. * - * @param array $headers Headers in format key => value - * @param string $eol End of line string. - * @return string + * @param \Cake\Mailer\Message $message Message instance. + * @return void + * @throws \Cake\Core\Exception\CakeException If at least one of to, cc or bcc is not specified. */ - protected function _headersToString($headers, $eol = "\r\n") + protected function checkRecipient(Message $message): void { - $out = ''; - foreach ($headers as $key => $value) { - if ($value === false || $value === null || $value === '') { - continue; - } - $out .= $key . ': ' . $value . $eol; + if ( + $message->getTo() === [] + && $message->getCc() === [] + && $message->getBcc() === [] + ) { + throw new CakeException( + 'You must specify at least one recipient.' + . ' Use one of `setTo`, `setCc` or `setBcc` to define a recipient.', + ); } - if (!empty($out)) { - $out = substr($out, 0, -1 * strlen($eol)); - } - - return $out; } } diff --git a/src/Mailer/Email.php b/src/Mailer/Email.php deleted file mode 100644 index 46f15460fed..00000000000 --- a/src/Mailer/Email.php +++ /dev/null @@ -1,2871 +0,0 @@ - 'Cake\Mailer\Transport\DebugTransport', - 'mail' => 'Cake\Mailer\Transport\MailTransport', - 'smtp' => 'Cake\Mailer\Transport\SmtpTransport', - ]; - - /** - * Configuration profiles for transports. - * - * @var array - */ - protected static $_transportConfig = []; - - /** - * A copy of the configuration profile for this - * instance. This copy can be modified with Email::profile(). - * - * @var array - */ - protected $_profile = []; - - /** - * 8Bit character sets - * - * @var array - */ - protected $_charset8bit = ['UTF-8', 'SHIFT_JIS']; - - /** - * Define Content-Type charset name - * - * @var array - */ - protected $_contentTypeCharset = [ - 'ISO-2022-JP-MS' => 'ISO-2022-JP' - ]; - - /** - * Regex for email validation - * - * If null, filter_var() will be used. Use the emailPattern() method - * to set a custom pattern.' - * - * @var string - */ - protected $_emailPattern = self::EMAIL_PATTERN; - - /** - * Constructor - * - * @param array|string|null $config Array of configs, or string to load configs from email.php - */ - public function __construct($config = null) - { - $this->_appCharset = Configure::read('App.encoding'); - if ($this->_appCharset !== null) { - $this->charset = $this->_appCharset; - } - $this->_domain = preg_replace('/\:\d+$/', '', env('HTTP_HOST')); - if (empty($this->_domain)) { - $this->_domain = php_uname('n'); - } - - $this->viewBuilder() - ->setClassName('Cake\View\View') - ->setTemplate('') - ->setLayout('default') - ->setHelpers(['Html']); - - if ($config === null) { - $config = static::getConfig('default'); - } - if ($config) { - $this->setProfile($config); - } - if (empty($this->headerCharset)) { - $this->headerCharset = $this->charset; - } - } - - /** - * Clone ViewBuilder instance when email object is cloned. - * - * @return void - */ - public function __clone() - { - $this->_viewBuilder = clone $this->viewBuilder(); - } - - /** - * Sets "from" address. - * - * @param string|array $email Null to get, String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return $this - * @throws \InvalidArgumentException - */ - public function setFrom($email, $name = null) - { - return $this->_setEmailSingle('_from', $email, $name, 'From requires only 1 email address.'); - } - - /** - * Gets "from" address. - * - * @return array - */ - public function getFrom() - { - return $this->_from; - } - - /** - * From - * - * @deprecated 3.4.0 Use setFrom()/getFrom() instead. - * @param string|array|null $email Null to get, String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return array|$this - * @throws \InvalidArgumentException - */ - public function from($email = null, $name = null) - { - if ($email === null) { - return $this->getFrom(); - } - - return $this->setFrom($email, $name); - } - - /** - * Sets "sender" address. - * - * @param string|array $email String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return $this - * @throws \InvalidArgumentException - */ - public function setSender($email, $name = null) - { - return $this->_setEmailSingle('_sender', $email, $name, 'Sender requires only 1 email address.'); - } - - /** - * Gets "sender" address. - * - * @return array - */ - public function getSender() - { - return $this->_sender; - } - - /** - * Sender - * - * @deprecated 3.4.0 Use setSender()/getSender() instead. - * @param string|array|null $email Null to get, String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return array|$this - * @throws \InvalidArgumentException - */ - public function sender($email = null, $name = null) - { - if ($email === null) { - return $this->getSender(); - } - - return $this->setSender($email, $name); - } - - /** - * Sets "Reply-To" address. - * - * @param string|array $email String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return $this - * @throws \InvalidArgumentException - */ - public function setReplyTo($email, $name = null) - { - return $this->_setEmailSingle('_replyTo', $email, $name, 'Reply-To requires only 1 email address.'); - } - - /** - * Gets "Reply-To" address. - * - * @return array - */ - public function getReplyTo() - { - return $this->_replyTo; - } - - /** - * Reply-To - * - * @deprecated 3.4.0 Use setReplyTo()/getReplyTo() instead. - * @param string|array|null $email Null to get, String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return array|$this - * @throws \InvalidArgumentException - */ - public function replyTo($email = null, $name = null) - { - if ($email === null) { - return $this->getReplyTo(); - } - - return $this->setReplyTo($email, $name); - } - - /** - * Sets Read Receipt (Disposition-Notification-To header). - * - * @param string|array $email String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return $this - * @throws \InvalidArgumentException - */ - public function setReadReceipt($email, $name = null) - { - return $this->_setEmailSingle('_readReceipt', $email, $name, 'Disposition-Notification-To requires only 1 email address.'); - } - - /** - * Gets Read Receipt (Disposition-Notification-To header). - * - * @return array - */ - public function getReadReceipt() - { - return $this->_readReceipt; - } - - /** - * Read Receipt (Disposition-Notification-To header) - * - * @deprecated 3.4.0 Use setReadReceipt()/getReadReceipt() instead. - * @param string|array|null $email Null to get, String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return array|$this - * @throws \InvalidArgumentException - */ - public function readReceipt($email = null, $name = null) - { - if ($email === null) { - return $this->getReadReceipt(); - } - - return $this->setReadReceipt($email, $name); - } - - /** - * Return Path - * - * @param string|array $email String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return $this - * @throws \InvalidArgumentException - */ - public function setReturnPath($email, $name = null) - { - return $this->_setEmailSingle('_returnPath', $email, $name, 'Return-Path requires only 1 email address.'); - } - - /** - * Gets return path. - * - * @return array - */ - public function getReturnPath() - { - return $this->_returnPath; - } - - /** - * Return Path - * - * @deprecated 3.4.0 Use setReturnPath()/getReturnPath() instead. - * @param string|array|null $email Null to get, String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return array|$this - * @throws \InvalidArgumentException - */ - public function returnPath($email = null, $name = null) - { - if ($email === null) { - return $this->getReturnPath(); - } - - return $this->setReturnPath($email, $name); - } - - /** - * Sets "to" address. - * - * @param string|array $email String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return $this - */ - public function setTo($email, $name = null) - { - return $this->_setEmail('_to', $email, $name); - } - - /** - * Gets "to" address - * - * @return array - */ - public function getTo() - { - return $this->_to; - } - - /** - * To - * - * @deprecated 3.4.0 Use setTo()/getTo() instead. - * @param string|array|null $email Null to get, String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return array|$this - */ - public function to($email = null, $name = null) - { - if ($email === null) { - return $this->getTo(); - } - - return $this->setTo($email, $name); - } - - /** - * Add To - * - * @param string|array $email Null to get, String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return $this - */ - public function addTo($email, $name = null) - { - return $this->_addEmail('_to', $email, $name); - } - - /** - * Sets "cc" address. - * - * @param string|array $email String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return $this - */ - public function setCc($email, $name = null) - { - return $this->_setEmail('_cc', $email, $name); - } - - /** - * Gets "cc" address. - * - * @return array - */ - public function getCc() - { - return $this->_cc; - } - - /** - * Cc - * - * @deprecated 3.4.0 Use setCc()/getCc() instead. - * @param string|array|null $email Null to get, String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return array|$this - */ - public function cc($email = null, $name = null) - { - if ($email === null) { - return $this->getCc(); - } - - return $this->setCc($email, $name); - } - - /** - * Add Cc - * - * @param string|array $email Null to get, String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return $this - */ - public function addCc($email, $name = null) - { - return $this->_addEmail('_cc', $email, $name); - } - - /** - * Sets "bcc" address. - * - * @param string|array $email String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return $this - */ - public function setBcc($email, $name = null) - { - return $this->_setEmail('_bcc', $email, $name); - } - - /** - * Gets "bcc" address. - * - * @return array - */ - public function getBcc() - { - return $this->_bcc; - } - - /** - * Bcc - * - * @deprecated 3.4.0 Use setBcc()/getBcc() instead. - * @param string|array|null $email Null to get, String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return array|$this - */ - public function bcc($email = null, $name = null) - { - if ($email === null) { - return $this->getBcc(); - } - - return $this->setBcc($email, $name); - } - - /** - * Add Bcc - * - * @param string|array $email Null to get, String with email, - * Array with email as key, name as value or email as value (without name) - * @param string|null $name Name - * @return $this - */ - public function addBcc($email, $name = null) - { - return $this->_addEmail('_bcc', $email, $name); - } - - /** - * Charset setter. - * - * @param string|null $charset Character set. - * @return $this - */ - public function setCharset($charset) - { - $this->charset = $charset; - if (!$this->headerCharset) { - $this->headerCharset = $charset; - } - - return $this; - } - - /** - * Charset getter. - * - * @return string Charset - */ - public function getCharset() - { - return $this->charset; - } - - /** - * Charset setter/getter - * - * @deprecated 3.4.0 Use setCharset()/getCharset() instead. - * @param string|null $charset Character set. - * @return string Charset - */ - public function charset($charset = null) - { - if ($charset === null) { - return $this->getCharset(); - } - $this->setCharset($charset); - - return $this->charset; - } - - /** - * HeaderCharset setter. - * - * @param string|null $charset Character set. - * @return $this - */ - public function setHeaderCharset($charset) - { - $this->headerCharset = $charset; - - return $this; - } - - /** - * HeaderCharset getter. - * - * @return string Charset - */ - public function getHeaderCharset() - { - return $this->headerCharset; - } - - /** - * HeaderCharset setter/getter - * - * @deprecated 3.4.0 Use setHeaderCharset()/getHeaderCharset() instead. - * @param string|null $charset Character set. - * @return string Charset - */ - public function headerCharset($charset = null) - { - if ($charset === null) { - return $this->getHeaderCharset(); - } - - $this->setHeaderCharset($charset); - - return $this->headerCharset; - } - - /** - * TransferEncoding setter. - * - * @param string|null $encoding Encoding set. - * @return $this - */ - public function setTransferEncoding($encoding) - { - $encoding = strtolower($encoding); - if (!in_array($encoding, $this->_transferEncodingAvailable)) { - throw new InvalidArgumentException( - sprintf( - 'Transfer encoding not available. Can be : %s.', - implode(', ', $this->_transferEncodingAvailable) - ) - ); - } - $this->transferEncoding = $encoding; - - return $this; - } - - /** - * TransferEncoding getter. - * - * @return string|null Encoding - */ - public function getTransferEncoding() - { - return $this->transferEncoding; - } - - /** - * EmailPattern setter/getter - * - * @param string|null $regex The pattern to use for email address validation, - * null to unset the pattern and make use of filter_var() instead. - * @return $this - */ - public function setEmailPattern($regex) - { - $this->_emailPattern = $regex; - - return $this; - } - - /** - * EmailPattern setter/getter - * - * @return string - */ - public function getEmailPattern() - { - return $this->_emailPattern; - } - - /** - * EmailPattern setter/getter - * - * @deprecated 3.4.0 Use setEmailPattern()/getEmailPattern() instead. - * @param string|bool|null $regex The pattern to use for email address validation, - * null to unset the pattern and make use of filter_var() instead, false or - * nothing to return the current value - * @return string|$this - */ - public function emailPattern($regex = false) - { - if ($regex === false) { - return $this->getEmailPattern(); - } - - return $this->setEmailPattern($regex); - } - - /** - * Set email - * - * @param string $varName Property name - * @param string|array $email String with email, - * Array with email as key, name as value or email as value (without name) - * @param string $name Name - * @return $this - * @throws \InvalidArgumentException - */ - protected function _setEmail($varName, $email, $name) - { - if (!is_array($email)) { - $this->_validateEmail($email, $varName); - if ($name === null) { - $name = $email; - } - $this->{$varName} = [$email => $name]; - - return $this; - } - $list = []; - foreach ($email as $key => $value) { - if (is_int($key)) { - $key = $value; - } - $this->_validateEmail($key, $varName); - $list[$key] = $value; - } - $this->{$varName} = $list; - - return $this; - } - - /** - * Validate email address - * - * @param string $email Email address to validate - * @param string $context Which property was set - * @return void - * @throws \InvalidArgumentException If email address does not validate - */ - protected function _validateEmail($email, $context) - { - if ($this->_emailPattern === null) { - if (filter_var($email, FILTER_VALIDATE_EMAIL)) { - return; - } - } elseif (preg_match($this->_emailPattern, $email)) { - return; - } - - $context = ltrim($context, '_'); - if ($email == '') { - throw new InvalidArgumentException(sprintf('The email set for "%s" is empty.', $context)); - } - throw new InvalidArgumentException(sprintf('Invalid email set for "%s". You passed "%s".', $context, $email)); - } - - /** - * Set only 1 email - * - * @param string $varName Property name - * @param string|array $email String with email, - * Array with email as key, name as value or email as value (without name) - * @param string $name Name - * @param string $throwMessage Exception message - * @return $this - * @throws \InvalidArgumentException - */ - protected function _setEmailSingle($varName, $email, $name, $throwMessage) - { - $current = $this->{$varName}; - $this->_setEmail($varName, $email, $name); - if (count($this->{$varName}) !== 1) { - $this->{$varName} = $current; - throw new InvalidArgumentException($throwMessage); - } - - return $this; - } - - /** - * Add email - * - * @param string $varName Property name - * @param string|array $email String with email, - * Array with email as key, name as value or email as value (without name) - * @param string $name Name - * @return $this - * @throws \InvalidArgumentException - */ - protected function _addEmail($varName, $email, $name) - { - if (!is_array($email)) { - $this->_validateEmail($email, $varName); - if ($name === null) { - $name = $email; - } - $this->{$varName}[$email] = $name; - - return $this; - } - $list = []; - foreach ($email as $key => $value) { - if (is_int($key)) { - $key = $value; - } - $this->_validateEmail($key, $varName); - $list[$key] = $value; - } - $this->{$varName} = array_merge($this->{$varName}, $list); - - return $this; - } - - /** - * Sets subject. - * - * @param string $subject Subject string. - * @return $this - */ - public function setSubject($subject) - { - $this->_subject = $this->_encode((string)$subject); - - return $this; - } - - /** - * Gets subject. - * - * @return string - */ - public function getSubject() - { - return $this->_subject; - } - - /** - * Get/Set Subject. - * - * @deprecated 3.4.0 Use setSubject()/getSubject() instead. - * @param string|null $subject Subject string. - * @return string|$this - */ - public function subject($subject = null) - { - if ($subject === null) { - return $this->getSubject(); - } - - return $this->setSubject($subject); - } - - /** - * Get original subject without encoding - * - * @return string Original subject - */ - public function getOriginalSubject() - { - return $this->_decode($this->_subject); - } - - /** - * Sets headers for the message - * - * @param array $headers Associative array containing headers to be set. - * @return $this - */ - public function setHeaders(array $headers) - { - $this->_headers = $headers; - - return $this; - } - - /** - * Add header for the message - * - * @param array $headers Headers to set. - * @return $this - */ - public function addHeaders(array $headers) - { - $this->_headers = array_merge($this->_headers, $headers); - - return $this; - } - - /** - * Get list of headers - * - * ### Includes: - * - * - `from` - * - `replyTo` - * - `readReceipt` - * - `returnPath` - * - `to` - * - `cc` - * - `bcc` - * - `subject` - * - * @param array $include List of headers. - * @return array - */ - public function getHeaders(array $include = []) - { - if ($include == array_values($include)) { - $include = array_fill_keys($include, true); - } - $defaults = array_fill_keys( - [ - 'from', 'sender', 'replyTo', 'readReceipt', 'returnPath', - 'to', 'cc', 'bcc', 'subject'], - false - ); - $include += $defaults; - - $headers = []; - $relation = [ - 'from' => 'From', - 'replyTo' => 'Reply-To', - 'readReceipt' => 'Disposition-Notification-To', - 'returnPath' => 'Return-Path' - ]; - foreach ($relation as $var => $header) { - if ($include[$var]) { - $var = '_' . $var; - $headers[$header] = current($this->_formatAddress($this->{$var})); - } - } - if ($include['sender']) { - if (key($this->_sender) === key($this->_from)) { - $headers['Sender'] = ''; - } else { - $headers['Sender'] = current($this->_formatAddress($this->_sender)); - } - } - - foreach (['to', 'cc', 'bcc'] as $var) { - if ($include[$var]) { - $classVar = '_' . $var; - $headers[ucfirst($var)] = implode(', ', $this->_formatAddress($this->{$classVar})); - } - } - - $headers += $this->_headers; - if (!isset($headers['Date'])) { - $headers['Date'] = date(DATE_RFC2822); - } - if ($this->_messageId !== false) { - if ($this->_messageId === true) { - $headers['Message-ID'] = '<' . str_replace('-', '', Text::uuid()) . '@' . $this->_domain . '>'; - } else { - $headers['Message-ID'] = $this->_messageId; - } - } - - if ($this->_priority) { - $headers['X-Priority'] = $this->_priority; - } - - if ($include['subject']) { - $headers['Subject'] = $this->_subject; - } - - $headers['MIME-Version'] = '1.0'; - if ($this->_attachments) { - $headers['Content-Type'] = 'multipart/mixed; boundary="' . $this->_boundary . '"'; - } elseif ($this->_emailFormat === 'both') { - $headers['Content-Type'] = 'multipart/alternative; boundary="' . $this->_boundary . '"'; - } elseif ($this->_emailFormat === 'text') { - $headers['Content-Type'] = 'text/plain; charset=' . $this->_getContentTypeCharset(); - } elseif ($this->_emailFormat === 'html') { - $headers['Content-Type'] = 'text/html; charset=' . $this->_getContentTypeCharset(); - } - $headers['Content-Transfer-Encoding'] = $this->_getContentTransferEncoding(); - - return $headers; - } - - /** - * Format addresses - * - * If the address contains non alphanumeric/whitespace characters, it will - * be quoted as characters like `:` and `,` are known to cause issues - * in address header fields. - * - * @param array $address Addresses to format. - * @return array - */ - protected function _formatAddress($address) - { - $return = []; - foreach ($address as $email => $alias) { - if ($email === $alias) { - $return[] = $email; - } else { - $encoded = $this->_encode($alias); - if ($encoded === $alias && preg_match('/[^a-z0-9 ]/i', $encoded)) { - $encoded = '"' . str_replace('"', '\"', $encoded) . '"'; - } - $return[] = sprintf('%s <%s>', $encoded, $email); - } - } - - return $return; - } - - /** - * Sets template. - * - * @param string|null $template Template name or null to not use. - * @return $this - */ - public function setTemplate($template) - { - $this->viewBuilder()->setTemplate($template ?: ''); - - return $this; - } - - /** - * Gets template. - * - * @return string - */ - public function getTemplate() - { - return $this->viewBuilder()->getTemplate(); - } - - /** - * Sets layout. - * - * @param string|null $layout Layout name or null to not use - * @return $this - */ - public function setLayout($layout) - { - $this->viewBuilder()->setLayout($layout ?: false); - - return $this; - } - - /** - * Gets layout. - * - * @return string - */ - public function getLayout() - { - return $this->viewBuilder()->getLayout(); - } - - /** - * Template and layout - * - * @deprecated 3.4.0 Use setTemplate()/getTemplate() and setLayout()/getLayout() instead. - * @param bool|string $template Template name or null to not use - * @param bool|string $layout Layout name or null to not use - * @return array|$this - */ - public function template($template = false, $layout = false) - { - if ($template === false) { - return [ - 'template' => $this->getTemplate(), - 'layout' => $this->getLayout() - ]; - } - $this->setTemplate($template); - if ($layout !== false) { - $this->setLayout($layout); - } - - return $this; - } - - /** - * Sets view class for render. - * - * @param string $viewClass View class name. - * @return $this - */ - public function setViewRenderer($viewClass) - { - $this->viewBuilder()->setClassName($viewClass); - - return $this; - } - - /** - * Gets view class for render. - * - * @return string - */ - public function getViewRenderer() - { - return $this->viewBuilder()->getClassName(); - } - - /** - * View class for render - * - * @deprecated 3.4.0 Use setViewRenderer()/getViewRenderer() instead. - * @param string|null $viewClass View class name. - * @return string|$this - */ - public function viewRender($viewClass = null) - { - if ($viewClass === null) { - return $this->getViewRenderer(); - } - $this->setViewRenderer($viewClass); - - return $this; - } - - /** - * Sets variables to be set on render. - * - * @param array $viewVars Variables to set for view. - * @return $this - */ - public function setViewVars($viewVars) - { - $this->set((array)$viewVars); - - return $this; - } - - /** - * Gets variables to be set on render. - * - * @return array - */ - public function getViewVars() - { - return $this->viewVars; - } - - /** - * Variables to be set on render - * - * @deprecated 3.4.0 Use setViewVars()/getViewVars() instead. - * @param array|null $viewVars Variables to set for view. - * @return array|$this - */ - public function viewVars($viewVars = null) - { - if ($viewVars === null) { - return $this->getViewVars(); - } - - return $this->setViewVars($viewVars); - } - - /** - * Sets theme to use when rendering. - * - * @param string $theme Theme name. - * @return $this - */ - public function setTheme($theme) - { - $this->viewBuilder()->setTheme($theme); - - return $this; - } - - /** - * Gets theme to use when rendering. - * - * @return string - */ - public function getTheme() - { - return $this->viewBuilder()->getTheme(); - } - - /** - * Theme to use when rendering - * - * @deprecated 3.4.0 Use setTheme()/getTheme() instead. - * @param string|null $theme Theme name. - * @return string|$this - */ - public function theme($theme = null) - { - if ($theme === null) { - return $this->getTheme(); - } - - return $this->setTheme($theme); - } - - /** - * Sets helpers to be used when rendering. - * - * @param array $helpers Helpers list. - * @return $this - */ - public function setHelpers(array $helpers) - { - $this->viewBuilder()->setHelpers($helpers, false); - - return $this; - } - - /** - * Gets helpers to be used when rendering. - * - * @return array - */ - public function getHelpers() - { - return $this->viewBuilder()->getHelpers(); - } - - /** - * Helpers to be used in render - * - * @deprecated 3.4.0 Use setHelpers()/getHelpers() instead. - * @param array|null $helpers Helpers list. - * @return array|$this - */ - public function helpers($helpers = null) - { - if ($helpers === null) { - return $this->getHelpers(); - } - - return $this->setHelpers((array)$helpers); - } - - /** - * Sets email format. - * - * @param string $format Formatting string. - * @return $this - * @throws \InvalidArgumentException - */ - public function setEmailFormat($format) - { - if (!in_array($format, $this->_emailFormatAvailable)) { - throw new InvalidArgumentException('Format not available.'); - } - $this->_emailFormat = $format; - - return $this; - } - - /** - * Gets email format. - * - * @return string - */ - public function getEmailFormat() - { - return $this->_emailFormat; - } - - /** - * Email format - * - * @deprecated 3.4.0 Use setEmailFormat()/getEmailFormat() instead. - * @param string|null $format Formatting string. - * @return string|$this - * @throws \InvalidArgumentException - */ - public function emailFormat($format = null) - { - if ($format === null) { - return $this->getEmailFormat(); - } - - return $this->setEmailFormat($format); - } - - /** - * Sets the transport. - * - * When setting the transport you can either use the name - * of a configured transport or supply a constructed transport. - * - * @param string|\Cake\Mailer\AbstractTransport $name Either the name of a configured - * transport, or a transport instance. - * @return $this - * @throws \LogicException When the chosen transport lacks a send method. - * @throws \InvalidArgumentException When $name is neither a string nor an object. - */ - public function setTransport($name) - { - if (is_string($name)) { - $transport = $this->_constructTransport($name); - } elseif (is_object($name)) { - $transport = $name; - } else { - throw new InvalidArgumentException( - sprintf('The value passed for the "$name" argument must be either a string, or an object, %s given.', gettype($name)) - ); - } - if (!method_exists($transport, 'send')) { - throw new LogicException(sprintf('The "%s" do not have send method.', get_class($transport))); - } - - $this->_transport = $transport; - - return $this; - } - - /** - * Gets the transport. - * - * @return \Cake\Mailer\AbstractTransport - */ - public function getTransport() - { - return $this->_transport; - } - - /** - * Get/set the transport. - * - * When setting the transport you can either use the name - * of a configured transport or supply a constructed transport. - * - * @deprecated 3.4.0 Use setTransport()/getTransport() instead. - * @param string|\Cake\Mailer\AbstractTransport|null $name Either the name of a configured - * transport, or a transport instance. - * @return \Cake\Mailer\AbstractTransport|$this - * @throws \LogicException When the chosen transport lacks a send method. - * @throws \InvalidArgumentException When $name is neither a string nor an object. - */ - public function transport($name = null) - { - if ($name === null) { - return $this->getTransport(); - } - - return $this->setTransport($name); - } - - /** - * Build a transport instance from configuration data. - * - * @param string $name The transport configuration name to build. - * @return \Cake\Mailer\AbstractTransport - * @throws \InvalidArgumentException When transport configuration is missing or invalid. - */ - protected function _constructTransport($name) - { - if (!isset(static::$_transportConfig[$name])) { - throw new InvalidArgumentException(sprintf('Transport config "%s" is missing.', $name)); - } - - if (!isset(static::$_transportConfig[$name]['className'])) { - throw new InvalidArgumentException( - sprintf('Transport config "%s" is invalid, the required `className` option is missing', $name) - ); - } - - $config = static::$_transportConfig[$name]; - - if (is_object($config['className'])) { - if (!$config['className'] instanceof AbstractTransport) { - throw new InvalidArgumentException(sprintf( - 'Transport object must be of type "AbstractTransport". Found invalid type: "%s".', - get_class($config['className']) - )); - } - - return $config['className']; - } - - $className = App::className($config['className'], 'Mailer/Transport', 'Transport'); - if (!$className) { - $className = App::className($config['className'], 'Network/Email', 'Transport'); - if ($className) { - trigger_error( - 'Transports in "Network/Email" are deprecated, use "Mailer/Transport" instead.', - E_USER_DEPRECATED - ); - } - } - - if (!$className) { - throw new InvalidArgumentException(sprintf('Transport class "%s" not found.', $config['className'])); - } - if (!method_exists($className, 'send')) { - throw new InvalidArgumentException(sprintf('The "%s" does not have a send() method.', $className)); - } - - unset($config['className']); - - return new $className($config); - } - - /** - * Sets message ID. - * - * @param bool|string $message True to generate a new Message-ID, False to ignore (not send in email), String to set as Message-ID. - * @return $this - * @throws \InvalidArgumentException - */ - public function setMessageId($message) - { - if (is_bool($message)) { - $this->_messageId = $message; - } else { - if (!preg_match('/^\<.+@.+\>$/', $message)) { - throw new InvalidArgumentException('Invalid format to Message-ID. The text should be something like ""'); - } - $this->_messageId = $message; - } - - return $this; - } - - /** - * Gets message ID. - * - * @return bool|string - */ - public function getMessageId() - { - return $this->_messageId; - } - - /** - * Message-ID - * - * @deprecated 3.4.0 Use setMessageId()/getMessageId() instead. - * @param bool|string|null $message True to generate a new Message-ID, False to ignore (not send in email), String to set as Message-ID - * @return bool|string|$this - * @throws \InvalidArgumentException - */ - public function messageId($message = null) - { - if ($message === null) { - return $this->getMessageId(); - } - - return $this->setMessageId($message); - } - - /** - * Sets domain. - * - * Domain as top level (the part after @). - * - * @param string $domain Manually set the domain for CLI mailing. - * @return $this - */ - public function setDomain($domain) - { - $this->_domain = $domain; - - return $this; - } - - /** - * Gets domain. - * - * @return string - */ - public function getDomain() - { - return $this->_domain; - } - - /** - * Domain as top level (the part after @) - * - * @deprecated 3.4.0 Use setDomain()/getDomain() instead. - * @param string|null $domain Manually set the domain for CLI mailing - * @return string|$this - */ - public function domain($domain = null) - { - if ($domain === null) { - return $this->getDomain(); - } - - return $this->setDomain($domain); - } - - /** - * Add attachments to the email message - * - * Attachments can be defined in a few forms depending on how much control you need: - * - * Attach a single file: - * - * ``` - * $email->attachments('path/to/file'); - * ``` - * - * Attach a file with a different filename: - * - * ``` - * $email->attachments(['custom_name.txt' => 'path/to/file.txt']); - * ``` - * - * Attach a file and specify additional properties: - * - * ``` - * $email->attachments(['custom_name.png' => [ - * 'file' => 'path/to/file', - * 'mimetype' => 'image/png', - * 'contentId' => 'abc123', - * 'contentDisposition' => false - * ] - * ]); - * ``` - * - * Attach a file from string and specify additional properties: - * - * ``` - * $email->attachments(['custom_name.png' => [ - * 'data' => file_get_contents('path/to/file'), - * 'mimetype' => 'image/png' - * ] - * ]); - * ``` - * - * The `contentId` key allows you to specify an inline attachment. In your email text, you - * can use `` to display the image inline. - * - * The `contentDisposition` key allows you to disable the `Content-Disposition` header, this can improve - * attachment compatibility with outlook email clients. - * - * @param string|array $attachments String with the filename or array with filenames - * @return $this - * @throws \InvalidArgumentException - */ - public function setAttachments($attachments) - { - $attach = []; - foreach ((array)$attachments as $name => $fileInfo) { - if (!is_array($fileInfo)) { - $fileInfo = ['file' => $fileInfo]; - } - if (!isset($fileInfo['file'])) { - if (!isset($fileInfo['data'])) { - throw new InvalidArgumentException('No file or data specified.'); - } - if (is_int($name)) { - throw new InvalidArgumentException('No filename specified.'); - } - $fileInfo['data'] = chunk_split(base64_encode($fileInfo['data']), 76, "\r\n"); - } else { - $fileName = $fileInfo['file']; - $fileInfo['file'] = realpath($fileInfo['file']); - if ($fileInfo['file'] === false || !file_exists($fileInfo['file'])) { - throw new InvalidArgumentException(sprintf('File not found: "%s"', $fileName)); - } - if (is_int($name)) { - $name = basename($fileInfo['file']); - } - } - if (!isset($fileInfo['mimetype']) && function_exists('mime_content_type')) { - $fileInfo['mimetype'] = mime_content_type($fileInfo['file']); - } - if (!isset($fileInfo['mimetype'])) { - $fileInfo['mimetype'] = 'application/octet-stream'; - } - $attach[$name] = $fileInfo; - } - $this->_attachments = $attach; - - return $this; - } - - /** - * Gets attachments to the email message. - * - * @return array Array of attachments. - */ - public function getAttachments() - { - return $this->_attachments; - } - - /** - * Add attachments to the email message - * - * Attachments can be defined in a few forms depending on how much control you need: - * - * Attach a single file: - * - * ``` - * $email->attachments('path/to/file'); - * ``` - * - * Attach a file with a different filename: - * - * ``` - * $email->attachments(['custom_name.txt' => 'path/to/file.txt']); - * ``` - * - * Attach a file and specify additional properties: - * - * ``` - * $email->attachments(['custom_name.png' => [ - * 'file' => 'path/to/file', - * 'mimetype' => 'image/png', - * 'contentId' => 'abc123', - * 'contentDisposition' => false - * ] - * ]); - * ``` - * - * Attach a file from string and specify additional properties: - * - * ``` - * $email->attachments(['custom_name.png' => [ - * 'data' => file_get_contents('path/to/file'), - * 'mimetype' => 'image/png' - * ] - * ]); - * ``` - * - * The `contentId` key allows you to specify an inline attachment. In your email text, you - * can use `` to display the image inline. - * - * The `contentDisposition` key allows you to disable the `Content-Disposition` header, this can improve - * attachment compatibility with outlook email clients. - * - * @deprecated 3.4.0 Use setAttachments()/getAttachments() instead. - * @param string|array|null $attachments String with the filename or array with filenames - * @return array|$this Either the array of attachments when getting or $this when setting. - * @throws \InvalidArgumentException - */ - public function attachments($attachments = null) - { - if ($attachments === null) { - return $this->getAttachments(); - } - - return $this->setAttachments($attachments); - } - - /** - * Add attachments - * - * @param string|array $attachments String with the filename or array with filenames - * @return $this - * @throws \InvalidArgumentException - * @see \Cake\Mailer\Email::attachments() - */ - public function addAttachments($attachments) - { - $current = $this->_attachments; - $this->setAttachments($attachments); - $this->_attachments = array_merge($current, $this->_attachments); - - return $this; - } - - /** - * Get generated message (used by transport classes) - * - * @param string|null $type Use MESSAGE_* constants or null to return the full message as array - * @return string|array String if type is given, array if type is null - */ - public function message($type = null) - { - switch ($type) { - case static::MESSAGE_HTML: - return $this->_htmlMessage; - case static::MESSAGE_TEXT: - return $this->_textMessage; - } - - return $this->_message; - } - - /** - * Sets priority. - * - * @param int|null $priority 1 (highest) to 5 (lowest) - * @return $this - */ - public function setPriority($priority) - { - $this->_priority = $priority; - - return $this; - } - - /** - * Gets priority. - * - * @return int - */ - public function getPriority() - { - return $this->_priority; - } - - /** - * Sets transport configuration. - * - * Use this method to define transports to use in delivery profiles. - * Once defined you cannot edit the configurations, and must use - * Email::dropTransport() to flush the configuration first. - * - * When using an array of configuration data a new transport - * will be constructed for each message sent. When using a Closure, the - * closure will be evaluated for each message. - * - * The `className` is used to define the class to use for a transport. - * It can either be a short name, or a fully qualified class name - * - * @param string|array $key The configuration name to write. Or - * an array of multiple transports to set. - * @param array|\Cake\Mailer\AbstractTransport|null $config Either an array of configuration - * data, or a transport instance. Null when using key as array. - * @return void - * @throws \BadMethodCallException When modifying an existing configuration. - */ - public static function setConfigTransport($key, $config = null) - { - if (is_array($key)) { - foreach ($key as $name => $settings) { - static::setConfigTransport($name, $settings); - } - - return; - } - - if (isset(static::$_transportConfig[$key])) { - throw new BadMethodCallException(sprintf('Cannot modify an existing config "%s"', $key)); - } - - if (is_object($config)) { - $config = ['className' => $config]; - } - - if (isset($config['url'])) { - $parsed = static::parseDsn($config['url']); - unset($config['url']); - $config = $parsed + $config; - } - - static::$_transportConfig[$key] = $config; - } - - /** - * Gets current transport configuration. - * - * @param string $key The configuration name to read. - * @return array|null Transport config. - */ - public static function getConfigTransport($key) - { - return isset(static::$_transportConfig[$key]) ? static::$_transportConfig[$key] : null; - } - - /** - * Add or read transport configuration. - * - * Use this method to define transports to use in delivery profiles. - * Once defined you cannot edit the configurations, and must use - * Email::dropTransport() to flush the configuration first. - * - * When using an array of configuration data a new transport - * will be constructed for each message sent. When using a Closure, the - * closure will be evaluated for each message. - * - * The `className` is used to define the class to use for a transport. - * It can either be a short name, or a fully qualified classname - * - * @deprecated 3.4.0 Use setConfigTransport()/getConfigTransport() instead. - * @param string|array $key The configuration name to read/write. Or - * an array of multiple transports to set. - * @param array|\Cake\Mailer\AbstractTransport|null $config Either an array of configuration - * data, or a transport instance. - * @return array|null Either null when setting or an array of data when reading. - * @throws \BadMethodCallException When modifying an existing configuration. - */ - public static function configTransport($key, $config = null) - { - if ($config === null && is_string($key)) { - return static::getConfigTransport($key); - } - if ($config === null && is_array($key)) { - static::setConfigTransport($key); - - return null; - } - - static::setConfigTransport($key, $config); - } - - /** - * Returns an array containing the named transport configurations - * - * @return array Array of configurations. - */ - public static function configuredTransport() - { - return array_keys(static::$_transportConfig); - } - - /** - * Delete transport configuration. - * - * @param string $key The transport name to remove. - * @return void - */ - public static function dropTransport($key) - { - unset(static::$_transportConfig[$key]); - } - - /** - * Sets the configuration profile to use for this instance. - * - * @param string|array $config String with configuration name, or - * an array with config. - * @return $this - */ - public function setProfile($config) - { - if (!is_array($config)) { - $config = (string)$config; - } - $this->_applyConfig($config); - - return $this; - } - - /** - * Gets the configuration profile to use for this instance. - * - * @return string|array - */ - public function getProfile() - { - return $this->_profile; - } - - /** - * Get/Set the configuration profile to use for this instance. - * - * @deprecated 3.4.0 Use setProfile()/getProfile() instead. - * @param null|string|array $config String with configuration name, or - * an array with config or null to return current config. - * @return string|array|$this - */ - public function profile($config = null) - { - if ($config === null) { - return $this->getProfile(); - } - - return $this->setProfile($config); - } - - /** - * Send an email using the specified content, template and layout - * - * @param string|array|null $content String with message or array with messages - * @return array - * @throws \BadMethodCallException - */ - public function send($content = null) - { - if (empty($this->_from)) { - throw new BadMethodCallException('From is not specified.'); - } - if (empty($this->_to) && empty($this->_cc) && empty($this->_bcc)) { - throw new BadMethodCallException('You need specify one destination on to, cc or bcc.'); - } - - if (is_array($content)) { - $content = implode("\n", $content) . "\n"; - } - - $this->_message = $this->_render($this->_wrap($content)); - - $transport = $this->getTransport(); - if (!$transport) { - $msg = 'Cannot send email, transport was not defined. Did you call transport() or define ' . - ' a transport in the set profile?'; - throw new BadMethodCallException($msg); - } - $contents = $transport->send($this); - $this->_logDelivery($contents); - - return $contents; - } - - /** - * Log the email message delivery. - * - * @param array $contents The content with 'headers' and 'message' keys. - * @return void - */ - protected function _logDelivery($contents) - { - if (empty($this->_profile['log'])) { - return; - } - $config = [ - 'level' => 'debug', - 'scope' => 'email' - ]; - if ($this->_profile['log'] !== true) { - if (!is_array($this->_profile['log'])) { - $this->_profile['log'] = ['level' => $this->_profile['log']]; - } - $config = $this->_profile['log'] + $config; - } - Log::write( - $config['level'], - PHP_EOL . $this->flatten($contents['headers']) . PHP_EOL . PHP_EOL . $this->flatten($contents['message']), - $config['scope'] - ); - } - - /** - * Converts given value to string - * - * @param string|array $value The value to convert - * @return string - */ - protected function flatten($value) - { - return is_array($value) ? implode(';', $value) : (string)$value; - } - - /** - * Static method to fast create an instance of \Cake\Mailer\Email - * - * @param string|array|null $to Address to send (see Cake\Mailer\Email::to()). If null, will try to use 'to' from transport config - * @param string|null $subject String of subject or null to use 'subject' from transport config - * @param string|array|null $message String with message or array with variables to be used in render - * @param string|array $transportConfig String to use config from EmailConfig or array with configs - * @param bool $send Send the email or just return the instance pre-configured - * @return static Instance of Cake\Mailer\Email - * @throws \InvalidArgumentException - */ - public static function deliver($to = null, $subject = null, $message = null, $transportConfig = 'default', $send = true) - { - $class = __CLASS__; - - if (is_array($transportConfig) && !isset($transportConfig['transport'])) { - $transportConfig['transport'] = 'default'; - } - /* @var \Cake\Mailer\Email $instance */ - $instance = new $class($transportConfig); - if ($to !== null) { - $instance->setTo($to); - } - if ($subject !== null) { - $instance->setSubject($subject); - } - if (is_array($message)) { - $instance->setViewVars($message); - $message = null; - } elseif ($message === null && array_key_exists('message', $config = $instance->getProfile())) { - $message = $config['message']; - } - - if ($send === true) { - $instance->send($message); - } - - return $instance; - } - - /** - * Apply the config to an instance - * - * @param string|array $config Configuration options. - * @return void - * @throws \InvalidArgumentException When using a configuration that doesn't exist. - */ - protected function _applyConfig($config) - { - if (is_string($config)) { - $name = $config; - $config = static::getConfig($name); - if (empty($config)) { - throw new InvalidArgumentException(sprintf('Unknown email configuration "%s".', $name)); - } - unset($name); - } - - $this->_profile = array_merge($this->_profile, $config); - - $simpleMethods = [ - 'from', 'sender', 'to', 'replyTo', 'readReceipt', 'returnPath', - 'cc', 'bcc', 'messageId', 'domain', 'subject', 'attachments', - 'transport', 'emailFormat', 'emailPattern', 'charset', 'headerCharset' - ]; - foreach ($simpleMethods as $method) { - if (isset($config[$method])) { - $this->$method($config[$method]); - } - } - - if (empty($this->headerCharset)) { - $this->headerCharset = $this->charset; - } - if (isset($config['headers'])) { - $this->setHeaders($config['headers']); - } - - $viewBuilderMethods = [ - 'template', 'layout', 'theme' - ]; - foreach ($viewBuilderMethods as $method) { - if (array_key_exists($method, $config)) { - $this->viewBuilder()->$method($config[$method]); - } - } - - if (array_key_exists('helpers', $config)) { - $this->viewBuilder()->setHelpers($config['helpers'], false); - } - if (array_key_exists('viewRender', $config)) { - $this->viewBuilder()->setClassName($config['viewRender']); - } - if (array_key_exists('viewVars', $config)) { - $this->set($config['viewVars']); - } - } - - /** - * Reset all the internal variables to be able to send out a new email. - * - * @return $this - */ - public function reset() - { - $this->_to = []; - $this->_from = []; - $this->_sender = []; - $this->_replyTo = []; - $this->_readReceipt = []; - $this->_returnPath = []; - $this->_cc = []; - $this->_bcc = []; - $this->_messageId = true; - $this->_subject = ''; - $this->_headers = []; - $this->_textMessage = ''; - $this->_htmlMessage = ''; - $this->_message = []; - $this->_emailFormat = 'text'; - $this->_transport = null; - $this->_priority = null; - $this->charset = 'utf-8'; - $this->headerCharset = null; - $this->transferEncoding = null; - $this->_attachments = []; - $this->_profile = []; - $this->_emailPattern = self::EMAIL_PATTERN; - - $this->viewBuilder()->setLayout('default'); - $this->viewBuilder()->setTemplate(''); - $this->viewBuilder()->setClassName('Cake\View\View'); - $this->viewVars = []; - $this->viewBuilder()->setTheme(false); - $this->viewBuilder()->setHelpers(['Html'], false); - - return $this; - } - - /** - * Encode the specified string using the current charset - * - * @param string $text String to encode - * @return string Encoded string - */ - protected function _encode($text) - { - $restore = mb_internal_encoding(); - mb_internal_encoding($this->_appCharset); - if (empty($this->headerCharset)) { - $this->headerCharset = $this->charset; - } - $return = mb_encode_mimeheader($text, $this->headerCharset, 'B'); - mb_internal_encoding($restore); - - return $return; - } - - /** - * Decode the specified string - * - * @param string $text String to decode - * @return string Decoded string - */ - protected function _decode($text) - { - $restore = mb_internal_encoding(); - mb_internal_encoding($this->_appCharset); - $return = mb_decode_mimeheader($text); - mb_internal_encoding($restore); - - return $return; - } - - /** - * Translates a string for one charset to another if the App.encoding value - * differs and the mb_convert_encoding function exists - * - * @param string $text The text to be converted - * @param string $charset the target encoding - * @return string - */ - protected function _encodeString($text, $charset) - { - if ($this->_appCharset === $charset) { - return $text; - } - - return mb_convert_encoding($text, $charset, $this->_appCharset); - } - - /** - * Wrap the message to follow the RFC 2822 - 2.1.1 - * - * @param string $message Message to wrap - * @param int $wrapLength The line length - * @return array Wrapped message - */ - protected function _wrap($message, $wrapLength = Email::LINE_LENGTH_MUST) - { - if (strlen($message) === 0) { - return ['']; - } - $message = str_replace(["\r\n", "\r"], "\n", $message); - $lines = explode("\n", $message); - $formatted = []; - $cut = ($wrapLength == Email::LINE_LENGTH_MUST); - - foreach ($lines as $line) { - if (empty($line) && $line !== '0') { - $formatted[] = ''; - continue; - } - if (strlen($line) < $wrapLength) { - $formatted[] = $line; - continue; - } - if (!preg_match('/<[a-z]+.*>/i', $line)) { - $formatted = array_merge( - $formatted, - explode("\n", wordwrap($line, $wrapLength, "\n", $cut)) - ); - continue; - } - - $tagOpen = false; - $tmpLine = $tag = ''; - $tmpLineLength = 0; - for ($i = 0, $count = strlen($line); $i < $count; $i++) { - $char = $line[$i]; - if ($tagOpen) { - $tag .= $char; - if ($char === '>') { - $tagLength = strlen($tag); - if ($tagLength + $tmpLineLength < $wrapLength) { - $tmpLine .= $tag; - $tmpLineLength += $tagLength; - } else { - if ($tmpLineLength > 0) { - $formatted = array_merge( - $formatted, - explode("\n", wordwrap(trim($tmpLine), $wrapLength, "\n", $cut)) - ); - $tmpLine = ''; - $tmpLineLength = 0; - } - if ($tagLength > $wrapLength) { - $formatted[] = $tag; - } else { - $tmpLine = $tag; - $tmpLineLength = $tagLength; - } - } - $tag = ''; - $tagOpen = false; - } - continue; - } - if ($char === '<') { - $tagOpen = true; - $tag = '<'; - continue; - } - if ($char === ' ' && $tmpLineLength >= $wrapLength) { - $formatted[] = $tmpLine; - $tmpLineLength = 0; - continue; - } - $tmpLine .= $char; - $tmpLineLength++; - if ($tmpLineLength === $wrapLength) { - $nextChar = $line[$i + 1]; - if ($nextChar === ' ' || $nextChar === '<') { - $formatted[] = trim($tmpLine); - $tmpLine = ''; - $tmpLineLength = 0; - if ($nextChar === ' ') { - $i++; - } - } else { - $lastSpace = strrpos($tmpLine, ' '); - if ($lastSpace === false) { - continue; - } - $formatted[] = trim(substr($tmpLine, 0, $lastSpace)); - $tmpLine = substr($tmpLine, $lastSpace + 1); - - $tmpLineLength = strlen($tmpLine); - } - } - } - if (!empty($tmpLine)) { - $formatted[] = $tmpLine; - } - } - $formatted[] = ''; - - return $formatted; - } - - /** - * Create unique boundary identifier - * - * @return void - */ - protected function _createBoundary() - { - if ($this->_attachments || $this->_emailFormat === 'both') { - $this->_boundary = md5(Security::randomBytes(16)); - } - } - - /** - * Attach non-embedded files by adding file contents inside boundaries. - * - * @param string|null $boundary Boundary to use. If null, will default to $this->_boundary - * @return array An array of lines to add to the message - */ - protected function _attachFiles($boundary = null) - { - if ($boundary === null) { - $boundary = $this->_boundary; - } - - $msg = []; - foreach ($this->_attachments as $filename => $fileInfo) { - if (!empty($fileInfo['contentId'])) { - continue; - } - $data = isset($fileInfo['data']) ? $fileInfo['data'] : $this->_readFile($fileInfo['file']); - $hasDisposition = ( - !isset($fileInfo['contentDisposition']) || - $fileInfo['contentDisposition'] - ); - $part = new FormDataPart(false, $data, false); - - if ($hasDisposition) { - $part->disposition('attachment'); - $part->filename($filename); - } - $part->transferEncoding('base64'); - $part->type($fileInfo['mimetype']); - - $msg[] = '--' . $boundary; - $msg[] = (string)$part; - $msg[] = ''; - } - - return $msg; - } - - /** - * Read the file contents and return a base64 version of the file contents. - * - * @param string $path The absolute path to the file to read. - * @return string File contents in base64 encoding - */ - protected function _readFile($path) - { - $File = new File($path); - - return chunk_split(base64_encode($File->read())); - } - - /** - * Attach inline/embedded files to the message. - * - * @param string|null $boundary Boundary to use. If null, will default to $this->_boundary - * @return array An array of lines to add to the message - */ - protected function _attachInlineFiles($boundary = null) - { - if ($boundary === null) { - $boundary = $this->_boundary; - } - - $msg = []; - foreach ($this->_attachments as $filename => $fileInfo) { - if (empty($fileInfo['contentId'])) { - continue; - } - $data = isset($fileInfo['data']) ? $fileInfo['data'] : $this->_readFile($fileInfo['file']); - - $msg[] = '--' . $boundary; - $part = new FormDataPart(false, $data, 'inline'); - $part->type($fileInfo['mimetype']); - $part->transferEncoding('base64'); - $part->contentId($fileInfo['contentId']); - $part->filename($filename); - $msg[] = (string)$part; - $msg[] = ''; - } - - return $msg; - } - - /** - * Render the body of the email. - * - * @param array $content Content to render - * @return array Email body ready to be sent - */ - protected function _render($content) - { - $this->_textMessage = $this->_htmlMessage = ''; - - $content = implode("\n", $content); - $rendered = $this->_renderTemplates($content); - - $this->_createBoundary(); - $msg = []; - - $contentIds = array_filter((array)Hash::extract($this->_attachments, '{s}.contentId')); - $hasInlineAttachments = count($contentIds) > 0; - $hasAttachments = !empty($this->_attachments); - $hasMultipleTypes = count($rendered) > 1; - $multiPart = ($hasAttachments || $hasMultipleTypes); - - $boundary = $relBoundary = $textBoundary = $this->_boundary; - - if ($hasInlineAttachments) { - $msg[] = '--' . $boundary; - $msg[] = 'Content-Type: multipart/related; boundary="rel-' . $boundary . '"'; - $msg[] = ''; - $relBoundary = $textBoundary = 'rel-' . $boundary; - } - - if ($hasMultipleTypes && $hasAttachments) { - $msg[] = '--' . $relBoundary; - $msg[] = 'Content-Type: multipart/alternative; boundary="alt-' . $boundary . '"'; - $msg[] = ''; - $textBoundary = 'alt-' . $boundary; - } - - if (isset($rendered['text'])) { - if ($multiPart) { - $msg[] = '--' . $textBoundary; - $msg[] = 'Content-Type: text/plain; charset=' . $this->_getContentTypeCharset(); - $msg[] = 'Content-Transfer-Encoding: ' . $this->_getContentTransferEncoding(); - $msg[] = ''; - } - $this->_textMessage = $rendered['text']; - $content = explode("\n", $this->_textMessage); - $msg = array_merge($msg, $content); - $msg[] = ''; - } - - if (isset($rendered['html'])) { - if ($multiPart) { - $msg[] = '--' . $textBoundary; - $msg[] = 'Content-Type: text/html; charset=' . $this->_getContentTypeCharset(); - $msg[] = 'Content-Transfer-Encoding: ' . $this->_getContentTransferEncoding(); - $msg[] = ''; - } - $this->_htmlMessage = $rendered['html']; - $content = explode("\n", $this->_htmlMessage); - $msg = array_merge($msg, $content); - $msg[] = ''; - } - - if ($textBoundary !== $relBoundary) { - $msg[] = '--' . $textBoundary . '--'; - $msg[] = ''; - } - - if ($hasInlineAttachments) { - $attachments = $this->_attachInlineFiles($relBoundary); - $msg = array_merge($msg, $attachments); - $msg[] = ''; - $msg[] = '--' . $relBoundary . '--'; - $msg[] = ''; - } - - if ($hasAttachments) { - $attachments = $this->_attachFiles($boundary); - $msg = array_merge($msg, $attachments); - } - if ($hasAttachments || $hasMultipleTypes) { - $msg[] = ''; - $msg[] = '--' . $boundary . '--'; - $msg[] = ''; - } - - return $msg; - } - - /** - * Gets the text body types that are in this email message - * - * @return array Array of types. Valid types are 'text' and 'html' - */ - protected function _getTypes() - { - $types = [$this->_emailFormat]; - if ($this->_emailFormat === 'both') { - $types = ['html', 'text']; - } - - return $types; - } - - /** - * Build and set all the view properties needed to render the templated emails. - * If there is no template set, the $content will be returned in a hash - * of the text content types for the email. - * - * @param string $content The content passed in from send() in most cases. - * @return array The rendered content with html and text keys. - */ - protected function _renderTemplates($content) - { - $types = $this->_getTypes(); - $rendered = []; - $template = $this->viewBuilder()->getTemplate(); - if (empty($template)) { - foreach ($types as $type) { - $rendered[$type] = $this->_encodeString($content, $this->charset); - } - - return $rendered; - } - - $View = $this->createView(); - - list($templatePlugin) = pluginSplit($View->getTemplate()); - list($layoutPlugin) = pluginSplit($View->getLayout()); - if ($templatePlugin) { - $View->plugin = $templatePlugin; - } elseif ($layoutPlugin) { - $View->plugin = $layoutPlugin; - } - - if ($View->get('content') === null) { - $View->set('content', $content); - } - - foreach ($types as $type) { - $View->hasRendered = false; - $View->setTemplatePath('Email' . DIRECTORY_SEPARATOR . $type); - $View->setLayoutPath('Email' . DIRECTORY_SEPARATOR . $type); - - $render = $View->render(); - $render = str_replace(["\r\n", "\r"], "\n", $render); - $rendered[$type] = $this->_encodeString($render, $this->charset); - } - - foreach ($rendered as $type => $content) { - $rendered[$type] = $this->_wrap($content); - $rendered[$type] = implode("\n", $rendered[$type]); - $rendered[$type] = rtrim($rendered[$type], "\n"); - } - - return $rendered; - } - - /** - * Return the Content-Transfer Encoding value based - * on the set transferEncoding or set charset. - * - * @return string - */ - protected function _getContentTransferEncoding() - { - if ($this->transferEncoding) { - return $this->transferEncoding; - } - - $charset = strtoupper($this->charset); - if (in_array($charset, $this->_charset8bit)) { - return '8bit'; - } - - return '7bit'; - } - - /** - * Return charset value for Content-Type. - * - * Checks fallback/compatibility types which include workarounds - * for legacy japanese character sets. - * - * @return string - */ - protected function _getContentTypeCharset() - { - $charset = strtoupper($this->charset); - if (array_key_exists($charset, $this->_contentTypeCharset)) { - return strtoupper($this->_contentTypeCharset[$charset]); - } - - return strtoupper($this->charset); - } - - /** - * Serializes the email object to a value that can be natively serialized and re-used - * to clone this email instance. - * - * It has certain limitations for viewVars that are good to know: - * - * - ORM\Query executed and stored as resultset - * - SimpleXMLElements stored as associative array - * - Exceptions stored as strings - * - Resources, \Closure and \PDO are not supported. - * - * @return array Serializable array of configuration properties. - * @throws \Exception When a view var object can not be properly serialized. - */ - public function jsonSerialize() - { - $properties = [ - '_to', '_from', '_sender', '_replyTo', '_cc', '_bcc', '_subject', - '_returnPath', '_readReceipt', '_emailFormat', '_emailPattern', '_domain', - '_attachments', '_messageId', '_headers', '_appCharset', 'viewVars', 'charset', 'headerCharset' - ]; - - $array = ['viewConfig' => $this->viewBuilder()->jsonSerialize()]; - - foreach ($properties as $property) { - $array[$property] = $this->{$property}; - } - - array_walk($array['_attachments'], function (&$item, $key) { - if (!empty($item['file'])) { - $item['data'] = $this->_readFile($item['file']); - unset($item['file']); - } - }); - - array_walk_recursive($array['viewVars'], [$this, '_checkViewVars']); - - return array_filter($array, function ($i) { - return !is_array($i) && strlen($i) || !empty($i); - }); - } - - /** - * Iterates through hash to clean up and normalize. - * - * @param mixed $item Reference to the view var value. - * @param string $key View var key. - * @return void - */ - protected function _checkViewVars(&$item, $key) - { - if ($item instanceof Exception) { - $item = (string)$item; - } - - if (is_resource($item) || - $item instanceof Closure || - $item instanceof PDO - ) { - throw new RuntimeException(sprintf( - 'Failed serializing the `%s` %s in the `%s` view var', - is_resource($item) ? get_resource_type($item) : get_class($item), - is_resource($item) ? 'resource' : 'object', - $key - )); - } - } - - /** - * Configures an email instance object from serialized config. - * - * @param array $config Email configuration array. - * @return $this Configured email instance. - */ - public function createFromArray($config) - { - if (isset($config['viewConfig'])) { - $this->viewBuilder()->createFromArray($config['viewConfig']); - unset($config['viewConfig']); - } - - foreach ($config as $property => $value) { - $this->{$property} = $value; - } - - return $this; - } - - /** - * Serializes the Email object. - * - * @return string - */ - public function serialize() - { - $array = $this->jsonSerialize(); - array_walk_recursive($array, function (&$item, $key) { - if ($item instanceof SimpleXMLElement) { - $item = json_decode(json_encode((array)$item), true); - } - }); - - return serialize($array); - } - - /** - * Unserializes the Email object. - * - * @param string $data Serialized string. - * @return static Configured email instance. - */ - public function unserialize($data) - { - return $this->createFromArray(unserialize($data)); - } -} diff --git a/src/Mailer/Exception/MissingActionException.php b/src/Mailer/Exception/MissingActionException.php index d1a9734eb57..2bfb2f75d68 100644 --- a/src/Mailer/Exception/MissingActionException.php +++ b/src/Mailer/Exception/MissingActionException.php @@ -1,4 +1,6 @@ 'onRegistration', * ]; * } * - * public function onRegistration(Event $event, Entity $entity, ArrayObject $options) + * public function onRegistration(EventInterface $event, EntityInterface $entity, ArrayObject $options) * { * if ($entity->isNew()) { * $this->send('welcome', [$entity]); @@ -79,178 +87,203 @@ * Our mailer could either be registered in the application bootstrap, or * in the Table class' initialize() hook. * - * @method \Cake\Mailer\Email setTo($email, $name = null) - * @method array getTo() - * @method \Cake\Mailer\Email to($email = null, $name = null) - * @method \Cake\Mailer\Email setFrom($email, $name = null) - * @method array getFrom() - * @method \Cake\Mailer\Email from($email = null, $name = null) - * @method \Cake\Mailer\Email setSender($email, $name = null) - * @method array getSender() - * @method \Cake\Mailer\Email sender($email = null, $name = null) - * @method \Cake\Mailer\Email setReplyTo($email, $name = null) - * @method array getReplyTo() - * @method \Cake\Mailer\Email replyTo($email = null, $name = null) - * @method \Cake\Mailer\Email setReadReceipt($email, $name = null) - * @method array getReadReceipt() - * @method \Cake\Mailer\Email readReceipt($email = null, $name = null) - * @method \Cake\Mailer\Email setReturnPath($email, $name = null) - * @method array getReturnPath() - * @method \Cake\Mailer\Email returnPath($email = null, $name = null) - * @method \Cake\Mailer\Email addTo($email, $name = null) - * @method \Cake\Mailer\Email setCc($email, $name = null) - * @method array getCc() - * @method \Cake\Mailer\Email cc($email = null, $name = null) - * @method \Cake\Mailer\Email addCc($email, $name = null) - * @method \Cake\Mailer\Email setBcc($email, $name = null) - * @method array getBcc() - * @method \Cake\Mailer\Email bcc($email = null, $name = null) - * @method \Cake\Mailer\Email addBcc($email, $name = null) - * @method \Cake\Mailer\Email setCharset($charset) - * @method string getCharset() - * @method \Cake\Mailer\Email charset($charset = null) - * @method \Cake\Mailer\Email setHeaderCharset($charset) - * @method string getHeaderCharset() - * @method \Cake\Mailer\Email headerCharset($charset = null) - * @method \Cake\Mailer\Email setSubject($subject) - * @method string getSubject() - * @method \Cake\Mailer\Email subject($subject = null) - * @method \Cake\Mailer\Email setHeaders(array $headers) - * @method \Cake\Mailer\Email addHeaders(array $headers) - * @method \Cake\Mailer\Email getHeaders(array $include = []) - * @method \Cake\Mailer\Email setTemplate($template) - * @method string getTemplate() - * @method \Cake\Mailer\Email setLayout($layout) - * @method string getLayout() - * @method \Cake\Mailer\Email template($template = false, $layout = false) - * @method \Cake\Mailer\Email setViewRenderer($viewClass) - * @method string getViewRenderer() - * @method \Cake\Mailer\Email viewRender($viewClass = null) - * @method \Cake\Mailer\Email setViewVars($viewVars) - * @method array getViewVars() - * @method \Cake\Mailer\Email viewVars($viewVars = null) - * @method \Cake\Mailer\Email setTheme($theme) - * @method string getTheme() - * @method \Cake\Mailer\Email theme($theme = null) - * @method \Cake\Mailer\Email setHelpers(array $helpers) - * @method array getHelpers() - * @method \Cake\Mailer\Email helpers($helpers = null) - * @method \Cake\Mailer\Email setEmailFormat($format) - * @method string getEmailFormat() - * @method \Cake\Mailer\Email emailFormat($format = null) - * @method \Cake\Mailer\Email setTransport($name) - * @method \Cake\Mailer\AbstractTransport getTransport() - * @method \Cake\Mailer\Email transport($name = null) - * @method \Cake\Mailer\Email setMessageId($message) - * @method bool|string getMessageId() - * @method \Cake\Mailer\Email messageId($message = null) - * @method \Cake\Mailer\Email setDomain($domain) - * @method string getDomain() - * @method \Cake\Mailer\Email domain($domain = null) - * @method \Cake\Mailer\Email setAttachments($attachments) - * @method array getAttachments() - * @method \Cake\Mailer\Email attachments($attachments = null) - * @method \Cake\Mailer\Email addAttachments($attachments) - * @method \Cake\Mailer\Email message($type = null) - * @method \Cake\Mailer\Email setProfile($config) - * @method string|array getProfile() - * @method \Cake\Mailer\Email profile($config = null) + * @method $this setTo($email, $name = null) Sets "to" address. {@see \Cake\Mailer\Message::setTo()} + * @method array getTo() Gets "to" address. {@see \Cake\Mailer\Message::getTo()} + * @method $this setFrom($email, $name = null) Sets "from" address. {@see \Cake\Mailer\Message::setFrom()} + * @method array getFrom() Gets "from" address. {@see \Cake\Mailer\Message::getFrom()} + * @method $this setSender($email, $name = null) Sets "sender" address. {@see \Cake\Mailer\Message::setSender()} + * @method array getSender() Gets "sender" address. {@see \Cake\Mailer\Message::getSender()} + * @method $this setReplyTo($email, $name = null) Sets "Reply-To" address. {@see \Cake\Mailer\Message::setReplyTo()} + * @method array getReplyTo() Gets "Reply-To" address. {@see \Cake\Mailer\Message::getReplyTo()} + * @method $this addReplyTo($email, $name = null) Add "Reply-To" address. {@see \Cake\Mailer\Message::addReplyTo()} + * @method $this setReadReceipt($email, $name = null) Sets Read Receipt (Disposition-Notification-To header). + * {@see \Cake\Mailer\Message::setReadReceipt()} + * @method array getReadReceipt() Gets Read Receipt (Disposition-Notification-To header). + * {@see \Cake\Mailer\Message::getReadReceipt()} + * @method $this setReturnPath($email, $name = null) Sets return path. {@see \Cake\Mailer\Message::setReturnPath()} + * @method array getReturnPath() Gets return path. {@see \Cake\Mailer\Message::getReturnPath()} + * @method $this addTo($email, $name = null) Add "To" address. {@see \Cake\Mailer\Message::addTo()} + * @method $this setCc($email, $name = null) Sets "cc" address. {@see \Cake\Mailer\Message::setCc()} + * @method array getCc() Gets "cc" address. {@see \Cake\Mailer\Message::getCc()} + * @method $this addCc($email, $name = null) Add "cc" address. {@see \Cake\Mailer\Message::addCc()} + * @method $this setBcc($email, $name = null) Sets "bcc" address. {@see \Cake\Mailer\Message::setBcc()} + * @method array getBcc() Gets "bcc" address. {@see \Cake\Mailer\Message::getBcc()} + * @method $this addBcc($email, $name = null) Add "bcc" address. {@see \Cake\Mailer\Message::addBcc()} + * @method $this setCharset($charset) Charset setter. {@see \Cake\Mailer\Message::setCharset()} + * @method string getCharset() Charset getter. {@see \Cake\Mailer\Message::getCharset()} + * @method $this setHeaderCharset($charset) HeaderCharset setter. {@see \Cake\Mailer\Message::setHeaderCharset()} + * @method string getHeaderCharset() HeaderCharset getter. {@see \Cake\Mailer\Message::getHeaderCharset()} + * @method $this setSubject($subject) Sets subject. {@see \Cake\Mailer\Message::setSubject()} + * @method string getSubject() Gets subject. {@see \Cake\Mailer\Message::getSubject()} + * @method $this setHeaders(array $headers) Sets headers for the message. {@see \Cake\Mailer\Message::setHeaders()} + * @method $this addHeaders(array $headers) Add header for the message. {@see \Cake\Mailer\Message::addHeaders()} + * @method array getHeaders(array $include = []) Get list of headers. {@see \Cake\Mailer\Message::getHeaders()} + * @method $this setEmailFormat($format) Sets email format. {@see \Cake\Mailer\Message::setEmailFormat()} + * @method string getEmailFormat() Gets email format. {@see \Cake\Mailer\Message::getEmailFormat()} + * @method $this setMessageId($message) Sets message ID. {@see \Cake\Mailer\Message::setMessageId()} + * @method string|bool getMessageId() Gets message ID. {@see \Cake\Mailer\Message::getMessageId()} + * @method $this setDomain($domain) Sets domain. {@see \Cake\Mailer\Message::setDomain()} + * @method string getDomain() Gets domain. {@see \Cake\Mailer\Message::getDomain()} + * @method $this setAttachments($attachments) Add attachments to the email message. {@see \Cake\Mailer\Message::setAttachments()} + * @method array getAttachments() Gets attachments to the email message. {@see \Cake\Mailer\Message::getAttachments()} + * @method $this addAttachment(\Psr\Http\Message\UploadedFileInterface|string $path, ?string $name, ?string $mimetype, ?string $contentId, ?bool $contentDisposition) Add an attachment. {@see \Cake\Mailer\Message::addAttachment()} + * @method $this addAttachments($attachments) Add attachments. {@see \Cake\Mailer\Message::addAttachments()} + * @method array getBody(?string $type = null) Get generated message body as array. + * {@see \Cake\Mailer\Message::getBody()} */ -abstract class Mailer implements EventListenerInterface +class Mailer implements EventListenerInterface { - - use ModelAwareTrait; + use LocatorAwareTrait; + use StaticConfigTrait; /** * Mailer's name. * * @var string + * @deprecated 5.4.0 This property is unused. + */ + public static string $name; + + /** + * The transport instance to use for sending mail. + * + * @var \Cake\Mailer\AbstractTransport|null */ - static public $name; + protected ?AbstractTransport $transport = null; /** - * Email instance. + * Message class name. * - * @var \Cake\Mailer\Email + * @var class-string<\Cake\Mailer\Message> */ - protected $_email; + protected string $messageClass = Message::class; /** - * Cloned Email instance for restoring instance after email is sent by - * mailer action. + * Message instance. * - * @var \Cake\Mailer\Email + * @var \Cake\Mailer\Message + */ + protected Message $message; + + /** + * Email Renderer + * + * @var \Cake\Mailer\Renderer|null + */ + protected ?Renderer $renderer = null; + + /** + * Hold message, renderer and transport instance for restoring after running + * a mailer action. + * + * @var array + */ + protected array $clonedInstances = [ + 'message' => null, + 'renderer' => null, + 'transport' => null, + ]; + + /** + * Mailer driver class map. + * + * @var array + */ + protected static array $_dsnClassMap = []; + + /** + * @var array|null */ - protected $_clonedEmail; + protected ?array $logConfig = null; /** - * Constructor. + * Constructor * - * @param \Cake\Mailer\Email|null $email Email instance. + * @param array|string|null $config Array of configs, or string to load configs from app.php */ - public function __construct(Email $email = null) + public function __construct(array|string|null $config = null) { - if ($email === null) { - $email = new Email(); - } + $this->message = new $this->messageClass(); + + $config ??= static::getConfig('default'); - $this->_email = $email; - $this->_clonedEmail = clone $email; + if ($config) { + $this->setProfile($config); + } } /** - * Returns the mailer's name. + * Get the view builder. * - * @return string + * @return \Cake\View\ViewBuilder */ - public function getName() + public function viewBuilder(): ViewBuilder { - if (!static::$name) { - static::$name = str_replace( - 'Mailer', - '', - implode('', array_slice(explode('\\', get_class($this)), -1)) - ); - } + return $this->getRenderer()->viewBuilder(); + } - return static::$name; + /** + * Get email renderer. + * + * @return \Cake\Mailer\Renderer + */ + public function getRenderer(): Renderer + { + return $this->renderer ??= new Renderer(); } /** - * Sets layout to use. + * Set email renderer. * - * @deprecated 3.4.0 Use setLayout() which sets the layout on the email class instead. - * @param string $layout Name of the layout to use. + * @param \Cake\Mailer\Renderer $renderer Renderer instance. * @return $this */ - public function layout($layout) + public function setRenderer(Renderer $renderer) { - $this->_email->viewBuilder()->setLayout($layout); + $this->renderer = $renderer; return $this; } /** - * Get Email instance's view builder. + * Get message instance. * - * @return \Cake\View\ViewBuilder + * @return \Cake\Mailer\Message */ - public function viewBuilder() + public function getMessage(): Message { - return $this->_email->viewBuilder(); + return $this->message; } /** - * Magic method to forward method class to Email instance. + * Set message instance. + * + * @param \Cake\Mailer\Message $message Message instance. + * @return $this + * @deprecated 5.1.0 Configure the mailer according to the documentation instead of manually setting the Message instance. + */ + public function setMessage(Message $message) + { + deprecationWarning( + '5.1.0', + 'Setting the message instance is deprecated. Configure the mailer according to the documentation instead.', + ); + $this->message = $message; + + return $this; + } + + /** + * Magic method to forward method class to Message instance. * * @param string $method Method name. * @param array $args Method arguments * @return $this|mixed */ - public function __call($method, $args) + public function __call(string $method, array $args) { - $result = $this->_email->$method(...$args); - if (strpos($method, 'get') === 0) { + $result = $this->message->$method(...$args); + if (str_starts_with($method, 'get')) { return $result; } @@ -260,13 +293,13 @@ public function __call($method, $args) /** * Sets email view vars. * - * @param string|array $key Variable name or hash of view variables. + * @param array|string $key Variable name or hash of view variables. * @param mixed $value View variable value. * @return $this */ - public function set($key, $value = null) + public function setViewVars(array|string $key, mixed $value = null) { - $this->_email->setViewVars(is_string($key) ? [$key => $value] : $key); + $this->getRenderer()->set($key, $value); return $this; } @@ -274,56 +307,303 @@ public function set($key, $value = null) /** * Sends email. * - * @param string $action The name of the mailer action to trigger. + * If an `$action` is specified the internal state of the mailer will be + * backed up and restored after the action is run. + * + * @param string|null $action The name of the mailer action to trigger. + * If no action is specified then all other method arguments will be ignored. * @param array $args Arguments to pass to the triggered mailer action. * @param array $headers Headers to set. - * @return array + * @return array{headers: string, message: string, ...} Contains 'headers' and 'message' keys. Additional keys allowed. * @throws \Cake\Mailer\Exception\MissingActionException * @throws \BadMethodCallException */ - public function send($action, $args = [], $headers = []) + public function send(?string $action = null, array $args = [], array $headers = []): array { - try { - if (!method_exists($this, $action)) { - throw new MissingActionException([ - 'mailer' => $this->getName() . 'Mailer', - 'action' => $action, - ]); - } + if ($action === null) { + return $this->deliver(); + } - $this->_email->setHeaders($headers); - if (!$this->_email->viewBuilder()->getTemplate()) { - $this->_email->viewBuilder()->setTemplate($action); - } + if (!method_exists($this, $action)) { + throw new MissingActionException([ + 'mailer' => static::class, + 'action' => $action, + ]); + } + + $this->backup(); + + $this->getMessage()->setHeaders($headers); + if (!$this->viewBuilder()->getTemplate()) { + $this->viewBuilder()->setTemplate($action); + } + try { $this->$action(...$args); - $result = $this->_email->send(); + $result = $this->deliver(); } finally { - $this->reset(); + $this->restore(); } return $result; } /** - * Reset email instance. + * Render content and set message body. * + * @param string $content Content. * @return $this */ - protected function reset() + public function render(string $content = '') { - $this->_email = clone $this->_clonedEmail; + $content = $this->getRenderer()->render( + $content, + $this->message->getBodyTypes(), + ); + + $this->message->setBody($content); return $this; } + /** + * Render content and send email using configured transport. + * + * @param string $content Content. + * @return array{headers: string, message: string, ...} Contains 'headers' and 'message' keys. Additional keys allowed. + */ + public function deliver(string $content = ''): array + { + $this->render($content); + + $result = $this->getTransport()->send($this->message); + $this->logDelivery($result); + + return $result; + } + + /** + * Sets the configuration profile to use for this instance. + * + * @param array|string $config String with configuration name, or + * an array with config. + * @return $this + */ + public function setProfile(array|string $config) + { + if (is_string($config)) { + $name = $config; + $config = static::getConfig($name); + if (!$config) { + throw new InvalidArgumentException(sprintf('Unknown email configuration `%s`.', $name)); + } + unset($name); + } + + $simpleMethods = [ + 'transport', + ]; + foreach ($simpleMethods as $method) { + if (isset($config[$method])) { + $this->{'set' . ucfirst($method)}($config[$method]); + unset($config[$method]); + } + } + + $viewBuilderMethods = [ + 'template', 'layout', 'theme', + ]; + foreach ($viewBuilderMethods as $method) { + if (array_key_exists($method, $config)) { + $this->viewBuilder()->{'set' . ucfirst($method)}($config[$method]); + unset($config[$method]); + } + } + + if (array_key_exists('helpers', $config)) { + $this->viewBuilder()->setHelpers($config['helpers']); + unset($config['helpers']); + } + if (array_key_exists('viewRenderer', $config)) { + $this->viewBuilder()->setClassName($config['viewRenderer']); + unset($config['viewRenderer']); + } + if (array_key_exists('viewVars', $config)) { + $this->viewBuilder()->setVars($config['viewVars']); + unset($config['viewVars']); + } + if (isset($config['autoLayout'])) { + if ($config['autoLayout'] === false) { + $this->viewBuilder()->disableAutoLayout(); + } + unset($config['autoLayout']); + } + + if (isset($config['log'])) { + $this->setLogConfig($config['log']); + } + + $this->message->setConfig($config); + + return $this; + } + + /** + * Sets the transport. + * + * When setting the transport you can either use the name + * of a configured transport or supply a constructed transport. + * + * @param \Cake\Mailer\AbstractTransport|string $name Either the name of a configured + * transport, or a transport instance. + * @return $this + * @throws \LogicException When the chosen transport lacks a send method. + */ + public function setTransport(AbstractTransport|string $name) + { + if (is_string($name)) { + $this->transport = TransportFactory::get($name); + } else { + $this->transport = $name; + } + + return $this; + } + + /** + * Gets the transport. + * + * @return \Cake\Mailer\AbstractTransport + */ + public function getTransport(): AbstractTransport + { + if ($this->transport === null) { + throw new BadMethodCallException( + 'Transport was not defined. ' + . 'You must set on using setTransport() or set `transport` option in your mailer profile.', + ); + } + + return $this->transport; + } + + /** + * Backup message, renderer, transport instances before an action is run. + * + * @return void + */ + protected function backup(): void + { + $this->clonedInstances['message'] = clone $this->message; + if ($this->renderer !== null) { + $this->clonedInstances['renderer'] = clone $this->renderer; + } + if ($this->transport !== null) { + $this->clonedInstances['transport'] = clone $this->transport; + } + } + + /** + * Restore message, renderer, transport instances to state before an action was run. + * + * @return $this + */ + protected function restore() + { + foreach (array_keys($this->clonedInstances) as $key) { + if ($this->clonedInstances[$key] === null) { + if ($key === 'message') { + $this->message->reset(); + } else { + $this->{$key} = null; + } + } else { + $this->{$key} = clone $this->clonedInstances[$key]; + $this->clonedInstances[$key] = null; + } + } + + return $this; + } + + /** + * Reset all the internal variables to be able to send out a new email. + * + * @return $this + */ + public function reset() + { + $this->message->reset(); + $this->getRenderer()->reset(); + $this->transport = null; + $this->clonedInstances = [ + 'message' => null, + 'renderer' => null, + 'transport' => null, + ]; + + return $this; + } + + /** + * Log the email message delivery. + * + * @param array{headers: string, message: string, ...} $contents The content with 'headers' and 'message' keys. + * @return void + */ + protected function logDelivery(array $contents): void + { + if (!$this->logConfig) { + return; + } + + Log::write( + $this->logConfig['level'], + PHP_EOL . $this->flatten($contents['headers']) . PHP_EOL . PHP_EOL . $this->flatten($contents['message']), + $this->logConfig['scope'], + ); + } + + /** + * Set logging config. + * + * @param array|string|true $log Log config. + * @return void + */ + protected function setLogConfig(array|string|bool $log): void + { + $config = [ + 'level' => 'debug', + 'scope' => ['cake.mailer', 'email'], + ]; + if ($log !== true) { + if (!is_array($log)) { + $log = ['level' => $log]; + } + $config = $log + $config; + } + + $this->logConfig = $config; + } + + /** + * Converts given value to string + * + * @param array|string $value The value to convert + * @return string + */ + protected function flatten(array|string $value): string + { + return is_array($value) ? implode(';', $value) : $value; + } + /** * Implemented events. * - * @return array + * @return array */ - public function implementedEvents() + public function implementedEvents(): array { return []; } diff --git a/src/Mailer/MailerAwareTrait.php b/src/Mailer/MailerAwareTrait.php index 8b98f50da42..1b7611ff1b0 100644 --- a/src/Mailer/MailerAwareTrait.php +++ b/src/Mailer/MailerAwareTrait.php @@ -1,4 +1,6 @@ |string|null $config Array of configs, or profile name string. * @return \Cake\Mailer\Mailer * @throws \Cake\Mailer\Exception\MissingMailerException if undefined mailer class. */ - protected function getMailer($name, Email $email = null) + protected function getMailer(string $name, array|string|null $config = null): Mailer { - if ($email === null) { - $email = new Email(); - } - $className = App::className($name, 'Mailer', 'Mailer'); - - if (empty($className)) { + if ($className === null) { throw new MissingMailerException(compact('name')); } - return new $className($email); + return new $className($config); } } diff --git a/src/Mailer/Message.php b/src/Mailer/Message.php new file mode 100644 index 00000000000..a49e354d151 --- /dev/null +++ b/src/Mailer/Message.php @@ -0,0 +1,1946 @@ + + */ + protected array $emailFormatAvailable = [self::MESSAGE_TEXT, self::MESSAGE_HTML, self::MESSAGE_BOTH]; + + /** + * What format should the email be sent in + * + * @var string + */ + protected string $emailFormat = self::MESSAGE_TEXT; + + /** + * Charset the email body is sent in + * + * @var string + */ + protected string $charset = 'utf-8'; + + /** + * Charset the email header is sent in + * If null, the $charset property will be used as default + * + * @var string|null + */ + protected ?string $headerCharset = null; + + /** + * The email transfer encoding used. + * If null, the $charset property is used for determined the transfer encoding. + * + * @var string|null + */ + protected ?string $transferEncoding = null; + + /** + * Available encoding to be set for transfer. + * + * @var array + */ + protected array $transferEncodingAvailable = [ + '7bit', + '8bit', + 'base64', + 'binary', + 'quoted-printable', + ]; + + /** + * The application wide charset, used to encode headers and body + * + * @var string|null + */ + protected ?string $appCharset = null; + + /** + * List of files that should be attached to the email. + * + * Only absolute paths + * + * @var array + */ + protected array $attachments = []; + + /** + * If set, boundary to use for multipart mime messages + * + * @var string|null + */ + protected ?string $boundary = null; + + /** + * Contains the optional priority of the email. + * + * @var int|null + */ + protected ?int $priority = null; + + /** + * 8Bit character sets + * + * @var array + */ + protected array $charset8bit = ['UTF-8', 'SHIFT_JIS']; + + /** + * Define Content-Type charset name + * + * @var array + */ + protected array $contentTypeCharset = [ + 'ISO-2022-JP-MS' => 'ISO-2022-JP', + ]; + + /** + * Regex for email validation + * + * If null, filter_var() will be used. Use the emailPattern() method + * to set a custom pattern. + * + * @var string|null + */ + protected ?string $emailPattern = self::EMAIL_PATTERN; + + /** + * Properties that could be serialized + * + * @var array + */ + protected array $serializableProperties = [ + 'to', 'from', 'sender', 'replyTo', 'cc', 'bcc', 'subject', + 'returnPath', 'readReceipt', 'emailFormat', 'emailPattern', 'domain', + 'attachments', 'messageId', 'headers', 'appCharset', 'charset', 'headerCharset', + 'textMessage', 'htmlMessage', + ]; + + /** + * Constructor + * + * @param array|null $config Array of configs, or string to load configs from app.php + */ + public function __construct(?array $config = null) + { + $this->appCharset = Configure::read('App.encoding'); + if ($this->appCharset !== null) { + $this->charset = $this->appCharset; + } + $this->domain = (string)preg_replace('/\:\d+$/', '', (string)env('HTTP_HOST')); + if (!$this->domain) { + $this->domain = php_uname('n'); + } + + if ($config) { + $this->setConfig($config); + } + } + + /** + * Sets "from" address. + * + * @param array|string $email String with email, + * Array with email as key, name as value or email as value (without name) + * @param string|null $name Name + * @return $this + * @throws \InvalidArgumentException + */ + public function setFrom(array|string $email, ?string $name = null) + { + return $this->setEmailSingle('from', $email, $name, 'From requires only 1 email address.'); + } + + /** + * Gets "from" address. + * + * @return array + */ + public function getFrom(): array + { + return $this->from; + } + + /** + * Sets the "sender" address. See RFC link below for full explanation. + * + * @param array|string $email String with email, + * Array with email as key, name as value or email as value (without name) + * @param string|null $name Name + * @return $this + * @throws \InvalidArgumentException + * @link https://tools.ietf.org/html/rfc2822.html#section-3.6.2 + */ + public function setSender(array|string $email, ?string $name = null) + { + return $this->setEmailSingle('sender', $email, $name, 'Sender requires only 1 email address.'); + } + + /** + * Gets the "sender" address. See RFC link below for full explanation. + * + * @return array + * @link https://tools.ietf.org/html/rfc2822.html#section-3.6.2 + */ + public function getSender(): array + { + return $this->sender; + } + + /** + * Sets "Reply-To" address. + * + * @param array|string $email String with email, + * Array with email as key, name as value or email as value (without name) + * @param string|null $name Name + * @return $this + * @throws \InvalidArgumentException + */ + public function setReplyTo(array|string $email, ?string $name = null) + { + return $this->setEmail('replyTo', $email, $name); + } + + /** + * Gets "Reply-To" address. + * + * @return array + */ + public function getReplyTo(): array + { + return $this->replyTo; + } + + /** + * Add "Reply-To" address. + * + * @param array|string $email String with email, + * Array with email as key, name as value or email as value (without name) + * @param string|null $name Name + * @return $this + */ + public function addReplyTo(array|string $email, ?string $name = null) + { + return $this->addEmail('replyTo', $email, $name); + } + + /** + * Sets Read Receipt (Disposition-Notification-To header). + * + * @param array|string $email String with email, + * Array with email as key, name as value or email as value (without name) + * @param string|null $name Name + * @return $this + * @throws \InvalidArgumentException + */ + public function setReadReceipt(array|string $email, ?string $name = null) + { + return $this->setEmailSingle( + 'readReceipt', + $email, + $name, + 'Disposition-Notification-To requires only 1 email address.', + ); + } + + /** + * Gets Read Receipt (Disposition-Notification-To header). + * + * @return array + */ + public function getReadReceipt(): array + { + return $this->readReceipt; + } + + /** + * Sets return path. + * + * @param array|string $email String with email, + * Array with email as key, name as value or email as value (without name) + * @param string|null $name Name + * @return $this + * @throws \InvalidArgumentException + */ + public function setReturnPath(array|string $email, ?string $name = null) + { + return $this->setEmailSingle('returnPath', $email, $name, 'Return-Path requires only 1 email address.'); + } + + /** + * Gets return path. + * + * @return array + */ + public function getReturnPath(): array + { + return $this->returnPath; + } + + /** + * Sets "to" address. + * + * @param array|string $email String with email, + * Array with email as key, name as value or email as value (without name) + * @param string|null $name Name + * @return $this + */ + public function setTo(array|string $email, ?string $name = null) + { + return $this->setEmail('to', $email, $name); + } + + /** + * Gets "to" address + * + * @return array + */ + public function getTo(): array + { + return $this->to; + } + + /** + * Add "To" address. + * + * @param array|string $email String with email, + * Array with email as key, name as value or email as value (without name) + * @param string|null $name Name + * @return $this + */ + public function addTo(array|string $email, ?string $name = null) + { + return $this->addEmail('to', $email, $name); + } + + /** + * Sets "cc" address. + * + * @param array|string $email String with email, + * Array with email as key, name as value or email as value (without name) + * @param string|null $name Name + * @return $this + */ + public function setCc(array|string $email, ?string $name = null) + { + return $this->setEmail('cc', $email, $name); + } + + /** + * Gets "cc" address. + * + * @return array + */ + public function getCc(): array + { + return $this->cc; + } + + /** + * Add "cc" address. + * + * @param array|string $email String with email, + * Array with email as key, name as value or email as value (without name) + * @param string|null $name Name + * @return $this + */ + public function addCc(array|string $email, ?string $name = null) + { + return $this->addEmail('cc', $email, $name); + } + + /** + * Sets "bcc" address. + * + * @param array|string $email String with email, + * Array with email as key, name as value or email as value (without name) + * @param string|null $name Name + * @return $this + */ + public function setBcc(array|string $email, ?string $name = null) + { + return $this->setEmail('bcc', $email, $name); + } + + /** + * Gets "bcc" address. + * + * @return array + */ + public function getBcc(): array + { + return $this->bcc; + } + + /** + * Add "bcc" address. + * + * @param array|string $email String with email, + * Array with email as key, name as value or email as value (without name) + * @param string|null $name Name + * @return $this + */ + public function addBcc(array|string $email, ?string $name = null) + { + return $this->addEmail('bcc', $email, $name); + } + + /** + * Charset setter. + * + * @param string $charset Character set. + * @return $this + */ + public function setCharset(string $charset) + { + $this->charset = $charset; + + return $this; + } + + /** + * Charset getter. + * + * @return string Charset + */ + public function getCharset(): string + { + return $this->charset; + } + + /** + * HeaderCharset setter. + * + * @param string|null $charset Character set. + * @return $this + */ + public function setHeaderCharset(?string $charset) + { + $this->headerCharset = $charset; + + return $this; + } + + /** + * HeaderCharset getter. + * + * @return string Charset + */ + public function getHeaderCharset(): string + { + return $this->headerCharset ?: $this->charset; + } + + /** + * TransferEncoding setter. + * + * @param string|null $encoding Encoding set. + * @return $this + * @throws \InvalidArgumentException + */ + public function setTransferEncoding(?string $encoding) + { + if ($encoding !== null) { + $encoding = strtolower($encoding); + if (!in_array($encoding, $this->transferEncodingAvailable, true)) { + throw new InvalidArgumentException( + sprintf( + 'Transfer encoding not available. Can be : %s.', + implode(', ', $this->transferEncodingAvailable), + ), + ); + } + } + + $this->transferEncoding = $encoding; + + return $this; + } + + /** + * TransferEncoding getter. + * + * @return string|null Encoding + */ + public function getTransferEncoding(): ?string + { + return $this->transferEncoding; + } + + /** + * EmailPattern setter/getter + * + * @param string|null $regex The pattern to use for email address validation, + * null to unset the pattern and make use of filter_var() instead. + * @return $this + */ + public function setEmailPattern(?string $regex) + { + $this->emailPattern = $regex; + + return $this; + } + + /** + * EmailPattern setter/getter + * + * @return string|null + */ + public function getEmailPattern(): ?string + { + return $this->emailPattern; + } + + /** + * Set email + * + * @param string $varName Property name + * @param array|string $email String with email, + * Array with email as key, name as value or email as value (without name) + * @param string|null $name Name + * @return $this + * @throws \InvalidArgumentException + */ + protected function setEmail(string $varName, array|string $email, ?string $name) + { + if (!is_array($email)) { + $this->validateEmail($email, $varName); + $this->{$varName} = [$email => $name ?? $email]; + + return $this; + } + $list = []; + foreach ($email as $key => $value) { + if (is_int($key)) { + $key = $value; + } + $this->validateEmail($key, $varName); + $list[$key] = $value ?? $key; + } + $this->{$varName} = $list; + + return $this; + } + + /** + * Validate email address + * + * @param string $email Email address to validate + * @param string $context Which property was set + * @return void + * @throws \InvalidArgumentException If email address does not validate + */ + protected function validateEmail(string $email, string $context): void + { + if ($this->emailPattern === null) { + if (filter_var($email, FILTER_VALIDATE_EMAIL)) { + return; + } + } elseif (preg_match($this->emailPattern, $email)) { + return; + } + + $context = ltrim($context, '_'); + if ($email === '') { + throw new InvalidArgumentException(sprintf('The email set for `%s` is empty.', $context)); + } + throw new InvalidArgumentException(sprintf('Invalid email set for `%s`. You passed `%s`.', $context, $email)); + } + + /** + * Set only 1 email + * + * @param string $varName Property name + * @param array|string $email String with email, + * Array with email as key, name as value or email as value (without name) + * @param string|null $name Name + * @param string $throwMessage Exception message + * @return $this + * @throws \InvalidArgumentException + */ + protected function setEmailSingle(string $varName, array|string $email, ?string $name, string $throwMessage) + { + if ($email === []) { + $this->{$varName} = $email; + + return $this; + } + + $current = $this->{$varName}; + $this->setEmail($varName, $email, $name); + if (count($this->{$varName}) !== 1) { + $this->{$varName} = $current; + throw new InvalidArgumentException($throwMessage); + } + + return $this; + } + + /** + * Add email + * + * @param string $varName Property name + * @param array|string $email String with email, + * Array with email as key, name as value or email as value (without name) + * @param string|null $name Name + * @return $this + * @throws \InvalidArgumentException + */ + protected function addEmail(string $varName, array|string $email, ?string $name) + { + if (!is_array($email)) { + $this->validateEmail($email, $varName); + $name ??= $email; + $this->{$varName}[$email] = $name; + + return $this; + } + $list = []; + foreach ($email as $key => $value) { + if (is_int($key)) { + $key = $value; + } + $this->validateEmail($key, $varName); + $list[$key] = $value; + } + $this->{$varName} = array_merge($this->{$varName}, $list); + + return $this; + } + + /** + * Sets subject. + * + * @param string $subject Subject string. + * @return $this + */ + public function setSubject(string $subject) + { + $this->subject = $this->encodeForHeader($subject); + + return $this; + } + + /** + * Gets subject. + * + * @return string + */ + public function getSubject(): string + { + return $this->subject; + } + + /** + * Get original subject without encoding + * + * @return string Original subject + */ + public function getOriginalSubject(): string + { + return $this->decodeForHeader($this->subject); + } + + /** + * Sets headers for the message + * + * @param array $headers Associative array containing headers to be set. + * @return $this + */ + public function setHeaders(array $headers) + { + $this->headers = $headers; + + return $this; + } + + /** + * Add header for the message + * + * @param array $headers Headers to set. + * @return $this + */ + public function addHeaders(array $headers) + { + $this->headers = Hash::merge($this->headers, $headers); + + return $this; + } + + /** + * Get list of headers + * + * ### Includes: + * + * - `from` + * - `replyTo` + * - `readReceipt` + * - `returnPath` + * - `to` + * - `cc` + * - `bcc` + * - `subject` + * + * @param array $include List of headers. + * @return array + */ + public function getHeaders(array $include = []): array + { + $this->createBoundary(); + + if ($include === array_values($include)) { + $include = array_fill_keys($include, true); + } + $defaults = array_fill_keys( + [ + 'from', 'sender', 'replyTo', 'readReceipt', 'returnPath', + 'to', 'cc', 'bcc', 'subject', + ], + false, + ); + $include += $defaults; + + $headers = []; + $relation = [ + 'from' => 'From', + 'replyTo' => 'Reply-To', + 'readReceipt' => 'Disposition-Notification-To', + 'returnPath' => 'Return-Path', + 'to' => 'To', + 'cc' => 'Cc', + 'bcc' => 'Bcc', + ]; + $headersMultipleEmails = ['to', 'cc', 'bcc', 'replyTo']; + foreach ($relation as $var => $header) { + if ($include[$var]) { + if (in_array($var, $headersMultipleEmails, true)) { + $headers[$header] = implode(', ', $this->formatAddress($this->{$var})); + } else { + $headers[$header] = (string)current($this->formatAddress($this->{$var})); + } + } + } + if ($include['sender']) { + if (key($this->sender) === key($this->from)) { + $headers['Sender'] = ''; + } else { + $headers['Sender'] = (string)current($this->formatAddress($this->sender)); + } + } + + $headers += $this->headers; + $headers['Date'] ??= date(DATE_RFC2822); + if ($this->messageId !== false) { + if ($this->messageId === true) { + $this->messageId = '<' . str_replace('-', '', Text::uuid()) . '@' . $this->domain . '>'; + } + + $headers['Message-ID'] = $this->messageId; + } + + if ($this->priority) { + $headers['X-Priority'] = (string)$this->priority; + } + + if ($include['subject']) { + $headers['Subject'] = $this->subject; + } + + $headers['MIME-Version'] = '1.0'; + if ($this->attachments) { + $headers['Content-Type'] = 'multipart/mixed; boundary="' . $this->boundary . '"'; + } elseif ($this->emailFormat === static::MESSAGE_BOTH) { + $headers['Content-Type'] = 'multipart/alternative; boundary="' . $this->boundary . '"'; + } elseif ($this->emailFormat === static::MESSAGE_TEXT) { + $headers['Content-Type'] = 'text/plain; charset=' . $this->getContentTypeCharset(); + } elseif ($this->emailFormat === static::MESSAGE_HTML) { + $headers['Content-Type'] = 'text/html; charset=' . $this->getContentTypeCharset(); + } + $headers['Content-Transfer-Encoding'] = $this->getContentTransferEncoding(); + + return $headers; + } + + /** + * Get headers as string. + * + * @param array $include List of headers. + * @param string $eol End of line string for concatenating headers. + * @param \Closure|null $callback Callback to run each header value through before stringifying. + * @return string + * @see Message::getHeaders() + */ + public function getHeadersString(array $include = [], string $eol = "\r\n", ?Closure $callback = null): string + { + $lines = $this->getHeaders($include); + + if ($callback) { + $lines = array_map($callback, $lines); + } + + $headers = []; + foreach ($lines as $key => $value) { + if ($value === '') { + continue; + } + + foreach ((array)$value as $val) { + $headers[] = $key . ': ' . $val; + } + } + + return implode($eol, $headers); + } + + /** + * Format addresses + * + * If the address contains non alphanumeric/whitespace characters, it will + * be quoted as characters like `:` and `,` are known to cause issues + * in address header fields. + * + * @param array $address Addresses to format. + * @return array + */ + public function formatAddress(array $address): array + { + $return = []; + foreach ($address as $email => $alias) { + if ($email === $alias) { + $return[] = $email; + } else { + $encoded = $this->encodeForHeader($alias); + if (preg_match('/[^a-z0-9+\-\\=? ]/i', $encoded)) { + $encoded = '"' . addcslashes($encoded, '"\\') . '"'; + } + $return[] = sprintf('%s <%s>', $encoded, $email); + } + } + + return $return; + } + + /** + * Sets email format. + * + * @param string $format Formatting string. + * @return $this + * @throws \InvalidArgumentException + */ + public function setEmailFormat(string $format) + { + if (!in_array($format, $this->emailFormatAvailable, true)) { + throw new InvalidArgumentException('Format not available.'); + } + $this->emailFormat = $format; + + return $this; + } + + /** + * Gets email format. + * + * @return string + */ + public function getEmailFormat(): string + { + return $this->emailFormat; + } + + /** + * Gets the body types that are in this email message + * + * @return array Array of types. Valid types are Email::MESSAGE_TEXT and Email::MESSAGE_HTML + */ + public function getBodyTypes(): array + { + $format = $this->emailFormat; + + if ($format === static::MESSAGE_BOTH) { + return [static::MESSAGE_HTML, static::MESSAGE_TEXT]; + } + + return [$format]; + } + + /** + * Sets message ID. + * + * @param string|bool $message True to generate a new Message-ID, False to ignore (not send in email), + * String to set as Message-ID. + * @return $this + * @throws \InvalidArgumentException + */ + public function setMessageId(string|bool $message) + { + if (is_bool($message)) { + $this->messageId = $message; + } else { + if (!preg_match('/^\<.+@.+\>$/', $message)) { + throw new InvalidArgumentException( + 'Invalid format to Message-ID. The text should be something like ""', + ); + } + $this->messageId = $message; + } + + return $this; + } + + /** + * Gets message ID. + * + * @return string|bool + */ + public function getMessageId(): string|bool + { + return $this->messageId; + } + + /** + * Sets domain. + * + * Domain as top level (the part after @). + * + * @param string $domain Manually set the domain for CLI mailing. + * @return $this + */ + public function setDomain(string $domain) + { + $this->domain = $domain; + + return $this; + } + + /** + * Gets domain. + * + * @return string + */ + public function getDomain(): string + { + return $this->domain; + } + + /** + * Add attachments to the email message + * + * Attachments can be defined in a few forms depending on how much control you need: + * + * Attach a file: + * + * ``` + * $this->setAttachments(['custom_name.txt' => 'path/to/file.txt']); + * ``` + * + * Attach a file and specify additional properties: + * + * ``` + * $this->setAttachments(['custom_name.png' => [ + * 'file' => 'path/to/file', + * 'mimetype' => 'image/png', + * 'contentId' => 'abc123', + * 'contentDisposition' => false + * ] + * ]); + * ``` + * + * Attach a file from string and specify additional properties: + * + * ``` + * $this->setAttachments(['custom_name.png' => [ + * 'data' => file_get_contents('path/to/file'), + * 'mimetype' => 'image/png' + * ] + * ]); + * ``` + * + * The `contentId` key allows you to specify an inline attachment. In your email text, you + * can use `` to display the image inline. + * + * The `contentDisposition` key allows you to disable the `Content-Disposition` header, this can improve + * attachment compatibility with outlook email clients. + * + * @param array $attachments Array of filenames. + * @return $this + * @throws \InvalidArgumentException + */ + public function setAttachments(array $attachments) + { + $attach = []; + foreach ($attachments as $name => $fileInfo) { + if (!is_array($fileInfo)) { + $fileInfo = ['file' => $fileInfo]; + } + if (!isset($fileInfo['file'])) { + if (!isset($fileInfo['data'])) { + throw new InvalidArgumentException('No file or data specified.'); + } + if (is_int($name)) { + throw new InvalidArgumentException('No filename specified.'); + } + $fileInfo['data'] = chunk_split(base64_encode($fileInfo['data']), 76, "\r\n"); + } elseif ($fileInfo['file'] instanceof UploadedFileInterface) { + $fileInfo['mimetype'] = $fileInfo['file']->getClientMediaType(); + if (is_int($name)) { + $name = $fileInfo['file']->getClientFilename(); + assert(is_string($name)); + } + } elseif (is_string($fileInfo['file'])) { + $fileName = $fileInfo['file']; + $fileInfo['file'] = realpath($fileInfo['file']); + if ($fileInfo['file'] === false || !file_exists($fileInfo['file'])) { + throw new InvalidArgumentException(sprintf('File not found: `%s`', $fileName)); + } + if (is_int($name)) { + $name = basename($fileInfo['file']); + } + } else { + throw new InvalidArgumentException(sprintf( + 'File must be a filepath or UploadedFileInterface instance. Found `%s` instead.', + gettype($fileInfo['file']), + )); + } + if ( + !isset($fileInfo['mimetype']) + && isset($fileInfo['file']) + && is_string($fileInfo['file']) + && function_exists('mime_content_type') + ) { + $fileInfo['mimetype'] = mime_content_type($fileInfo['file']); + } + $fileInfo['mimetype'] ??= 'application/octet-stream'; + + $attach[$name] = $fileInfo; + } + $this->attachments = $attach; + + return $this; + } + + /** + * Gets attachments to the email message. + * + * @return array Array of attachments. + */ + public function getAttachments(): array + { + return $this->attachments; + } + + /** + * Add attachment. + * + * @param \Psr\Http\Message\UploadedFileInterface|string $path Path to the file or UploadedFileInterface instance. + * @param string|null $name Overrides the attachment name. + * @param string|null $mimetype Mimetype of the file. + * @param string|null $contentId Content ID for inline attachments. + * @param bool|null $contentDisposition Allows you to disable the `Content-Disposition` header + * @return $this + */ + public function addAttachment( + UploadedFileInterface|string $path, + ?string $name = null, + ?string $mimetype = null, + ?string $contentId = null, + ?bool $contentDisposition = null, + ) { + $name ??= 0; + + $this->addAttachments([$name => [ + 'file' => $path, + 'mimetype' => $mimetype, + 'contentId' => $contentId, + 'contentDisposition' => $contentDisposition, + ]]); + + return $this; + } + + /** + * Add attachments + * + * @param array $attachments Array of filenames. + * @return $this + * @throws \InvalidArgumentException + * @see Message::setAttachments() + */ + public function addAttachments(array $attachments) + { + $current = $this->attachments; + $this->setAttachments($attachments); + $this->attachments = array_merge($current, $this->attachments); + + return $this; + } + + /** + * Get generated message body as array. + * + * @return array + */ + public function getBody(): array + { + if (!$this->message) { + $this->message = $this->generateMessage(); + } + + return $this->message; + } + + /** + * Get generated body as string. + * + * @param string $eol End of line string for imploding. + * @return string + * @see Message::getBody() + */ + public function getBodyString(string $eol = "\r\n"): string + { + $lines = $this->getBody(); + + return implode($eol, $lines); + } + + /** + * Create unique boundary identifier + * + * @return void + */ + protected function createBoundary(): void + { + if ( + $this->boundary === null && + ( + $this->attachments || + $this->emailFormat === static::MESSAGE_BOTH + ) + ) { + $this->boundary = hash('xxh128', Security::randomBytes(16)); + } + } + + /** + * Generate full message. + * + * @return array + */ + protected function generateMessage(): array + { + $this->createBoundary(); + $msg = []; + + $contentIds = array_filter((array)Hash::extract($this->attachments, '{s}.contentId')); + $hasInlineAttachments = $contentIds !== []; + $hasAttachments = $this->attachments !== []; + $hasMultipleTypes = $this->emailFormat === static::MESSAGE_BOTH; + $multiPart = ($hasAttachments || $hasMultipleTypes); + + $boundary = $this->boundary ?? ''; + $relBoundary = $boundary; + $textBoundary = $boundary; + + if ($hasInlineAttachments) { + $msg[] = '--' . $boundary; + $msg[] = 'Content-Type: multipart/related; boundary="rel-' . $boundary . '"'; + $msg[] = ''; + $relBoundary = 'rel-' . $boundary; + $textBoundary = 'rel-' . $boundary; + } + + if ($hasMultipleTypes && $hasAttachments) { + $msg[] = '--' . $relBoundary; + $msg[] = 'Content-Type: multipart/alternative; boundary="alt-' . $boundary . '"'; + $msg[] = ''; + $textBoundary = 'alt-' . $boundary; + } + + if ( + $this->emailFormat === static::MESSAGE_TEXT + || $this->emailFormat === static::MESSAGE_BOTH + ) { + if ($multiPart) { + $msg[] = '--' . $textBoundary; + $msg[] = 'Content-Type: text/plain; charset=' . $this->getContentTypeCharset(); + $msg[] = 'Content-Transfer-Encoding: ' . $this->getContentTransferEncoding(); + $msg[] = ''; + } + $content = explode("\n", $this->textMessage); + $msg = array_merge($msg, $content); + $msg[] = ''; + $msg[] = ''; + } + + if ( + $this->emailFormat === static::MESSAGE_HTML + || $this->emailFormat === static::MESSAGE_BOTH + ) { + if ($multiPart) { + $msg[] = '--' . $textBoundary; + $msg[] = 'Content-Type: text/html; charset=' . $this->getContentTypeCharset(); + $msg[] = 'Content-Transfer-Encoding: ' . $this->getContentTransferEncoding(); + $msg[] = ''; + } + $content = explode("\n", $this->htmlMessage); + $msg = array_merge($msg, $content); + $msg[] = ''; + $msg[] = ''; + } + + if ($textBoundary !== $relBoundary) { + $msg[] = '--' . $textBoundary . '--'; + $msg[] = ''; + } + + if ($hasInlineAttachments) { + $attachments = $this->attachInlineFiles($relBoundary); + $msg = array_merge($msg, $attachments); + $msg[] = ''; + $msg[] = '--' . $relBoundary . '--'; + $msg[] = ''; + } + + if ($hasAttachments) { + $attachments = $this->attachFiles($boundary); + $msg = array_merge($msg, $attachments); + } + if ($hasAttachments || $hasMultipleTypes) { + $msg[] = ''; + $msg[] = '--' . $boundary . '--'; + $msg[] = ''; + } + + return $msg; + } + + /** + * Attach non-embedded files by adding file contents inside boundaries. + * + * @param string|null $boundary Boundary to use. If null, will default to $this->boundary + * @return array An array of lines to add to the message + */ + protected function attachFiles(?string $boundary = null): array + { + $boundary ??= $this->boundary; + + $msg = []; + foreach ($this->attachments as $filename => $fileInfo) { + if (!empty($fileInfo['contentId'])) { + continue; + } + $data = $fileInfo['data'] ?? $this->readFile($fileInfo['file']); + $hasDisposition = ( + !isset($fileInfo['contentDisposition']) || + $fileInfo['contentDisposition'] + ); + $part = new FormDataPart('', $data, '', $this->getHeaderCharset()); + + if ($hasDisposition) { + $part->disposition('attachment'); + $part->filename($filename); + } + $part->transferEncoding('base64'); + $part->type($fileInfo['mimetype']); + + $msg[] = '--' . $boundary; + $msg[] = (string)$part; + $msg[] = ''; + } + + return $msg; + } + + /** + * Attach inline/embedded files to the message. + * + * @param string|null $boundary Boundary to use. If null, will default to $this->boundary + * @return array An array of lines to add to the message + */ + protected function attachInlineFiles(?string $boundary = null): array + { + $boundary ??= $this->boundary; + + $msg = []; + foreach ($this->getAttachments() as $filename => $fileInfo) { + if (empty($fileInfo['contentId'])) { + continue; + } + $data = $fileInfo['data'] ?? $this->readFile($fileInfo['file']); + + $msg[] = '--' . $boundary; + $part = new FormDataPart('', $data, 'inline', $this->getHeaderCharset()); + $part->type($fileInfo['mimetype']); + $part->transferEncoding('base64'); + $part->contentId($fileInfo['contentId']); + $part->filename($filename); + $msg[] = (string)$part; + $msg[] = ''; + } + + return $msg; + } + + /** + * Sets priority. + * + * @param int|null $priority 1 (highest) to 5 (lowest) + * @return $this + */ + public function setPriority(?int $priority) + { + $this->priority = $priority; + + return $this; + } + + /** + * Gets priority. + * + * @return int|null + */ + public function getPriority(): ?int + { + return $this->priority; + } + + /** + * Sets the configuration for this instance. + * + * @param array $config Config array. + * @return $this + */ + public function setConfig(array $config) + { + $simpleMethods = [ + 'from', 'sender', 'to', 'replyTo', 'readReceipt', 'returnPath', + 'cc', 'bcc', 'messageId', 'domain', 'subject', 'attachments', + 'emailFormat', 'emailPattern', 'charset', 'headerCharset', + ]; + foreach ($simpleMethods as $method) { + if (isset($config[$method])) { + $this->{'set' . ucfirst($method)}($config[$method]); + } + } + + if (isset($config['headers'])) { + $this->setHeaders($config['headers']); + } + + return $this; + } + + /** + * Set message body. + * + * @param array $content Content array with keys "text" and/or "html" with + * content string of respective type. + * @return $this + */ + public function setBody(array $content) + { + foreach ($content as $type => $text) { + if (!in_array($type, $this->emailFormatAvailable, true)) { + throw new InvalidArgumentException(sprintf( + 'Invalid message type: `%s`. Valid types are: `text`, `html`.', + $type, + )); + } + + $text = str_replace(["\r\n", "\r"], "\n", $text); + $text = $this->encodeString($text, $this->getCharset()); + $text = $this->wrap($text); + $text = implode("\n", $text); + $text = rtrim($text, "\n"); + + $property = "{$type}Message"; + $this->$property = $text; + } + + $this->boundary = null; + $this->message = []; + + return $this; + } + + /** + * Set text body for message. + * + * @param string $content Content string + * @return $this + */ + public function setBodyText(string $content) + { + $this->setBody([static::MESSAGE_TEXT => $content]); + + return $this; + } + + /** + * Set HTML body for message. + * + * @param string $content Content string + * @return $this + */ + public function setBodyHtml(string $content) + { + $this->setBody([static::MESSAGE_HTML => $content]); + + return $this; + } + + /** + * Get text body of message. + * + * @return string + */ + public function getBodyText(): string + { + return $this->textMessage; + } + + /** + * Get HTML body of message. + * + * @return string + */ + public function getBodyHtml(): string + { + return $this->htmlMessage; + } + + /** + * Translates a string for one charset to another if the App.encoding value + * differs and the mb_convert_encoding function exists + * + * @param string $text The text to be converted + * @param string $charset the target encoding + * @return string + */ + protected function encodeString(string $text, string $charset): string + { + if ($this->appCharset === $charset) { + return $text; + } + + if ($this->appCharset === null) { + $encoded = mb_convert_encoding($text, $charset); + if ($encoded === false) { + throw new RuntimeException('mb_convert_encoding failed.'); + } + + return $encoded; + } + + $encoded = mb_convert_encoding($text, $charset, $this->appCharset); + if ($encoded === false) { + throw new RuntimeException('mb_convert_encoding failed.'); + } + + return $encoded; + } + + /** + * Wrap the message to follow the RFC 2822 - 2.1.1 + * + * @param string|null $message Message to wrap + * @param int $wrapLength The line length + * @return array Wrapped message + */ + protected function wrap(?string $message = null, int $wrapLength = self::LINE_LENGTH_MUST): array + { + if ($message === null || $message === '') { + return ['']; + } + $message = str_replace(["\r\n", "\r"], "\n", $message); + $lines = explode("\n", $message); + $formatted = []; + $cut = ($wrapLength === static::LINE_LENGTH_MUST); + + foreach ($lines as $line) { + if ($line === '') { + $formatted[] = ''; + continue; + } + if (strlen($line) < $wrapLength) { + $formatted[] = $line; + continue; + } + if (!preg_match('/<[a-z]+.*>/i', $line)) { + $formatted = array_merge( + $formatted, + explode("\n", Text::wordWrap($line, $wrapLength, "\n", $cut)), + ); + continue; + } + + $tagOpen = false; + $tmpLine = ''; + $tag = ''; + $tmpLineLength = 0; + for ($i = 0, $count = strlen($line); $i < $count; $i++) { + $char = $line[$i]; + if ($tagOpen) { + $tag .= $char; + if ($char === '>') { + $tagLength = strlen($tag); + if ($tagLength + $tmpLineLength < $wrapLength) { + $tmpLine .= $tag; + $tmpLineLength += $tagLength; + } else { + if ($tmpLineLength > 0) { + $formatted = array_merge( + $formatted, + explode("\n", Text::wordWrap(trim($tmpLine), $wrapLength, "\n", $cut)), + ); + $tmpLine = ''; + $tmpLineLength = 0; + } + if ($tagLength > $wrapLength) { + $formatted[] = $tag; + } else { + $tmpLine = $tag; + $tmpLineLength = $tagLength; + } + } + $tag = ''; + $tagOpen = false; + } + continue; + } + if ($char === '<') { + $tagOpen = true; + $tag = '<'; + continue; + } + if ($char === ' ' && $tmpLineLength >= $wrapLength) { + $formatted[] = $tmpLine; + $tmpLineLength = 0; + continue; + } + $tmpLine .= $char; + $tmpLineLength++; + if ($tmpLineLength === $wrapLength) { + $nextChar = $line[$i + 1] ?? ''; + if ($nextChar === ' ' || $nextChar === '<') { + $formatted[] = trim($tmpLine); + $tmpLine = ''; + $tmpLineLength = 0; + if ($nextChar === ' ') { + $i++; + } + } else { + $lastSpace = strrpos($tmpLine, ' '); + if ($lastSpace === false) { + continue; + } + $formatted[] = trim(substr($tmpLine, 0, $lastSpace)); + $tmpLine = substr($tmpLine, $lastSpace + 1); + + $tmpLineLength = strlen($tmpLine); + } + } + } + if ($tmpLine) { + $formatted[] = $tmpLine; + } + } + $formatted[] = ''; + + return $formatted; + } + + /** + * Reset all the internal variables to be able to send out a new email. + * + * @return $this + */ + public function reset() + { + $this->to = []; + $this->from = []; + $this->sender = []; + $this->replyTo = []; + $this->readReceipt = []; + $this->returnPath = []; + $this->cc = []; + $this->bcc = []; + $this->messageId = true; + $this->subject = ''; + $this->headers = []; + $this->textMessage = ''; + $this->htmlMessage = ''; + $this->message = []; + $this->emailFormat = static::MESSAGE_TEXT; + $this->priority = null; + $this->charset = 'utf-8'; + $this->headerCharset = null; + $this->transferEncoding = null; + $this->attachments = []; + $this->emailPattern = static::EMAIL_PATTERN; + + return $this; + } + + /** + * Encode the specified string using the current charset + * + * @param string $text String to encode + * @return string Encoded string + */ + protected function encodeForHeader(string $text): string + { + if ($this->appCharset === null) { + return $text; + } + + $restore = mb_internal_encoding(); + mb_internal_encoding($this->appCharset); + $return = mb_encode_mimeheader($text, $this->getHeaderCharset(), 'B'); + mb_internal_encoding($restore); + + return $return; + } + + /** + * Decode the specified string + * + * @param string $text String to decode + * @return string Decoded string + */ + protected function decodeForHeader(string $text): string + { + if ($this->appCharset === null) { + return $text; + } + + $restore = mb_internal_encoding(); + mb_internal_encoding($this->appCharset); + $return = mb_decode_mimeheader($text); + mb_internal_encoding($restore); + + return $return; + } + + /** + * Read the file contents and return a base64 version of the file contents. + * + * @param \Psr\Http\Message\UploadedFileInterface|string $file The absolute path to the file to read + * or UploadedFileInterface instance. + * @return string File contents in base64 encoding + */ + protected function readFile(UploadedFileInterface|string $file): string + { + if (is_string($file)) { + $content = (string)file_get_contents($file); + } else { + $content = (string)$file->getStream(); + } + + return chunk_split(base64_encode($content)); + } + + /** + * Return the Content-Transfer Encoding value based + * on the set transferEncoding or set charset. + * + * @return string + */ + public function getContentTransferEncoding(): string + { + if ($this->transferEncoding) { + return $this->transferEncoding; + } + + $charset = strtoupper($this->charset); + if (in_array($charset, $this->charset8bit, true)) { + return '8bit'; + } + + return '7bit'; + } + + /** + * Return charset value for Content-Type. + * + * Checks fallback/compatibility types which include workarounds + * for legacy japanese character sets. + * + * @return string + */ + public function getContentTypeCharset(): string + { + $charset = strtoupper($this->charset); + if (array_key_exists($charset, $this->contentTypeCharset)) { + return strtoupper($this->contentTypeCharset[$charset]); + } + + return strtoupper($this->charset); + } + + /** + * Serializes the email object to a value that can be natively serialized and re-used + * to clone this email instance. + * + * @return array Serializable array of configuration properties. + * @throws \Exception When a view var object can not be properly serialized. + */ + public function jsonSerialize(): array + { + $array = []; + foreach ($this->serializableProperties as $property) { + $array[$property] = $this->{$property}; + } + + array_walk($array['attachments'], function (array &$item): void { + if (!empty($item['file'])) { + $item['data'] = $this->readFile($item['file']); + unset($item['file']); + } + }); + + return array_filter($array, function ($i) { + return $i !== null && !is_array($i) && !is_bool($i) && strlen($i) || !empty($i); + }); + } + + /** + * Configures an email instance object from serialized config. + * + * @param array $config Email configuration array. + * @return $this + */ + public function createFromArray(array $config) + { + foreach ($config as $property => $value) { + $this->{$property} = $value; + } + + return $this; + } + + /** + * Magic method used for serializing the Message object. + * + * @return array + */ + public function __serialize(): array + { + $array = $this->jsonSerialize(); + array_walk_recursive($array, function (&$item): void { + if ($item instanceof SimpleXMLElement) { + $item = json_decode((string)json_encode((array)$item), true); + } + }); + + /** @var array */ + return $array; + } + + /** + * Magic method used to rebuild the Message object. + * + * @param array $data Data array. + * @return void + */ + public function __unserialize(array $data): void + { + $this->createFromArray($data); + } +} diff --git a/src/Mailer/Renderer.php b/src/Mailer/Renderer.php new file mode 100644 index 00000000000..1459a2afebd --- /dev/null +++ b/src/Mailer/Renderer.php @@ -0,0 +1,117 @@ +reset(); + } + + /** + * Render text/HTML content. + * + * If there is no template set, the $content will be returned in a hash + * of the specified content types for the email. + * + * @param string $content The content. + * @param array<\Cake\Mailer\Message::MESSAGE_HTML|\Cake\Mailer\Message::MESSAGE_TEXT> $types Content types to render. Valid array values are {@link Message::MESSAGE_HTML}, {@link Message::MESSAGE_TEXT}. + * @return array{html?: string, text?: string} The rendered content with "html" and/or "text" keys. + */ + public function render(string $content, array $types = []): array + { + $rendered = []; + $template = $this->viewBuilder()->getTemplate(); + if (!$template) { + foreach ($types as $type) { + $rendered[$type] = $content; + } + + return $rendered; + } + + $view = $this->createView(); + + [$templatePlugin] = pluginSplit($view->getTemplate()); + [$layoutPlugin] = pluginSplit($view->getLayout()); + if ($templatePlugin) { + $view->setPlugin($templatePlugin); + } elseif ($layoutPlugin) { + $view->setPlugin($layoutPlugin); + } + + if ($view->get('content') === null) { + $view->set('content', $content); + } + + foreach ($types as $type) { + $view->setTemplatePath(static::TEMPLATE_FOLDER . DIRECTORY_SEPARATOR . $type); + $view->setLayoutPath(static::TEMPLATE_FOLDER . DIRECTORY_SEPARATOR . $type); + + $rendered[$type] = $view->render(); + } + + return $rendered; + } + + /** + * Reset view builder to defaults. + * + * @return $this + */ + public function reset() + { + $this->_viewBuilder = null; + + $this->viewBuilder() + ->setClassName(View::class) + ->setLayout('default') + ->setHelpers(['Html']); + + return $this; + } + + /** + * Clone ViewBuilder instance when renderer is cloned. + */ + public function __clone() + { + if ($this->_viewBuilder !== null) { + $this->_viewBuilder = clone $this->_viewBuilder; + } + } +} diff --git a/src/Mailer/Transport/DebugTransport.php b/src/Mailer/Transport/DebugTransport.php index 0f973c03c2a..bfd33d459ac 100644 --- a/src/Mailer/Transport/DebugTransport.php +++ b/src/Mailer/Transport/DebugTransport.php @@ -1,6 +1,8 @@ getHeaders(['from', 'sender', 'replyTo', 'readReceipt', 'returnPath', 'to', 'cc', 'subject']); - $headers = $this->_headersToString($headers); - $message = implode("\r\n", (array)$email->message()); + $headers = $message->getHeadersString( + ['from', 'sender', 'replyTo', 'readReceipt', 'returnPath', 'to', 'cc', 'subject'], + ); + $message = implode("\r\n", $message->getBody()); return ['headers' => $headers, 'message' => $message]; } diff --git a/src/Mailer/Transport/MailTransport.php b/src/Mailer/Transport/MailTransport.php index 885eff625de..ff244c16c82 100644 --- a/src/Mailer/Transport/MailTransport.php +++ b/src/Mailer/Transport/MailTransport.php @@ -1,4 +1,6 @@ _config['eol'])) { - $eol = $this->_config['eol']; - } - $headers = $email->getHeaders(['from', 'sender', 'replyTo', 'readReceipt', 'returnPath', 'to', 'cc', 'bcc']); - $to = $headers['To']; - unset($headers['To']); - foreach ($headers as $key => $header) { - $headers[$key] = str_replace(["\r", "\n"], '', $header); - } - $headers = $this->_headersToString($headers, $eol); - $subject = str_replace(["\r", "\n"], '', $email->getSubject()); - $to = str_replace(["\r", "\n"], '', $to); + $this->checkRecipient($message); - $message = implode($eol, $email->message()); + // https://github.com/cakephp/cakephp/issues/2209 + // https://bugs.php.net/bug.php?id=47983 + $subject = str_replace("\r\n", '', $message->getSubject()); - $params = isset($this->_config['additionalParameters']) ? $this->_config['additionalParameters'] : null; + $to = $message->getHeaders(['to'])['To']; + $to = str_replace("\r\n", '', $to); + + $eol = $this->getConfig('eol', "\r\n"); + $headers = $message->getHeadersString( + [ + 'from', + 'sender', + 'replyTo', + 'readReceipt', + 'returnPath', + 'cc', + 'bcc', + ], + $eol, + function ($val) { + return str_replace("\r\n", '', $val); + }, + ); + + $message = $message->getBodyString($eol); + + $params = $this->getConfig('additionalParameters', ''); $this->_mail($to, $subject, $message, $headers, $params); $headers .= $eol . 'To: ' . $to; @@ -66,18 +76,23 @@ public function send(Email $email) * @param string $subject email's subject * @param string $message email's body * @param string $headers email's custom headers - * @param string|null $params additional params for sending email + * @param string $params additional params for sending email * @throws \Cake\Network\Exception\SocketException if mail could not be sent * @return void */ - protected function _mail($to, $subject, $message, $headers, $params = null) - { - //@codingStandardsIgnoreStart + protected function _mail( + string $to, + string $subject, + string $message, + string $headers = '', + string $params = '', + ): void { + // phpcs:disable if (!@mail($to, $subject, $message, $headers, $params)) { $error = error_get_last(); - $msg = 'Could not send email: ' . (isset($error['message']) ? $error['message'] : 'unknown'); - throw new SocketException($msg); + $msg = 'Could not send email: ' . ($error['message'] ?? 'unknown'); + throw new CakeException($msg); } - //@codingStandardsIgnoreEnd + // phpcs:enable } } diff --git a/src/Mailer/Transport/SmtpTransport.php b/src/Mailer/Transport/SmtpTransport.php index a72ff9cdaf0..cda97fc7cf6 100644 --- a/src/Mailer/Transport/SmtpTransport.php +++ b/src/Mailer/Transport/SmtpTransport.php @@ -1,4 +1,6 @@ */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ 'host' => 'localhost', 'port' => 25, 'timeout' => 30, @@ -39,7 +53,8 @@ class SmtpTransport extends AbstractTransport 'password' => null, 'client' => null, 'tls' => false, - 'keepAlive' => false + 'keepAlive' => false, + 'authType' => null, ]; /** @@ -47,21 +62,28 @@ class SmtpTransport extends AbstractTransport * * @var \Cake\Network\Socket */ - protected $_socket; + protected Socket $_socket; /** * Content of email to return * - * @var array + * @var array */ - protected $_content = []; + protected array $_content = []; /** * The response of the last sent SMTP command. * * @var array */ - protected $_lastResponse = []; + protected array $_lastResponse = []; + + /** + * Authentication type. + * + * @var string|null + */ + protected ?string $authType = null; /** * Destructor @@ -73,11 +95,37 @@ public function __destruct() { try { $this->disconnect(); - } catch (Exception $e) { + } catch (Exception) { // avoid fatal error on script termination } } + /** + * Returns only serializable properties + * + * @return array + */ + public function __serialize(): array + { + return array_diff_key(get_object_vars($this), ['_socket' => null]); + } + + /** + * Unserialize handler. + * + * Ensure that the socket property isn't reinitialized in a broken state. + * + * @return void + */ + public function __unserialize(array $data): void + { + unset($data['_socket']); + + foreach ($data as $key => $val) { + $this->{$key} = $val; + } + } + /** * Connect to the SMTP server. * @@ -86,7 +134,7 @@ public function __destruct() * * @return void */ - public function connect() + public function connect(): void { if (!$this->connected()) { $this->_connect(); @@ -99,9 +147,9 @@ public function connect() * * @return bool */ - public function connected() + public function connected(): bool { - return $this->_socket !== null && $this->_socket->connected; + return isset($this->_socket) && $this->_socket->isConnected(); } /** @@ -112,11 +160,13 @@ public function connected() * * @return void */ - public function disconnect() + public function disconnect(): void { - if ($this->connected()) { - $this->_disconnect(); + if (!$this->connected()) { + return; } + + $this->_disconnect(); } /** @@ -144,7 +194,7 @@ public function disconnect() * * @return array */ - public function getLastResponse() + public function getLastResponse(): array { return $this->_lastResponse; } @@ -152,12 +202,14 @@ public function getLastResponse() /** * Send mail * - * @param \Cake\Mailer\Email $email Email instance - * @return array + * @param \Cake\Mailer\Message $message Message instance + * @return array{headers: string, message: string, ...} Contains 'headers' and 'message' keys. Additional keys allowed. * @throws \Cake\Network\Exception\SocketException */ - public function send(Email $email) + public function send(Message $message): array { + $this->checkRecipient($message); + if (!$this->connected()) { $this->_connect(); $this->_auth(); @@ -165,43 +217,91 @@ public function send(Email $email) $this->_smtpSend('RSET'); } - $this->_sendRcpt($email); - $this->_sendData($email); + $this->_sendRcpt($message); + $this->_sendData($message); if (!$this->_config['keepAlive']) { $this->_disconnect(); } + /** @var array{headers: string, message: string} */ return $this->_content; } /** * Parses and stores the response lines in `'code' => 'message'` format. * - * @param array $responseLines Response lines to parse. + * @param array $responseLines Response lines to parse. * @return void */ - protected function _bufferResponseLines(array $responseLines) + protected function _bufferResponseLines(array $responseLines): void { $response = []; foreach ($responseLines as $responseLine) { if (preg_match('/^(\d{3})(?:[ -]+(.*))?$/', $responseLine, $match)) { $response[] = [ 'code' => $match[1], - 'message' => isset($match[2]) ? $match[2] : null + 'message' => $match[2] ?? null, ]; } } $this->_lastResponse = array_merge($this->_lastResponse, $response); } + /** + * Parses the last response line and extract the preferred authentication type. + * + * @return void + */ + protected function _parseAuthType(): void + { + $authType = $this->getConfig('authType'); + if ($authType !== null) { + if (!in_array($authType, self::SUPPORTED_AUTH_TYPES)) { + throw new CakeException( + 'Unsupported auth type. Available types are: ' . implode(', ', self::SUPPORTED_AUTH_TYPES), + ); + } + + $this->authType = $authType; + + return; + } + + if (!isset($this->_config['username'], $this->_config['password'])) { + return; + } + + $auth = ''; + foreach ($this->_lastResponse as $line) { + if ($line['message'] === '' || str_starts_with($line['message'], 'AUTH ')) { + $auth = $line['message']; + break; + } + } + + if ($auth === '') { + return; + } + + foreach (self::SUPPORTED_AUTH_TYPES as $type) { + if (str_contains($auth, $type)) { + $this->authType = $type; + + return; + } + } + + throw new CakeException('Unsupported auth type: ' . substr($auth, 5)); + } + /** * Connect to SMTP Server * * @return void * @throws \Cake\Network\Exception\SocketException */ - protected function _connect() + protected function _connect(): void { $this->_generateSocket(); if (!$this->_socket->connect()) { @@ -211,12 +311,17 @@ protected function _connect() $config = $this->_config; + $host = 'localhost'; if (isset($config['client'])) { + if (empty($config['client'])) { + throw new SocketException('Cannot use an empty client name.'); + } $host = $config['client']; - } elseif ($httpHost = env('HTTP_HOST')) { - list($host) = explode(':', $httpHost); } else { - $host = 'localhost'; + $httpHost = env('HTTP_HOST'); + if (is_string($httpHost) && strlen($httpHost)) { + [$host] = explode(':', $httpHost); + } } try { @@ -228,14 +333,20 @@ protected function _connect() } } catch (SocketException $e) { if ($config['tls']) { - throw new SocketException('SMTP server did not accept the connection or trying to connect to non TLS SMTP server using TLS.'); + throw new SocketException( + 'SMTP server did not accept the connection or trying to connect to non TLS SMTP server using TLS.', + null, + $e, + ); } try { $this->_smtpSend("HELO {$host}", '250'); } catch (SocketException $e2) { - throw new SocketException('SMTP server did not accept the connection.'); + throw new SocketException('SMTP server did not accept the connection.', null, $e2); } } + + $this->_parseAuthType(); } /** @@ -244,62 +355,139 @@ protected function _connect() * @return void * @throws \Cake\Network\Exception\SocketException */ - protected function _auth() + protected function _auth(): void { - if (isset($this->_config['username'], $this->_config['password'])) { - $replyCode = (string)$this->_smtpSend('AUTH LOGIN', '334|500|502|504'); - if ($replyCode === '334') { - try { - $this->_smtpSend(base64_encode($this->_config['username']), '334'); - } catch (SocketException $e) { - throw new SocketException('SMTP server did not accept the username.'); - } - try { - $this->_smtpSend(base64_encode($this->_config['password']), '235'); - } catch (SocketException $e) { - throw new SocketException('SMTP server did not accept the password.'); + if (!isset($this->_config['username'], $this->_config['password'])) { + return; + } + + $username = $this->_config['username']; + $password = $this->_config['password']; + + switch ($this->authType) { + case self::AUTH_PLAIN: + $this->_authPlain($username, $password); + break; + + case self::AUTH_LOGIN: + $this->_authLogin($username, $password); + break; + + case self::AUTH_XOAUTH2: + $this->_authXoauth2($username, $password); + break; + + default: + $replyCode = $this->_authPlain($username, $password); + if ($replyCode === '235') { + break; } - } elseif ($replyCode === '504') { - throw new SocketException('SMTP authentication method not allowed, check if SMTP server requires TLS.'); - } else { - throw new SocketException('AUTH command not recognized or not implemented, SMTP server may not require authentication.'); + + $this->_authLogin($username, $password); + } + } + + /** + * Authenticate using AUTH PLAIN mechanism. + * + * @param string $username Username. + * @param string $password Password. + * @return string|null Response code for the command. + */ + protected function _authPlain(string $username, #[SensitiveParameter] string $password): ?string + { + return $this->_smtpSend( + sprintf( + 'AUTH PLAIN %s', + base64_encode(chr(0) . $username . chr(0) . $password), + ), + '235|504|534|535', + ); + } + + /** + * Authenticate using AUTH LOGIN mechanism. + * + * @param string $username Username. + * @param string $password Password. + * @return void + */ + protected function _authLogin(string $username, #[SensitiveParameter] string $password): void + { + $replyCode = $this->_smtpSend('AUTH LOGIN', '334|500|502|504'); + if ($replyCode === '334') { + try { + $this->_smtpSend(base64_encode($username), '334'); + } catch (SocketException $e) { + throw new SocketException('SMTP server did not accept the username.', null, $e); } + try { + $this->_smtpSend(base64_encode($password), '235'); + } catch (SocketException $e) { + throw new SocketException('SMTP server did not accept the password.', null, $e); + } + } elseif ($replyCode === '504') { + throw new SocketException('SMTP authentication method not allowed, check if SMTP server requires TLS.'); + } else { + throw new SocketException( + 'AUTH command not recognized or not implemented, SMTP server may not require authentication.', + ); } } + /** + * Authenticate using AUTH XOAUTH2 mechanism. + * + * @param string $username Username. + * @param string $token Token. + * @return void + * @see https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth#smtp-protocol-exchange + * @see https://developers.google.com/gmail/imap/xoauth2-protocol#smtp_protocol_exchange + */ + protected function _authXoauth2(string $username, #[SensitiveParameter] string $token): void + { + $authString = base64_encode(sprintf( + "user=%s\1auth=Bearer %s\1\1", + $username, + $token, + )); + + $this->_smtpSend('AUTH XOAUTH2 ' . $authString, '235'); + } + /** * Prepares the `MAIL FROM` SMTP command. * - * @param string $email The email address to send with the command. + * @param string $message The email address to send with the command. * @return string */ - protected function _prepareFromCmd($email) + protected function _prepareFromCmd(string $message): string { - return 'MAIL FROM:<' . $email . '>'; + return 'MAIL FROM:<' . $message . '>'; } /** * Prepares the `RCPT TO` SMTP command. * - * @param string $email The email address to send with the command. + * @param string $message The email address to send with the command. * @return string */ - protected function _prepareRcptCmd($email) + protected function _prepareRcptCmd(string $message): string { - return 'RCPT TO:<' . $email . '>'; + return 'RCPT TO:<' . $message . '>'; } /** * Prepares the `from` email address. * - * @param \Cake\Mailer\Email $email Email instance + * @param \Cake\Mailer\Message $message Message instance * @return array */ - protected function _prepareFromAddress($email) + protected function _prepareFromAddress(Message $message): array { - $from = $email->getReturnPath(); - if (empty($from)) { - $from = $email->getFrom(); + $from = $message->getReturnPath(); + if (!$from) { + return $message->getFrom(); } return $from; @@ -308,41 +496,30 @@ protected function _prepareFromAddress($email) /** * Prepares the recipient email addresses. * - * @param \Cake\Mailer\Email $email Email instance + * @param \Cake\Mailer\Message $message Message instance * @return array */ - protected function _prepareRecipientAddresses($email) + protected function _prepareRecipientAddresses(Message $message): array { - $to = $email->getTo(); - $cc = $email->getCc(); - $bcc = $email->getBcc(); + $to = $message->getTo(); + $cc = $message->getCc(); + $bcc = $message->getBcc(); return array_merge(array_keys($to), array_keys($cc), array_keys($bcc)); } - /** - * Prepares the message headers. - * - * @param \Cake\Mailer\Email $email Email instance - * @return array - */ - protected function _prepareMessageHeaders($email) - { - return $email->getHeaders(['from', 'sender', 'replyTo', 'readReceipt', 'to', 'cc', 'subject', 'returnPath']); - } - /** * Prepares the message body. * - * @param \Cake\Mailer\Email $email Email instance + * @param \Cake\Mailer\Message $message Message instance * @return string */ - protected function _prepareMessage($email) + protected function _prepareMessage(Message $message): string { - $lines = $email->message(); + $lines = $message->getBody(); $messages = []; foreach ($lines as $line) { - if ((!empty($line)) && ($line[0] === '.')) { + if (str_starts_with($line, '.')) { $messages[] = '.' . $line; } else { $messages[] = $line; @@ -355,17 +532,17 @@ protected function _prepareMessage($email) /** * Send emails * - * @return void - * @param \Cake\Mailer\Email $email Cake Email + * @param \Cake\Mailer\Message $message Message instance * @throws \Cake\Network\Exception\SocketException + * @return void */ - protected function _sendRcpt($email) + protected function _sendRcpt(Message $message): void { - $from = $this->_prepareFromAddress($email); - $this->_smtpSend($this->_prepareFromCmd(key($from))); + $from = $this->_prepareFromAddress($message); + $this->_smtpSend($this->_prepareFromCmd((string)key($from))); - $emails = $this->_prepareRecipientAddresses($email); - foreach ($emails as $mail) { + $messages = $this->_prepareRecipientAddresses($message); + foreach ($messages as $mail) { $this->_smtpSend($this->_prepareRcptCmd($mail)); } } @@ -373,16 +550,31 @@ protected function _sendRcpt($email) /** * Send Data * - * @param \Cake\Mailer\Email $email Email instance + * @param \Cake\Mailer\Message $message Message instance * @return void * @throws \Cake\Network\Exception\SocketException */ - protected function _sendData($email) + protected function _sendData(Message $message): void { $this->_smtpSend('DATA', '354'); - $headers = $this->_headersToString($this->_prepareMessageHeaders($email)); - $message = $this->_prepareMessage($email); + $headers = $message->getHeadersString( + [ + 'from', + 'sender', + 'replyTo', + 'readReceipt', + 'to', + 'cc', + 'subject', + 'returnPath', + ], + "\r\n", + function (string $val): string { + return str_replace("\r\n", '', $val); + }, + ); + $message = $this->_prepareMessage($message); $this->_smtpSend($headers . "\r\n\r\n" . $message . "\r\n\r\n\r\n."); $this->_content = ['headers' => $headers, 'message' => $message]; @@ -394,10 +586,11 @@ protected function _sendData($email) * @return void * @throws \Cake\Network\Exception\SocketException */ - protected function _disconnect() + protected function _disconnect(): void { $this->_smtpSend('QUIT', false); $this->_socket->disconnect(); + $this->authType = null; } /** @@ -406,7 +599,7 @@ protected function _disconnect() * @return void * @throws \Cake\Network\Exception\SocketException */ - protected function _generateSocket() + protected function _generateSocket(): void { $this->_socket = new Socket($this->_config); } @@ -415,11 +608,11 @@ protected function _generateSocket() * Protected method for sending data to SMTP connection * * @param string|null $data Data to be sent to SMTP server - * @param string|bool $checkCode Code to check for in server response, false to skip + * @param string|false $checkCode Code to check for in server response, false to skip * @return string|null The matched code, or null if nothing matched * @throws \Cake\Network\Exception\SocketException */ - protected function _smtpSend($data, $checkCode = '250') + protected function _smtpSend(?string $data, string|false $checkCode = '250'): ?string { $this->_lastResponse = []; @@ -432,15 +625,17 @@ protected function _smtpSend($data, $checkCode = '250') while ($checkCode !== false) { $response = ''; $startTime = time(); - while (substr($response, -2) !== "\r\n" && ((time() - $startTime) < $timeout)) { + while (!str_ends_with($response, "\r\n") && (time() - $startTime < $timeout)) { $bytes = $this->_socket->read(); - if ($bytes === false || $bytes === null) { + if ($bytes === null) { break; } $response .= $bytes; } - if (substr($response, -2) !== "\r\n") { - throw new SocketException('SMTP timeout.'); + // Catch empty or malformed responses. + if (!str_ends_with($response, "\r\n")) { + // Use response message or assume operation timed out. + throw new SocketException($response ?: 'SMTP timeout.'); } $responseLines = explode("\r\n", rtrim($response, "\r\n")); $response = end($responseLines); @@ -456,5 +651,7 @@ protected function _smtpSend($data, $checkCode = '250') } throw new SocketException(sprintf('SMTP Error: %s', $response)); } + + return null; } } diff --git a/src/Mailer/TransportFactory.php b/src/Mailer/TransportFactory.php new file mode 100644 index 00000000000..fb7073514ff --- /dev/null +++ b/src/Mailer/TransportFactory.php @@ -0,0 +1,110 @@ + + */ + protected static array $_dsnClassMap = [ + 'debug' => Transport\DebugTransport::class, + 'mail' => Transport\MailTransport::class, + 'smtp' => Transport\SmtpTransport::class, + ]; + + /** + * Returns the Transport Registry used for creating and using transport instances. + * + * @return \Cake\Mailer\TransportRegistry + */ + public static function getRegistry(): TransportRegistry + { + return static::$_registry ??= new TransportRegistry(); + } + + /** + * Sets the Transport Registry instance used for creating and using transport instances. + * + * Also allows for injecting of a new registry instance. + * + * @param \Cake\Mailer\TransportRegistry $registry Injectable registry object. + * @return void + */ + public static function setRegistry(TransportRegistry $registry): void + { + static::$_registry = $registry; + } + + /** + * Finds and builds the instance of the required transport class. + * + * @param string $name Name of the config array that needs a transport instance built + * @return \Cake\Mailer\AbstractTransport + * @throws \InvalidArgumentException When a transport cannot be created. + */ + protected static function _buildTransport(string $name): AbstractTransport + { + if (!isset(static::$_config[$name])) { + throw new InvalidArgumentException( + sprintf('The `%s` transport configuration does not exist', $name), + ); + } + + if (is_array(static::$_config[$name]) && empty(static::$_config[$name]['className'])) { + throw new InvalidArgumentException( + sprintf('Transport config `%s` is invalid, the required `className` option is missing', $name), + ); + } + + return static::getRegistry()->load($name, static::$_config[$name]); + } + + /** + * Get transport instance. + * + * @param string $name Config name. + * @return \Cake\Mailer\AbstractTransport + */ + public static function get(string $name): AbstractTransport + { + $registry = static::getRegistry(); + + if ($registry->has($name)) { + return $registry->get($name); + } + + return static::_buildTransport($name); + } +} diff --git a/src/Mailer/TransportRegistry.php b/src/Mailer/TransportRegistry.php new file mode 100644 index 00000000000..c251304f1ed --- /dev/null +++ b/src/Mailer/TransportRegistry.php @@ -0,0 +1,90 @@ + + */ +class TransportRegistry extends ObjectRegistry +{ + /** + * Resolve a mailer transport classname. + * + * Part of the template method for Cake\Core\ObjectRegistry::load() + * + * @param string $class Partial classname to resolve or transport instance. + * @return class-string<\Cake\Mailer\AbstractTransport>|null Either the correct classname or null. + */ + protected function _resolveClassName(string $class): ?string + { + /** @var class-string<\Cake\Mailer\AbstractTransport>|null */ + return App::className($class, 'Mailer/Transport', 'Transport'); + } + + /** + * Throws an exception when a transport is missing. + * + * Part of the template method for Cake\Core\ObjectRegistry::load() + * + * @param string $class The classname that is missing. + * @param string|null $plugin The plugin the transport is missing in. + * @return void + * @throws \BadMethodCallException + */ + protected function _throwMissingClassError(string $class, ?string $plugin): void + { + throw new BadMethodCallException(sprintf('Mailer transport `%s` is not available.', $class)); + } + + /** + * Create the mailer transport instance. + * + * Part of the template method for Cake\Core\ObjectRegistry::load() + * + * @param \Cake\Mailer\AbstractTransport|class-string<\Cake\Mailer\AbstractTransport> $class The classname or object to make. + * @param string $alias The alias of the object. + * @param array $config An array of settings to use for the transport. + * @return \Cake\Mailer\AbstractTransport The constructed transport class. + */ + protected function _create(object|string $class, string $alias, array $config): AbstractTransport + { + if (is_object($class)) { + return $class; + } + + return new $class($config); + } + + /** + * Remove a single adapter from the registry. + * + * @param string $name The adapter name. + * @return $this + */ + public function unload(string $name) + { + unset($this->_loaded[$name]); + + return $this; + } +} diff --git a/src/Network/CorsBuilder.php b/src/Network/CorsBuilder.php deleted file mode 100644 index 35986704a92..00000000000 --- a/src/Network/CorsBuilder.php +++ /dev/null @@ -1,209 +0,0 @@ -_origin = $origin; - $this->_isSsl = $isSsl; - $this->_response = $response; - } - - /** - * Apply the queued headers to the response. - * - * If the builder has no Origin, or if there are no allowed domains, - * or if the allowed domains do not match the Origin header no headers will be applied. - * - * @return \Cake\Http\Response - */ - public function build() - { - if (empty($this->_origin)) { - return $this->_response; - } - if (isset($this->_headers['Access-Control-Allow-Origin'])) { - $this->_response->header($this->_headers); - } - - return $this->_response; - } - - /** - * Set the list of allowed domains. - * - * Accepts a string or an array of domains that have CORS enabled. - * You can use `*.example.com` wildcards to accept subdomains, or `*` to allow all domains - * - * @param string|array $domain The allowed domains - * @return $this - */ - public function allowOrigin($domain) - { - $allowed = $this->_normalizeDomains((array)$domain); - foreach ($allowed as $domain) { - if (!preg_match($domain['preg'], $this->_origin)) { - continue; - } - $value = $domain['original'] === '*' ? '*' : $this->_origin; - $this->_headers['Access-Control-Allow-Origin'] = $value; - break; - } - - return $this; - } - - /** - * Normalize the origin to regular expressions and put in an array format - * - * @param array $domains Domain names to normalize. - * @return array - */ - protected function _normalizeDomains($domains) - { - $result = []; - foreach ($domains as $domain) { - if ($domain === '*') { - $result[] = ['preg' => '@.@', 'original' => '*']; - continue; - } - - $original = $preg = $domain; - if (strpos($domain, '://') === false) { - $preg = ($this->_isSsl ? 'https://' : 'http://') . $domain; - } - $preg = '@^' . str_replace('\*', '.*', preg_quote($preg, '@')) . '$@'; - $result[] = compact('original', 'preg'); - } - - return $result; - } - - /** - * Set the list of allowed HTTP Methods. - * - * @param array $methods The allowed HTTP methods - * @return $this - */ - public function allowMethods(array $methods) - { - $this->_headers['Access-Control-Allow-Methods'] = implode(', ', $methods); - - return $this; - } - - /** - * Enable cookies to be sent in CORS requests. - * - * @return $this - */ - public function allowCredentials() - { - $this->_headers['Access-Control-Allow-Credentials'] = 'true'; - - return $this; - } - - /** - * Whitelist headers that can be sent in CORS requests. - * - * @param array $headers The list of headers to accept in CORS requests. - * @return $this - */ - public function allowHeaders(array $headers) - { - $this->_headers['Access-Control-Allow-Headers'] = implode(', ', $headers); - - return $this; - } - - /** - * Define the headers a client library/browser can expose to scripting - * - * @param array $headers The list of headers to expose CORS responses - * @return $this - */ - public function exposeHeaders(array $headers) - { - $this->_headers['Access-Control-Expose-Headers'] = implode(', ', $headers); - - return $this; - } - - /** - * Define the max-age preflight OPTIONS requests are valid for. - * - * @param int $age The max-age for OPTIONS requests in seconds - * @return $this - */ - public function maxAge($age) - { - $this->_headers['Access-Control-Max-Age'] = $age; - - return $this; - } -} diff --git a/src/Network/Email/AbstractTransport.php b/src/Network/Email/AbstractTransport.php deleted file mode 100644 index 662667df5ea..00000000000 --- a/src/Network/Email/AbstractTransport.php +++ /dev/null @@ -1,3 +0,0 @@ -=')) { - unset($sessionConfig['ini']['session.save_handler']); - } - - if (!isset($sessionConfig['ini']['session.cookie_httponly']) && ini_get('session.cookie_httponly') != 1) { - $sessionConfig['ini']['session.cookie_httponly'] = 1; - } - - return new static($sessionConfig); - } - - /** - * Get one of the prebaked default session configurations. - * - * @param string $name Config name. - * @return bool|array - */ - protected static function _defaultConfig($name) - { - $defaults = [ - 'php' => [ - 'cookie' => 'CAKEPHP', - 'ini' => [ - 'session.use_trans_sid' => 0, - ] - ], - 'cake' => [ - 'cookie' => 'CAKEPHP', - 'ini' => [ - 'session.use_trans_sid' => 0, - 'session.serialize_handler' => 'php', - 'session.use_cookies' => 1, - 'session.save_path' => TMP . 'sessions', - 'session.save_handler' => 'files' - ] - ], - 'cache' => [ - 'cookie' => 'CAKEPHP', - 'ini' => [ - 'session.use_trans_sid' => 0, - 'session.use_cookies' => 1, - 'session.save_handler' => 'user', - ], - 'handler' => [ - 'engine' => 'CacheSession', - 'config' => 'default' - ] - ], - 'database' => [ - 'cookie' => 'CAKEPHP', - 'ini' => [ - 'session.use_trans_sid' => 0, - 'session.use_cookies' => 1, - 'session.save_handler' => 'user', - 'session.serialize_handler' => 'php', - ], - 'handler' => [ - 'engine' => 'DatabaseSession' - ] - ] - ]; - - if (isset($defaults[$name])) { - return $defaults[$name]; - } - - return false; - } - - /** - * Constructor. - * - * ### Configuration: - * - * - timeout: The time in minutes the session should be valid for. - * - cookiePath: The url path for which session cookie is set. Maps to the - * `session.cookie_path` php.ini config. Defaults to base path of app. - * - ini: A list of php.ini directives to change before the session start. - * - handler: An array containing at least the `class` key. To be used as the session - * engine for persisting data. The rest of the keys in the array will be passed as - * the configuration array for the engine. You can set the `class` key to an already - * instantiated session handler object. - * - * @param array $config The Configuration to apply to this session object - */ - public function __construct(array $config = []) - { - if (isset($config['timeout'])) { - $config['ini']['session.gc_maxlifetime'] = 60 * $config['timeout']; - } - - if (!empty($config['cookie'])) { - $config['ini']['session.name'] = $config['cookie']; - } - - if (!isset($config['ini']['session.cookie_path'])) { - $cookiePath = empty($config['cookiePath']) ? '/' : $config['cookiePath']; - $config['ini']['session.cookie_path'] = $cookiePath; - } - - if (!empty($config['ini']) && is_array($config['ini'])) { - $this->options($config['ini']); - } - - if (!empty($config['handler']['engine'])) { - $class = $config['handler']['engine']; - unset($config['handler']['engine']); - session_set_save_handler($this->engine($class, $config['handler']), false); - } - - $this->_lifetime = ini_get('session.gc_maxlifetime'); - $this->_isCLI = (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg'); - session_register_shutdown(); - } - - /** - * Sets the session handler instance to use for this session. - * If a string is passed for the first argument, it will be treated as the - * class name and the second argument will be passed as the first argument - * in the constructor. - * - * If an instance of a SessionHandlerInterface is provided as the first argument, - * the handler will be set to it. - * - * If no arguments are passed it will return the currently configured handler instance - * or null if none exists. - * - * @param string|\SessionHandlerInterface|null $class The session handler to use - * @param array $options the options to pass to the SessionHandler constructor - * @return \SessionHandlerInterface|null - * @throws \InvalidArgumentException - */ - public function engine($class = null, array $options = []) - { - if ($class instanceof SessionHandlerInterface) { - return $this->_engine = $class; - } - - if ($class === null) { - return $this->_engine; - } - - $className = App::className($class, 'Network/Session'); - if (!$className) { - throw new InvalidArgumentException( - sprintf('The class "%s" does not exist and cannot be used as a session engine', $class) - ); - } - - $handler = new $className($options); - if (!($handler instanceof SessionHandlerInterface)) { - throw new InvalidArgumentException( - 'The chosen SessionHandler does not implement SessionHandlerInterface, it cannot be used as an engine.' - ); - } - - return $this->_engine = $handler; - } - - /** - * Calls ini_set for each of the keys in `$options` and set them - * to the respective value in the passed array. - * - * ### Example: - * - * ``` - * $session->options(['session.use_cookies' => 1]); - * ``` - * - * @param array $options Ini options to set. - * @return void - * @throws \RuntimeException if any directive could not be set - */ - public function options(array $options) - { - if (session_status() === \PHP_SESSION_ACTIVE || headers_sent()) { - return; - } - - foreach ($options as $setting => $value) { - if (ini_set($setting, (string)$value) === false) { - throw new RuntimeException( - sprintf('Unable to configure the session, setting %s failed.', $setting) - ); - } - } - } - - /** - * Starts the Session. - * - * @return bool True if session was started - * @throws \RuntimeException if the session was already started - */ - public function start() - { - if ($this->_started) { - return true; - } - - if ($this->_isCLI) { - $_SESSION = []; - $this->id('cli'); - - return $this->_started = true; - } - - if (session_status() === \PHP_SESSION_ACTIVE) { - throw new RuntimeException('Session was already started'); - } - - if (ini_get('session.use_cookies') && headers_sent($file, $line)) { - return false; - } - - if (!session_start()) { - throw new RuntimeException('Could not start the session'); - } - - $this->_started = true; - - if ($this->_timedOut()) { - $this->destroy(); - - return $this->start(); - } - - return $this->_started; - } - - /** - * Determine if Session has already been started. - * - * @return bool True if session has been started. - */ - public function started() - { - return $this->_started || session_status() === \PHP_SESSION_ACTIVE; - } - - /** - * Returns true if given variable name is set in session. - * - * @param string|null $name Variable name to check for - * @return bool True if variable is there - */ - public function check($name = null) - { - if ($this->_hasSession() && !$this->started()) { - $this->start(); - } - - if (!isset($_SESSION)) { - return false; - } - - return Hash::get($_SESSION, $name) !== null; - } - - /** - * Returns given session variable, or all of them, if no parameters given. - * - * @param string|null $name The name of the session variable (or a path as sent to Hash.extract) - * @return string|array|null The value of the session variable, null if session not available, - * session not started, or provided name not found in the session. - */ - public function read($name = null) - { - if ($this->_hasSession() && !$this->started()) { - $this->start(); - } - - if (!isset($_SESSION)) { - return null; - } - - if ($name === null) { - return isset($_SESSION) ? $_SESSION : []; - } - - return Hash::get($_SESSION, $name); - } - - /** - * Reads and deletes a variable from session. - * - * @param string $name The key to read and remove (or a path as sent to Hash.extract). - * @return mixed The value of the session variable, null if session not available, - * session not started, or provided name not found in the session. - */ - public function consume($name) - { - if (empty($name)) { - return null; - } - $value = $this->read($name); - if ($value !== null) { - $this->_overwrite($_SESSION, Hash::remove($_SESSION, $name)); - } - - return $value; - } - - /** - * Writes value to given session variable name. - * - * @param string|array $name Name of variable - * @param mixed $value Value to write - * @return void - */ - public function write($name, $value = null) - { - if (!$this->started()) { - $this->start(); - } - - $write = $name; - if (!is_array($name)) { - $write = [$name => $value]; - } - - $data = isset($_SESSION) ? $_SESSION : []; - foreach ($write as $key => $val) { - $data = Hash::insert($data, $key, $val); - } - - $this->_overwrite($_SESSION, $data); - } - - /** - * Returns the session id. - * Calling this method will not auto start the session. You might have to manually - * assert a started session. - * - * Passing an id into it, you can also replace the session id if the session - * has not already been started. - * Note that depending on the session handler, not all characters are allowed - * within the session id. For example, the file session handler only allows - * characters in the range a-z A-Z 0-9 , (comma) and - (minus). - * - * @param string|null $id Id to replace the current session id - * @return string Session id - */ - public function id($id = null) - { - if ($id !== null && !headers_sent()) { - session_id($id); - } - - return session_id(); - } - - /** - * Removes a variable from session. - * - * @param string $name Session variable to remove - * @return void - */ - public function delete($name) - { - if ($this->check($name)) { - $this->_overwrite($_SESSION, Hash::remove($_SESSION, $name)); - } - } - - /** - * Used to write new data to _SESSION, since PHP doesn't like us setting the _SESSION var itself. - * - * @param array $old Set of old variables => values - * @param array $new New set of variable => value - * @return void - */ - protected function _overwrite(&$old, $new) - { - if (!empty($old)) { - foreach ($old as $key => $var) { - if (!isset($new[$key])) { - unset($old[$key]); - } - } - } - foreach ($new as $key => $var) { - $old[$key] = $var; - } - } - - /** - * Helper method to destroy invalid sessions. - * - * @return void - */ - public function destroy() - { - if ($this->_hasSession() && !$this->started()) { - $this->start(); - } - - if (!$this->_isCLI && session_status() === PHP_SESSION_ACTIVE) { - session_destroy(); - } - - $_SESSION = []; - $this->_started = false; - } - - /** - * Clears the session. - * - * Optionally it also clears the session id and renews the session. - * - * @param bool $renew If session should be renewed, as well. Defaults to false. - * @return void - */ - public function clear($renew = false) - { - $_SESSION = []; - if ($renew) { - $this->renew(); - } - } - - /** - * Returns whether a session exists - * - * @return bool - */ - protected function _hasSession() - { - return !ini_get('session.use_cookies') - || isset($_COOKIE[session_name()]) - || $this->_isCLI - || (ini_get('session.use_trans_sid') && isset($_GET[session_name()])); - } - - /** - * Restarts this session. - * - * @return void - */ - public function renew() - { - if (!$this->_hasSession() || $this->_isCLI) { - return; - } - - $this->start(); - $params = session_get_cookie_params(); - setcookie( - session_name(), - '', - time() - 42000, - $params['path'], - $params['domain'], - $params['secure'], - $params['httponly'] - ); - - if (session_id()) { - session_regenerate_id(true); - } - } - - /** - * Returns true if the session is no longer valid because the last time it was - * accessed was after the configured timeout. - * - * @return bool - */ - protected function _timedOut() - { - $time = $this->read('Config.time'); - $result = false; - - $checkTime = $time !== null && $this->_lifetime > 0; - if ($checkTime && (time() - $time > $this->_lifetime)) { - $result = true; - } - - $this->write('Config.time', time()); - - return $result; - } -} diff --git a/src/Network/Session/CacheSession.php b/src/Network/Session/CacheSession.php deleted file mode 100644 index 3e9cd2f08a3..00000000000 --- a/src/Network/Session/CacheSession.php +++ /dev/null @@ -1,135 +0,0 @@ -_options = $config; - } - - /** - * Method called on open of a database session. - * - * @param string $savePath The path where to store/retrieve the session. - * @param string $name The session name. - * @return bool Success - */ - public function open($savePath, $name) - { - return true; - } - - /** - * Method called on close of a database session. - * - * @return bool Success - */ - public function close() - { - return true; - } - - /** - * Method used to read from a cache session. - * - * @param string|int $id ID that uniquely identifies session in cache. - * @return string Session data or empty string if it does not exist. - */ - public function read($id) - { - $value = Cache::read($id, $this->_options['config']); - - if (empty($value)) { - return ''; - } - - return $value; - } - - /** - * Helper function called on write for cache sessions. - * - * @param string|int $id ID that uniquely identifies session in cache. - * @param mixed $data The data to be saved. - * @return bool True for successful write, false otherwise. - */ - public function write($id, $data) - { - if (!$id) { - return false; - } - - return (bool)Cache::write($id, $data, $this->_options['config']); - } - - /** - * Method called on the destruction of a cache session. - * - * @param string|int $id ID that uniquely identifies session in cache. - * @return bool Always true. - */ - public function destroy($id) - { - Cache::delete($id, $this->_options['config']); - - return true; - } - - /** - * Helper function called on gc for cache sessions. - * - * @param int $maxlifetime Sessions that have not updated for the last maxlifetime seconds will be removed. - * @return bool Always true. - */ - public function gc($maxlifetime) - { - Cache::gc($this->_options['config'], time() - $maxlifetime); - - return true; - } -} diff --git a/src/Network/Session/DatabaseSession.php b/src/Network/Session/DatabaseSession.php deleted file mode 100644 index 5ebbd7fa17f..00000000000 --- a/src/Network/Session/DatabaseSession.php +++ /dev/null @@ -1,182 +0,0 @@ -exists('Sessions') ? [] : ['table' => 'sessions']; - $this->_table = $tableLocator->get('Sessions', $config); - } else { - $this->_table = $tableLocator->get($config['model']); - } - - $this->_timeout = ini_get('session.gc_maxlifetime'); - } - - /** - * Set the timeout value for sessions. - * - * Primarily used in testing. - * - * @param int $timeout The timeout duration. - * @return $this - */ - public function setTimeout($timeout) - { - $this->_timeout = $timeout; - - return $this; - } - - /** - * Method called on open of a database session. - * - * @param string $savePath The path where to store/retrieve the session. - * @param string $name The session name. - * @return bool Success - */ - public function open($savePath, $name) - { - return true; - } - - /** - * Method called on close of a database session. - * - * @return bool Success - */ - public function close() - { - return true; - } - - /** - * Method used to read from a database session. - * - * @param string|int $id ID that uniquely identifies session in database. - * @return string Session data or empty string if it does not exist. - */ - public function read($id) - { - $result = $this->_table - ->find('all') - ->select(['data']) - ->where([$this->_table->getPrimaryKey() => $id]) - ->enableHydration(false) - ->first(); - - if (empty($result)) { - return ''; - } - - if (is_string($result['data'])) { - return $result['data']; - } - - $session = stream_get_contents($result['data']); - - if ($session === false) { - return ''; - } - - return $session; - } - - /** - * Helper function called on write for database sessions. - * - * @param string|int $id ID that uniquely identifies session in database. - * @param mixed $data The data to be saved. - * @return bool True for successful write, false otherwise. - */ - public function write($id, $data) - { - if (!$id) { - return false; - } - $expires = time() + $this->_timeout; - $record = compact('data', 'expires'); - $record[$this->_table->getPrimaryKey()] = $id; - $result = $this->_table->save(new Entity($record)); - - return (bool)$result; - } - - /** - * Method called on the destruction of a database session. - * - * @param string|int $id ID that uniquely identifies session in database. - * @return bool True for successful delete, false otherwise. - */ - public function destroy($id) - { - $this->_table->delete(new Entity( - [$this->_table->getPrimaryKey() => $id], - ['markNew' => false] - )); - - return true; - } - - /** - * Helper function called on gc for database sessions. - * - * @param int $maxlifetime Sessions that have not updated for the last maxlifetime seconds will be removed. - * @return bool True on success, false on failure. - */ - public function gc($maxlifetime) - { - $this->_table->deleteAll(['expires <' => time() - $maxlifetime]); - - return true; - } -} diff --git a/src/Network/Socket.php b/src/Network/Socket.php index e0484087b50..f83a87b848b 100644 --- a/src/Network/Socket.php +++ b/src/Network/Socket.php @@ -1,4 +1,6 @@ */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ 'persistent' => false, 'host' => 'localhost', 'protocol' => 'tcp', 'port' => 80, - 'timeout' => 30 + 'timeout' => 30, ]; /** @@ -57,73 +51,61 @@ class Socket * * @var resource|null */ - public $connection; + protected $connection; /** * This boolean contains the current state of the Socket class * * @var bool + * @deprecated 5.2.9 Use isConnected() instead. */ - public $connected = false; + protected bool $connected = false; /** * This variable contains an array with the last error number (num) and string (str) * - * @var array + * @var array */ - public $lastError = []; + protected array $lastError = []; /** - * True if the socket stream is encrypted after a Cake\Network\Socket::enableCrypto() call + * True if the socket stream is encrypted after a {@link \Cake\Network\Socket::enableCrypto()} call * * @var bool */ - public $encrypted = false; + protected bool $encrypted = false; /** * Contains all the encryption methods available * - * SSLv2 and SSLv3 are deprecated, and should not be used as they - * have several published vulnerablilities. - * - * @var array + * @var array */ - protected $_encryptMethods = [ - // @codingStandardsIgnoreStart - // @deprecated Will be removed in 4.0.0 - 'sslv2_client' => STREAM_CRYPTO_METHOD_SSLv2_CLIENT, - // @deprecated Will be removed in 4.0.0 - 'sslv3_client' => STREAM_CRYPTO_METHOD_SSLv3_CLIENT, + protected array $_encryptMethods = [ 'sslv23_client' => STREAM_CRYPTO_METHOD_SSLv23_CLIENT, 'tls_client' => STREAM_CRYPTO_METHOD_TLS_CLIENT, 'tlsv10_client' => STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT, 'tlsv11_client' => STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT, 'tlsv12_client' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, - // @deprecated Will be removed in 4.0.0 - 'sslv2_server' => STREAM_CRYPTO_METHOD_SSLv2_SERVER, - // @deprecated Will be removed in 4.0.0 - 'sslv3_server' => STREAM_CRYPTO_METHOD_SSLv3_SERVER, 'sslv23_server' => STREAM_CRYPTO_METHOD_SSLv23_SERVER, 'tls_server' => STREAM_CRYPTO_METHOD_TLS_SERVER, 'tlsv10_server' => STREAM_CRYPTO_METHOD_TLSv1_0_SERVER, 'tlsv11_server' => STREAM_CRYPTO_METHOD_TLSv1_1_SERVER, - 'tlsv12_server' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER - // @codingStandardsIgnoreEnd + 'tlsv12_server' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER, ]; /** * Used to capture connection warnings which can happen when there are * SSL errors for example. * - * @var array + * @var array */ - protected $_connectionErrors = []; + protected array $_connectionErrors = []; /** * Constructor. * - * @param array $config Socket configuration, which will be merged with the base configuration - * @see \Cake\Network\Socket::$_baseConfig + * @param array $config Socket configuration, which will be merged with the base configuration + * @see \Cake\Network\Socket::$_defaultConfig */ public function __construct(array $config = []) { @@ -136,15 +118,14 @@ public function __construct(array $config = []) * @return bool Success * @throws \Cake\Network\Exception\SocketException */ - public function connect() + public function connect(): bool { if ($this->connection) { $this->disconnect(); } - $hasProtocol = strpos($this->_config['host'], '://') !== false; - if ($hasProtocol) { - list($this->_config['protocol'], $this->_config['host']) = explode('://', $this->_config['host']); + if (str_contains($this->_config['host'], '://')) { + [$this->_config['protocol'], $this->_config['host']] = explode('://', $this->_config['host']); } $scheme = null; if (!empty($this->_config['protocol'])) { @@ -163,33 +144,92 @@ public function connect() $connectAs |= STREAM_CLIENT_PERSISTENT; } - set_error_handler([$this, '_connectionErrorHandler']); - $this->connection = stream_socket_client( - $scheme . $this->_config['host'] . ':' . $this->_config['port'], + /** + * @phpstan-ignore-next-line + */ + set_error_handler($this->_connectionErrorHandler(...)); + $remoteSocketTarget = $scheme . $this->_config['host']; + $port = (int)$this->_config['port']; + if ($port > 0) { + $remoteSocketTarget .= ':' . $port; + } + + $errNum = 0; + $errStr = ''; + $this->connection = $this->_getStreamSocketClient( + $remoteSocketTarget, $errNum, $errStr, - $this->_config['timeout'], + (int)$this->_config['timeout'], $connectAs, - $context + $context, ); restore_error_handler(); - if (!empty($errNum) || !empty($errStr)) { - $this->setLastError($errNum, $errStr); - throw new SocketException($errStr, $errNum); + if ($this->connection === null && (!$errNum || !$errStr)) { + $this->setLastError($errNum ?? 0, $errStr ?? ''); + throw new SocketException($errStr ?? '', $errNum ?? 0); } - if (!$this->connection && $this->_connectionErrors) { + if ($this->connection === null && $this->_connectionErrors) { $message = implode("\n", $this->_connectionErrors); throw new SocketException($message, E_WARNING); } - $this->connected = is_resource($this->connection); - if ($this->connected) { - stream_set_timeout($this->connection, $this->_config['timeout']); + $connected = is_resource($this->connection); + $this->connected = $connected; + if ($connected) { + assert($this->connection !== null); + + stream_set_timeout($this->connection, (int)$this->_config['timeout']); + } + + return $connected; + } + + /** + * Check the connection status after calling `connect()`. + * + * @return bool + */ + public function isConnected(): bool + { + return is_resource($this->connection); + } + + /** + * Create a stream socket client. Mock utility. + * + * @param string $remoteSocketTarget remote socket + * @param int|null $errNum error number + * @param string|null $errStr error string + * @param int $timeout timeout + * @param int<0, 7> $connectAs flags + * @param resource $context context + * @return resource|null + */ + protected function _getStreamSocketClient( + string $remoteSocketTarget, + ?int &$errNum, + ?string &$errStr, + int $timeout, + int $connectAs, + $context, + ) { + $resource = stream_socket_client( + $remoteSocketTarget, + $errNum, + $errStr, + $timeout, + $connectAs, + $context, + ); + + if (!$resource) { + return null; } - return $this->connected; + return $resource; } /** @@ -198,10 +238,10 @@ public function connect() * @param string $host The host name being connected to. * @return void */ - protected function _setSslContext($host) + protected function _setSslContext(string $host): void { foreach ($this->_config as $key => $value) { - if (substr($key, 0, 4) !== 'ssl_') { + if (!str_starts_with($key, 'ssl_')) { continue; } $contextKey = substr($key, 4); @@ -210,16 +250,13 @@ protected function _setSslContext($host) } unset($this->_config[$key]); } - if (!isset($this->_config['context']['ssl']['SNI_enabled'])) { - $this->_config['context']['ssl']['SNI_enabled'] = true; - } + $this->_config['context']['ssl']['SNI_enabled'] ??= true; + if (empty($this->_config['context']['ssl']['peer_name'])) { $this->_config['context']['ssl']['peer_name'] = $host; } if (empty($this->_config['context']['ssl']['cafile'])) { - $dir = dirname(dirname(__DIR__)); - $this->_config['context']['ssl']['cafile'] = $dir . DIRECTORY_SEPARATOR . - 'config' . DIRECTORY_SEPARATOR . 'cacert.pem'; + $this->_config['context']['ssl']['cafile'] = CaBundle::getBundledCaBundlePath(); } if (!empty($this->_config['context']['ssl']['verify_host'])) { $this->_config['context']['ssl']['CN_match'] = $host; @@ -228,16 +265,16 @@ protected function _setSslContext($host) } /** - * socket_stream_client() does not populate errNum, or $errStr when there are + * stream_socket_client() does not populate errNum, or $errStr when there are * connection errors, as in the case of SSL verification failure. * - * Instead we need to handle those errors manually. + * Instead, we need to handle those errors manually. * * @param int $code Code number. * @param string $message Message. * @return void */ - protected function _connectionErrorHandler($code, $message) + protected function _connectionErrorHandler(int $code, string $message): void { $this->_connectionErrors[] = $message; } @@ -245,9 +282,9 @@ protected function _connectionErrorHandler($code, $message) /** * Get the connection context. * - * @return null|array Null when there is no connection, an array when there is. + * @return array|null Null when there is no connection, an array when there is. */ - public function context() + public function context(): ?array { if (!$this->connection) { return null; @@ -261,13 +298,13 @@ public function context() * * @return string Host name */ - public function host() + public function host(): string { if (Validation::ip($this->_config['host'])) { - return gethostbyaddr($this->_config['host']); + return (string)gethostbyaddr($this->_config['host']); } - return gethostbyaddr($this->address()); + return (string)gethostbyaddr($this->address()); } /** @@ -275,7 +312,7 @@ public function host() * * @return string IP address */ - public function address() + public function address(): string { if (Validation::ip($this->_config['host'])) { return $this->_config['host']; @@ -287,15 +324,15 @@ public function address() /** * Get all IP addresses associated with the current connection. * - * @return array IP addresses + * @return array IP addresses */ - public function addresses() + public function addresses(): array { if (Validation::ip($this->_config['host'])) { return [$this->_config['host']]; } - return gethostbynamel($this->_config['host']); + return gethostbynamel($this->_config['host']) ?: []; } /** @@ -303,23 +340,23 @@ public function addresses() * * @return string|null Last error */ - public function lastError() + public function lastError(): ?string { - if (!empty($this->lastError)) { - return $this->lastError['num'] . ': ' . $this->lastError['str']; + if (!$this->lastError) { + return null; } - return null; + return $this->lastError['num'] . ': ' . $this->lastError['str']; } /** * Set the last error. * - * @param int $errNum Error code + * @param int|null $errNum Error code * @param string $errStr Error string * @return void */ - public function setLastError($errNum, $errStr) + public function setLastError(?int $errNum, string $errStr): void { $this->lastError = ['num' => $errNum, 'str' => $errStr]; } @@ -327,20 +364,19 @@ public function setLastError($errNum, $errStr) /** * Write data to the socket. * - * The bool false return value is deprecated and will be int 0 in the next major. - * Please code respectively to be future proof. - * * @param string $data The data to write to the socket. - * @return int|false Bytes written. + * @return int Bytes written. */ - public function write($data) + public function write(string $data): int { - if (!$this->connected && !$this->connect()) { - return false; + if (!$this->isConnected() && !$this->connect()) { + return 0; } $totalBytes = strlen($data); $written = 0; while ($written < $totalBytes) { + assert($this->connection !== null); + $rv = fwrite($this->connection, substr($data, $written)); if ($rv === false || $rv === 0) { return $written; @@ -352,34 +388,36 @@ public function write($data) } /** - * Read data from the socket. Returns false if no data is available or no connection could be + * Read data from the socket. Returns null if no data is available or no connection could be * established. * - * The bool false return value is deprecated and will be null in the next major. - * Please code respectively to be future proof. - * * @param int $length Optional buffer length to read; defaults to 1024 - * @return mixed Socket data + * @return string|null Socket data */ - public function read($length = 1024) + public function read(int $length = 1024): ?string { - if (!$this->connected && !$this->connect()) { - return false; + if ($length < 1) { + throw new InvalidArgumentException('Length must be greater than `0`'); } - if (!feof($this->connection)) { - $buffer = fread($this->connection, $length); - $info = stream_get_meta_data($this->connection); - if ($info['timed_out']) { - $this->setLastError(E_WARNING, 'Connection timed out'); + if (!$this->isConnected() && !$this->connect()) { + return null; + } - return false; - } + assert($this->connection !== null); + if (feof($this->connection)) { + return null; + } + + $buffer = fread($this->connection, $length); + $info = stream_get_meta_data($this->connection); + if ($info['timed_out']) { + $this->setLastError(E_WARNING, 'Connection timed out'); - return $buffer; + return null; } - return false; + return $buffer === false ? null : $buffer; } /** @@ -387,7 +425,7 @@ public function read($length = 1024) * * @return bool Success */ - public function disconnect() + public function disconnect(): bool { if (!is_resource($this->connection)) { $this->connected = false; @@ -412,26 +450,24 @@ public function __destruct() } /** - * Resets the state of this Socket instance to it's initial state (before Object::__construct got executed) + * Resets the state of this Socket instance to its initial state (before __construct() got executed) * * @param array|null $state Array with key and values to reset - * @return bool True on success + * @return void */ - public function reset($state = null) + public function reset(?array $state = null): void { - if (empty($state)) { - static $initalState = []; - if (empty($initalState)) { - $initalState = get_class_vars(__CLASS__); + if (!$state) { + static $initialState = []; + if (!$initialState) { + $initialState = get_class_vars(self::class); } - $state = $initalState; + $state = $initialState; } foreach ($state as $property => $value) { $this->{$property} = $value; } - - return true; } /** @@ -440,48 +476,53 @@ public function reset($state = null) * @param string $type can be one of 'ssl2', 'ssl3', 'ssl23' or 'tls' * @param string $clientOrServer can be one of 'client', 'server'. Default is 'client' * @param bool $enable enable or disable encryption. Default is true (enable) - * @return bool True on success + * @return void * @throws \InvalidArgumentException When an invalid encryption scheme is chosen. * @throws \Cake\Network\Exception\SocketException When attempting to enable SSL/TLS fails * @see stream_socket_enable_crypto */ - public function enableCrypto($type, $clientOrServer = 'client', $enable = true) + public function enableCrypto(string $type, string $clientOrServer = 'client', bool $enable = true): void { if (!array_key_exists($type . '_' . $clientOrServer, $this->_encryptMethods)) { throw new InvalidArgumentException('Invalid encryption scheme chosen'); } $method = $this->_encryptMethods[$type . '_' . $clientOrServer]; - // Prior to PHP 5.6.7 TLS_CLIENT was any version of TLS. This was changed in 5.6.7 - // to fix backwards compatibility issues, and now only resolves to TLS1.0 - // - // See https://github.com/php/php-src/commit/10bc5fd4c4c8e1dd57bd911b086e9872a56300a0 - if (version_compare(PHP_VERSION, '5.6.7', '>=')) { - if ($method == STREAM_CRYPTO_METHOD_TLS_CLIENT) { - // @codingStandardsIgnoreStart - $method |= STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; - // @codingStandardsIgnoreEnd - } - if ($method == STREAM_CRYPTO_METHOD_TLS_SERVER) { - // @codingStandardsIgnoreStart - $method |= STREAM_CRYPTO_METHOD_TLSv1_1_SERVER | STREAM_CRYPTO_METHOD_TLSv1_2_SERVER; - // @codingStandardsIgnoreEnd - } + if ($method === STREAM_CRYPTO_METHOD_TLS_CLIENT) { + $method |= STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; + } + if ($method === STREAM_CRYPTO_METHOD_TLS_SERVER) { + $method |= STREAM_CRYPTO_METHOD_TLSv1_1_SERVER | STREAM_CRYPTO_METHOD_TLSv1_2_SERVER; } try { + if ($this->connection === null) { + throw new CakeException('You must call connect() first.'); + } $enableCryptoResult = stream_socket_enable_crypto($this->connection, $enable, $method); } catch (Exception $e) { $this->setLastError(null, $e->getMessage()); - throw new SocketException($e->getMessage()); + throw new SocketException($e->getMessage(), null, $e); } + if ($enableCryptoResult === true) { $this->encrypted = $enable; - return true; + return; } + $errorMessage = 'Unable to perform enableCrypto operation on the current socket'; $this->setLastError(null, $errorMessage); throw new SocketException($errorMessage); } + + /** + * Check the encryption status after calling `enableCrypto()`. + * + * @return bool + */ + public function isEncrypted(): bool + { + return $this->encrypted; + } } diff --git a/src/ORM/.gitattributes b/src/ORM/.gitattributes new file mode 100644 index 00000000000..0086560d10e --- /dev/null +++ b/src/ORM/.gitattributes @@ -0,0 +1,10 @@ +# Define the line ending behavior of the different file extensions +# Set default behavior, in case users don't have core.autocrlf set. +* text text=auto eol=lf + +.php diff=php + +# Remove files for archives generated using `git archive` +.gitattributes export-ignore +phpstan.neon.dist export-ignore +tests/ export-ignore diff --git a/src/ORM/Association.php b/src/ORM/Association.php index f9300a2f0d9..4ec9673abf5 100644 --- a/src/ORM/Association.php +++ b/src/ORM/Association.php @@ -1,4 +1,6 @@ |string */ - protected $_bindingKey; + protected array|string $_bindingKey; /** * The name of the field representing the foreign key to the table to load * - * @var string|array + * @var array|string|false */ - protected $_foreignKey; + protected array|string|false $_foreignKey; /** * A list of conditions to be always included when fetching records from * the target association * - * @var array + * @var \Closure|array */ - protected $_conditions = []; + protected Closure|array $_conditions = []; /** * Whether the records on the target table are dependent on the source table, @@ -131,35 +136,35 @@ abstract class Association * * @var bool */ - protected $_dependent = false; + protected bool $_dependent = false; /** - * Whether or not cascaded deletes should also fire callbacks. + * Whether cascaded deletes should also fire callbacks. * * @var bool */ - protected $_cascadeCallbacks = false; + protected bool $_cascadeCallbacks = false; /** * Source table instance * * @var \Cake\ORM\Table */ - protected $_sourceTable; + protected Table $_sourceTable; /** * Target table instance * * @var \Cake\ORM\Table */ - protected $_targetTable; + protected Table $_targetTable; /** * The type of join to be used when adding the association to a query * * @var string */ - protected $_joinType = QueryInterface::JOIN_TYPE_LEFT; + protected string $_joinType = SelectQuery::JOIN_TYPE_LEFT; /** * The property name that should be filled with data from the target table @@ -167,7 +172,7 @@ abstract class Association * * @var string */ - protected $_propertyName; + protected string $_propertyName; /** * The strategy name to be used to fetch associated records. Some association @@ -175,34 +180,40 @@ abstract class Association * * @var string */ - protected $_strategy = self::STRATEGY_JOIN; + protected string $_strategy = self::STRATEGY_JOIN; /** * The default finder name to use for fetching rows from the target table + * With array value, finder name and default options are allowed. * - * @var string + * @var array|string */ - protected $_finder = 'all'; + protected array|string $_finder = 'all'; /** * Valid strategies for this association. Subclasses can narrow this down. * - * @var array + * @var array */ - protected $_validStrategies = [ + protected array $_validStrategies = [ self::STRATEGY_JOIN, self::STRATEGY_SELECT, - self::STRATEGY_SUBQUERY + self::STRATEGY_SUBQUERY, ]; + /** + * Whether the property name needs to be checked for collisions with source table fields. + */ + private bool $checkPropertyName = true; + /** * Constructor. Subclasses can override _options function to get the original * list of passed options if expecting any other special key * * @param string $alias The name given to the association - * @param array $options A list of properties to be set on this object + * @param array $options A list of properties to be set on this object */ - public function __construct($alias, array $options = []) + public function __construct(string $alias, array $options = []) { $defaults = [ 'cascadeCallbacks', @@ -216,7 +227,7 @@ public function __construct($alias, array $options = []) 'tableLocator', 'propertyName', 'sourceTable', - 'targetTable' + 'targetTable', ]; foreach ($defaults as $property) { if (isset($options[$property])) { @@ -224,11 +235,9 @@ public function __construct($alias, array $options = []) } } - if (empty($this->_className) && strpos($alias, '.')) { - $this->_className = $alias; - } + $this->_className ??= $alias; - list(, $name) = pluginSplit($alias); + [, $name] = pluginSplit($alias); $this->_name = $name; $this->_options($options); @@ -238,61 +247,24 @@ public function __construct($alias, array $options = []) } } - /** - * Sets the name for this association, usually the alias - * assigned to the target associated table - * - * @param string $name Name to be assigned - * @return $this - */ - public function setName($name) - { - if ($this->_targetTable !== null) { - $alias = $this->_targetTable->getAlias(); - if ($alias !== $name) { - throw new InvalidArgumentException('Association name does not match target table alias.'); - } - } - - $this->_name = $name; - - return $this; - } - /** * Gets the name for this association, usually the alias * assigned to the target associated table * * @return string */ - public function getName() + public function getName(): string { return $this->_name; } /** - * Sets the name for this association. - * - * @deprecated 3.4.0 Use setName()/getName() instead. - * @param string|null $name Name to be assigned - * @return string - */ - public function name($name = null) - { - if ($name !== null) { - $this->setName($name); - } - - return $this->getName(); - } - - /** - * Sets whether or not cascaded deletes should also fire callbacks. + * Sets whether cascaded deletes should also fire callbacks. * * @param bool $cascadeCallbacks cascade callbacks switch value * @return $this */ - public function setCascadeCallbacks($cascadeCallbacks) + public function setCascadeCallbacks(bool $cascadeCallbacks) { $this->_cascadeCallbacks = $cascadeCallbacks; @@ -300,38 +272,47 @@ public function setCascadeCallbacks($cascadeCallbacks) } /** - * Gets whether or not cascaded deletes should also fire callbacks. + * Gets whether cascaded deletes should also fire callbacks. * * @return bool */ - public function getCascadeCallbacks() + public function getCascadeCallbacks(): bool { return $this->_cascadeCallbacks; } /** - * Sets whether or not cascaded deletes should also fire callbacks. If no - * arguments are passed, the current configured value is returned + * Sets the class name of the target table object. * - * @deprecated 3.4.0 Use setCascadeCallbacks()/getCascadeCallbacks() instead. - * @param bool|null $cascadeCallbacks cascade callbacks switch value - * @return bool + * @param string $className Class name to set. + * @return $this + * @throws \InvalidArgumentException In case the class name is set after the target table has been + * resolved, and it doesn't match the target table's class name. */ - public function cascadeCallbacks($cascadeCallbacks = null) + public function setClassName(string $className) { - if ($cascadeCallbacks !== null) { - $this->setCascadeCallbacks($cascadeCallbacks); + if ( + isset($this->_targetTable) && + get_class($this->_targetTable) !== App::className($className, 'Model/Table', 'Table') + ) { + throw new InvalidArgumentException(sprintf( + "The class name `%s` doesn't match the target table class name of `%s`.", + $className, + $this->_targetTable::class, + )); } - return $this->getCascadeCallbacks(); + $this->_className = $className; + + return $this; } /** - * The class name of the target table object + * Gets the class name of the target table object. * * @return string */ - public function className() + public function getClassName(): string { return $this->_className; } @@ -354,28 +335,11 @@ public function setSource(Table $table) * * @return \Cake\ORM\Table */ - public function getSource() + public function getSource(): Table { return $this->_sourceTable; } - /** - * Sets the table instance for the source side of the association. If no arguments - * are passed, the current configured table instance is returned - * - * @deprecated 3.4.0 Use setSource()/getSource() instead. - * @param \Cake\ORM\Table|null $table the instance to be assigned as source side - * @return \Cake\ORM\Table - */ - public function source(Table $table = null) - { - if ($table === null) { - return $this->_sourceTable; - } - - return $this->_sourceTable = $table; - } - /** * Sets the table instance for the target side of the association. * @@ -394,11 +358,11 @@ public function setTarget(Table $table) * * @return \Cake\ORM\Table */ - public function getTarget() + public function getTarget(): Table { - if (!$this->_targetTable) { - if (strpos($this->_className, '.')) { - list($plugin) = pluginSplit($this->_className, true); + if (!isset($this->_targetTable)) { + if (str_contains($this->_className, '.')) { + [$plugin] = pluginSplit($this->_className, true); $registryAlias = $plugin . $this->_name; } else { $registryAlias = $this->_name; @@ -414,19 +378,20 @@ public function getTarget() $this->_targetTable = $tableLocator->get($registryAlias, $config); if ($exists) { - $className = $this->_getClassName($registryAlias, ['className' => $this->_className]); + $className = App::className($this->_className, 'Model/Table', 'Table') ?: Table::class; if (!$this->_targetTable instanceof $className) { - $errorMessage = '%s association "%s" of type "%s" to "%s" doesn\'t match the expected class "%s". '; - $errorMessage .= 'You can\'t have an association of the same name with a different target "className" option anywhere in your app.'; + $msg = "`%s` association `%s` of type `%s` to `%s` doesn't match the expected class `%s`. "; + $msg .= "You can't have an association of the same name with a different target "; + $msg .= '"className" option anywhere in your app.'; - throw new RuntimeException(sprintf( - $errorMessage, - $this->_sourceTable ? get_class($this->_sourceTable) : 'null', + throw new DatabaseException(sprintf( + $msg, + isset($this->_sourceTable) ? $this->_sourceTable::class : 'null', $this->getName(), $this->type(), - $this->_targetTable ? get_class($this->_targetTable) : 'null', - $className + $this->_targetTable::class, + $className, )); } } @@ -435,32 +400,15 @@ public function getTarget() return $this->_targetTable; } - /** - * Sets the table instance for the target side of the association. If no arguments - * are passed, the current configured table instance is returned - * - * @deprecated 3.4.0 Use setTarget()/getTarget() instead. - * @param \Cake\ORM\Table|null $table the instance to be assigned as target side - * @return \Cake\ORM\Table - */ - public function target(Table $table = null) - { - if ($table !== null) { - $this->setTarget($table); - } - - return $this->getTarget(); - } - /** * Sets a list of conditions to be always included when fetching records from * the target association. * - * @param array $conditions list of conditions to be used + * @param \Closure|array $conditions list of conditions to be used * @see \Cake\Database\Query::where() for examples on the format of the array * @return $this */ - public function setConditions($conditions) + public function setConditions(Closure|array $conditions) { $this->_conditions = $conditions; @@ -472,39 +420,21 @@ public function setConditions($conditions) * the target association. * * @see \Cake\Database\Query::where() for examples on the format of the array - * @return array + * @return \Closure|array */ - public function getConditions() + public function getConditions(): Closure|array { return $this->_conditions; } - /** - * Sets a list of conditions to be always included when fetching records from - * the target association. If no parameters are passed the current list is returned - * - * @deprecated 3.4.0 Use setConditions()/getConditions() instead. - * @param array|null $conditions list of conditions to be used - * @see \Cake\Database\Query::where() for examples on the format of the array - * @return array - */ - public function conditions($conditions = null) - { - if ($conditions !== null) { - $this->setConditions($conditions); - } - - return $this->getConditions(); - } - /** * Sets the name of the field representing the binding field with the target table. * When not manually specified the primary key of the owning side table is used. * - * @param string|array $key the table field or fields to be used to link both tables together + * @param array|string $key the table field or fields to be used to link both tables together * @return $this */ - public function setBindingKey($key) + public function setBindingKey(array|string $key) { $this->_bindingKey = $key; @@ -515,11 +445,11 @@ public function setBindingKey($key) * Gets the name of the field representing the binding field with the target table. * When not manually specified the primary key of the owning side table is used. * - * @return string|array + * @return array|string */ - public function getBindingKey() + public function getBindingKey(): array|string { - if ($this->_bindingKey === null) { + if (!isset($this->_bindingKey)) { $this->_bindingKey = $this->isOwningSide($this->getSource()) ? $this->getSource()->getPrimaryKey() : $this->getTarget()->getPrimaryKey(); @@ -528,31 +458,12 @@ public function getBindingKey() return $this->_bindingKey; } - /** - * Sets the name of the field representing the binding field with the target table. - * When not manually specified the primary key of the owning side table is used. - * - * If no parameters are passed the current field is returned - * - * @deprecated 3.4.0 Use setBindingKey()/getBindingKey() instead. - * @param string|null $key the table field to be used to link both tables together - * @return string|array - */ - public function bindingKey($key = null) - { - if ($key !== null) { - $this->setBindingKey($key); - } - - return $this->getBindingKey(); - } - /** * Gets the name of the field representing the foreign key to the target table. * - * @return string|array + * @return array|string|false */ - public function getForeignKey() + public function getForeignKey(): array|string|false { return $this->_foreignKey; } @@ -560,33 +471,16 @@ public function getForeignKey() /** * Sets the name of the field representing the foreign key to the target table. * - * @param string|array $key the key or keys to be used to link both tables together + * @param array|string $key the key or keys to be used to link both tables together * @return $this */ - public function setForeignKey($key) + public function setForeignKey(array|string $key) { $this->_foreignKey = $key; return $this; } - /** - * Sets the name of the field representing the foreign key to the target table. - * If no parameters are passed the current field is returned - * - * @deprecated 3.4.0 Use setForeignKey()/getForeignKey() instead. - * @param string|null $key the key to be used to link both tables together - * @return string|array - */ - public function foreignKey($key = null) - { - if ($key !== null) { - $this->setForeignKey($key); - } - - return $this->getForeignKey(); - } - /** * Sets whether the records on the target table are dependent on the source table. * @@ -598,7 +492,7 @@ public function foreignKey($key = null) * @param bool $dependent Set the dependent mode. Use null to read the current state. * @return $this */ - public function setDependent($dependent) + public function setDependent(bool $dependent) { $this->_dependent = $dependent; @@ -613,43 +507,22 @@ public function setDependent($dependent) * * @return bool */ - public function getDependent() + public function getDependent(): bool { return $this->_dependent; } - /** - * Sets whether the records on the target table are dependent on the source table. - * - * This is primarily used to indicate that records should be removed if the owning record in - * the source table is deleted. - * - * If no parameters are passed the current setting is returned. - * - * @deprecated 3.4.0 Use setDependent()/getDependent() instead. - * @param bool|null $dependent Set the dependent mode. Use null to read the current state. - * @return bool - */ - public function dependent($dependent = null) - { - if ($dependent !== null) { - $this->setDependent($dependent); - } - - return $this->getDependent(); - } - /** * Whether this association can be expressed directly in a query join * - * @param array $options custom options key that could alter the return value + * @param array $options custom options key that could alter the return value * @return bool */ - public function canBeJoined(array $options = []) + public function canBeJoined(array $options = []): bool { - $strategy = isset($options['strategy']) ? $options['strategy'] : $this->getStrategy(); + $strategy = $options['strategy'] ?? $this->getStrategy(); - return $strategy == $this::STRATEGY_JOIN; + return $strategy === $this::STRATEGY_JOIN; } /** @@ -658,7 +531,7 @@ public function canBeJoined(array $options = []) * @param string $type the join type to be used (e.g. INNER) * @return $this */ - public function setJoinType($type) + public function setJoinType(string $type) { $this->_joinType = $type; @@ -670,38 +543,22 @@ public function setJoinType($type) * * @return string */ - public function getJoinType() + public function getJoinType(): string { return $this->_joinType; } - /** - * Sets the type of join to be used when adding the association to a query. - * If no arguments are passed, the currently configured type is returned. - * - * @deprecated 3.4.0 Use setJoinType()/getJoinType() instead. - * @param string|null $type the join type to be used (e.g. INNER) - * @return string - */ - public function joinType($type = null) - { - if ($type !== null) { - $this->setJoinType($type); - } - - return $this->getJoinType(); - } - /** * Sets the property name that should be filled with data from the target table * in the source table record. * - * @param string $name The name of the association property. Use null to read the current value. + * @param string $name The name of the association property. * @return $this */ - public function setProperty($name) + public function setProperty(string $name) { $this->_propertyName = $name; + $this->checkPropertyName = true; return $this; } @@ -712,39 +569,25 @@ public function setProperty($name) * * @return string */ - public function getProperty() + public function getProperty(): string { - if (!$this->_propertyName) { - $this->_propertyName = $this->_propertyName(); - if (in_array($this->_propertyName, $this->_sourceTable->getSchema()->columns())) { - $msg = 'Association property name "%s" clashes with field of same name of table "%s".' . - ' You should explicitly specify the "propertyName" option.'; - trigger_error( - sprintf($msg, $this->_propertyName, $this->_sourceTable->getTable()), - E_USER_WARNING - ); - } + if (!isset($this->_propertyName)) { + $this->setProperty($this->_propertyName()); } - return $this->_propertyName; - } - - /** - * Sets the property name that should be filled with data from the target table - * in the source table record. - * If no arguments are passed, the currently configured type is returned. - * - * @deprecated 3.4.0 Use setProperty()/getProperty() instead. - * @param string|null $name The name of the association property. Use null to read the current value. - * @return string - */ - public function property($name = null) - { - if ($name !== null) { - $this->setProperty($name); + if ( + $this->checkPropertyName + && in_array($this->_propertyName, $this->_sourceTable->getSchema()->columns(), true) + ) { + $msg = 'Association property name `%s` clashes with field of same name of table `%s`.' . + ' You should specify an alterate name using the `propertyName` option or `setProperty()` method.'; + trigger_error( + sprintf($msg, $this->_propertyName, $this->_sourceTable->getTable()), + E_USER_WARNING, + ); } - return $this->getProperty(); + return $this->_propertyName; } /** @@ -752,28 +595,34 @@ public function property($name = null) * * @return string */ - protected function _propertyName() + protected function _propertyName(): string { - list(, $name) = pluginSplit($this->_name); + [, $name] = pluginSplit($this->_name); return Inflector::underscore($name); } /** - * Sets the strategy name to be used to fetch associated records. Keep in mind - * that some association types might not implement but a default strategy, - * rendering any changes to this setting void. + * Sets the strategy name to be used to fetch associated records. * - * @param string $name The strategy type. Use null to read the current value. + * Valid strategies depend on the association type and are stored in $_validStrategies. + * Some association types might only implement a default strategy, making this setting + * ineffective. + * + * @param string $name The strategy type (e.g., 'select', 'subquery', 'join'). + * Available strategies vary by association type. * @return $this * @throws \InvalidArgumentException When an invalid strategy is provided. + * @see Association::getStrategy() to retrieve the current strategy. */ - public function setStrategy($name) + public function setStrategy(string $name) { - if (!in_array($name, $this->_validStrategies)) { - throw new InvalidArgumentException( - sprintf('Invalid strategy "%s" was provided', $name) - ); + if (!in_array($name, $this->_validStrategies, true)) { + throw new InvalidArgumentException(sprintf( + 'Invalid strategy `%s` was provided. Valid options are `(%s)`.', + $name, + implode(', ', $this->_validStrategies), + )); } $this->_strategy = $name; @@ -787,37 +636,17 @@ public function setStrategy($name) * * @return string */ - public function getStrategy() + public function getStrategy(): string { return $this->_strategy; } - /** - * Sets the strategy name to be used to fetch associated records. Keep in mind - * that some association types might not implement but a default strategy, - * rendering any changes to this setting void. - * If no arguments are passed, the currently configured strategy is returned. - * - * @deprecated 3.4.0 Use setStrategy()/getStrategy() instead. - * @param string|null $name The strategy type. Use null to read the current value. - * @return string - * @throws \InvalidArgumentException When an invalid strategy is provided. - */ - public function strategy($name = null) - { - if ($name !== null) { - $this->setStrategy($name); - } - - return $this->getStrategy(); - } - /** * Gets the default finder to use for fetching rows from the target table. * - * @return string + * @return array|string */ - public function getFinder() + public function getFinder(): array|string { return $this->_finder; } @@ -825,42 +654,24 @@ public function getFinder() /** * Sets the default finder to use for fetching rows from the target table. * - * @param string $finder the finder name to use + * @param array|string $finder the finder name to use or array of finder name and option. * @return $this */ - public function setFinder($finder) + public function setFinder(array|string $finder) { $this->_finder = $finder; return $this; } - /** - * Sets the default finder to use for fetching rows from the target table. - * If no parameters are passed, it will return the currently configured - * finder name. - * - * @deprecated 3.4.0 Use setFinder()/getFinder() instead. - * @param string|null $finder the finder name to use - * @return string - */ - public function finder($finder = null) - { - if ($finder !== null) { - $this->setFinder($finder); - } - - return $this->getFinder(); - } - /** * Override this function to initialize any concrete association class, it will * get passed the original list of options used in the constructor * - * @param array $options List of options used for initialization + * @param array $options List of options used for initialization * @return void */ - protected function _options(array $options) + protected function _options(array $options): void { } @@ -868,7 +679,7 @@ protected function _options(array $options) * Alters a Query object to include the associated target table data in the final * result * - * The options array accept the following keys: + * The options array accepts the following keys: * * - includeFields: Whether to include target model fields in the result or not * - foreignKey: The name of the field to use as foreign key, if false none @@ -876,8 +687,6 @@ protected function _options(array $options) * - conditions: array with a list of conditions to filter the join with, this * will be merged with any conditions originally configured for this association * - fields: a list of fields in the target table to include in the result - * - type: The type of join to be used (e.g. INNER) - * the records found on this association * - aliasPath: A dot separated string representing the path of association names * followed from the passed query main table to this association. * - propertyPath: A dot separated string representing the path of association @@ -887,56 +696,74 @@ protected function _options(array $options) * - negateMatch: Will append a condition to the passed query for excluding matches. * with this association. * - * @param \Cake\ORM\Query $query the query to be altered to include the target table data - * @param array $options Any extra options or overrides to be taken in account + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query the query to be altered to include the target table data + * @param array $options Any extra options or overrides to be taken into account * @return void - * @throws \RuntimeException if the query builder passed does not return a query - * object + * @throws \RuntimeException Unable to build the query or associations. */ - public function attachTo(Query $query, array $options = []) + public function attachTo(SelectQuery $query, array $options = []): void { $target = $this->getTarget(); - $joinType = empty($options['joinType']) ? $this->getJoinType() : $options['joinType']; $table = $target->getTable(); $options += [ 'includeFields' => true, 'foreignKey' => $this->getForeignKey(), 'conditions' => [], + 'joinType' => $this->getJoinType(), 'fields' => [], - 'type' => $joinType, 'table' => $table, - 'finder' => $this->getFinder() + 'finder' => $this->getFinder(), ]; - if (!empty($options['foreignKey'])) { + // This is set by joinWith to disable matching results + if ($options['fields'] === false) { + $options['fields'] = []; + $options['includeFields'] = false; + } + + if ($options['foreignKey']) { $joinCondition = $this->_joinCondition($options); if ($joinCondition) { $options['conditions'][] = $joinCondition; } } - list($finder, $opts) = $this->_extractFinder($options['finder']); + [$finder, $opts] = $this->_extractFinder($options['finder']); $dummy = $this - ->find($finder, $opts) + ->find($finder, ...$opts) ->eagerLoaded(true); if (!empty($options['queryBuilder'])) { + assert(is_callable($options['queryBuilder'])); $dummy = $options['queryBuilder']($dummy); - if (!($dummy instanceof Query)) { - throw new RuntimeException(sprintf( - 'Query builder for association "%s" did not return a query', - $this->getName() + if (!($dummy instanceof SelectQuery)) { + throw new DatabaseException(sprintf( + 'Query builder for association `%s` did not return a query.', + $this->getName(), )); } } + if ( + !empty($options['matching']) && + $this->_strategy === static::STRATEGY_JOIN && + $dummy->getContain() + ) { + throw new DatabaseException(sprintf( + '`%s` association cannot contain() associations when using JOIN strategy.', + $this->getName(), + )); + } + $dummy->where($options['conditions']); $this->_dispatchBeforeFind($dummy); - $joinOptions = ['table' => 1, 'conditions' => 1, 'type' => 1]; - $options['conditions'] = $dummy->clause('where'); - $query->join([$this->_name => array_intersect_key($options, $joinOptions)]); + $query->join([$this->_name => [ + 'table' => $options['table'], + 'conditions' => $dummy->clause('where'), + 'type' => $options['joinType'], + ]]); $this->_appendFields($query, $dummy, $options); $this->_formatAssociationResults($query, $dummy, $options); @@ -948,17 +775,19 @@ public function attachTo(Query $query, array $options = []) * Conditionally adds a condition to the passed Query that will make it find * records where there is no match with this association. * - * @param \Cake\Datasource\QueryInterface $query The query to modify - * @param array $options Options array containing the `negateMatch` key. + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query The query to modify + * @param array $options Options array containing the `negateMatch` key. * @return void */ - protected function _appendNotMatching($query, $options) + protected function _appendNotMatching(SelectQuery $query, array $options): void { - $target = $this->_targetTable; + $target = $this->getTarget(); if (!empty($options['negateMatch'])) { $primaryKey = $query->aliasFields((array)$target->getPrimaryKey(), $this->_name); $query->andWhere(function ($exp) use ($primaryKey) { - array_map([$exp, 'isNull'], $primaryKey); + /** @var callable $callable */ + $callable = [$exp, 'isNull']; + array_map($callable, $primaryKey); return $exp; }); @@ -969,16 +798,16 @@ protected function _appendNotMatching($query, $options) * Correctly nests a result row associated values into the correct array keys inside the * source results. * - * @param array $row The row to transform + * @param array $row The row to transform * @param string $nestKey The array key under which the results for this association * should be found - * @param bool $joined Whether or not the row is a result of a direct join + * @param bool $joined Whether the row is a result of a direct join * with this association * @param string|null $targetProperty The property name in the source results where the association - * data shuld be nested in. Will use the default one if not provided. + * data should be nested in. Will use the default one if not provided. * @return array */ - public function transformRow($row, $nestKey, $joined, $targetProperty = null) + public function transformRow(array $row, string $nestKey, bool $joined, ?string $targetProperty = null): array { $sourceAlias = $this->getSource()->getAlias(); $nestKey = $nestKey ?: $this->_name; @@ -996,12 +825,12 @@ public function transformRow($row, $nestKey, $joined, $targetProperty = null) * with the default empty value according to whether the association was * joined or fetched externally. * - * @param array $row The row to set a default on. - * @param bool $joined Whether or not the row is a result of a direct join + * @param array $row The row to set a default on. + * @param bool $joined Whether the row is a result of a direct join * with this association - * @return array + * @return array */ - public function defaultRowValue($row, $joined) + public function defaultRowValue(array $row, bool $joined): array { $sourceAlias = $this->getSource()->getAlias(); if (isset($row[$sourceAlias])) { @@ -1016,19 +845,21 @@ public function defaultRowValue($row, $joined) * and modifies the query accordingly based of this association * configuration * - * @param string|array|null $type the type of query to perform, if an array is passed, - * it will be interpreted as the `$options` parameter - * @param array $options The options to for the find + * @param array|string|null $type the type of query to perform if an array is passed, + * it will be interpreted as the `$args` parameter + * @param mixed ...$args Arguments that match up to finder-specific parameters * @see \Cake\ORM\Table::find() - * @return \Cake\ORM\Query + * @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> */ - public function find($type = null, array $options = []) + public function find(array|string|null $type = null, mixed ...$args): SelectQuery { $type = $type ?: $this->getFinder(); - list($type, $opts) = $this->_extractFinder($type); + [$type, $opts] = $this->_extractFinder($type); + + $args += $opts; return $this->getTarget() - ->find($type, $options + $opts) + ->find($type, ...$args) ->where($this->getConditions()); } @@ -1036,83 +867,78 @@ public function find($type = null, array $options = []) * Proxies the operation to the target table's exists method after * appending the default conditions for this association * - * @param array|callable|\Cake\Database\ExpressionInterface $conditions The conditions to use + * @param \Cake\Database\ExpressionInterface|\Closure|array|string|null $conditions The conditions to use * for checking if any record matches. * @see \Cake\ORM\Table::exists() * @return bool */ - public function exists($conditions) + public function exists(ExpressionInterface|Closure|array|string|null $conditions): bool { - if ($this->_conditions) { - $conditions = $this - ->find('all', ['conditions' => $conditions]) - ->clause('where'); - } + $conditions = $this->find() + ->where($conditions) + ->clause('where'); return $this->getTarget()->exists($conditions); } /** - * Proxies the update operation to the target table's updateAll method + * Proxies the update operation to the target `Table::updateAll()` method * - * @param array $fields A hash of field => new value. - * @param mixed $conditions Conditions to be used, accepts anything Query::where() - * can take. - * @see \Cake\ORM\Table::updateAll() + * @param \Cake\Database\Expression\QueryExpression|\Closure|array|string $fields A hash of field => new value. + * @param \Cake\Database\Expression\QueryExpression|\Closure|array|string|null $conditions Conditions to be used, accepts anything Query::where() * @return int Count Returns the affected rows. + * @see \Cake\ORM\Table::updateAll() */ - public function updateAll($fields, $conditions) - { - $target = $this->getTarget(); - $expression = $target->query() - ->where($this->getConditions()) + public function updateAll( + QueryExpression|Closure|array|string $fields, + QueryExpression|Closure|array|string|null $conditions, + ): int { + $expression = $this->find() ->where($conditions) ->clause('where'); - return $target->updateAll($fields, $expression); + return $this->getTarget()->updateAll($fields, $expression); } /** - * Proxies the delete operation to the target table's deleteAll method + * Proxies the delete operation to the target `Table::deleteAll()` method * - * @param mixed $conditions Conditions to be used, accepts anything Query::where() + * @param \Cake\Database\Expression\QueryExpression|\Closure|array|string|null $conditions Conditions to be used, accepts anything Query::where() * can take. * @return int Returns the number of affected rows. * @see \Cake\ORM\Table::deleteAll() */ - public function deleteAll($conditions) + public function deleteAll(QueryExpression|Closure|array|string|null $conditions): int { - $target = $this->getTarget(); - $expression = $target->query() - ->where($this->getConditions()) + $expression = $this->find() ->where($conditions) ->clause('where'); - return $target->deleteAll($expression); + return $this->getTarget()->deleteAll($expression); } /** * Returns true if the eager loading process will require a set of the owning table's * binding keys in order to use them as a filter in the finder query. * - * @param array $options The options containing the strategy to be used. + * @param array $options The options containing the strategy to be used. * @return bool true if a list of keys will be required */ - public function requiresKeys(array $options = []) + public function requiresKeys(array $options = []): bool { - $strategy = isset($options['strategy']) ? $options['strategy'] : $this->getStrategy(); + $strategy = $options['strategy'] ?? $this->getStrategy(); return $strategy === static::STRATEGY_SELECT; } /** - * Triggers beforeFind on the target table for the query this association is + * Triggers `beforeFind` on the target table for the query this association is * attaching to * - * @param \Cake\ORM\Query $query the query this association is attaching itself to + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query the query this association is attaching itself to * @return void */ - protected function _dispatchBeforeFind($query) + protected function _dispatchBeforeFind(SelectQuery $query): void { $query->triggerBeforeFind(); } @@ -1121,53 +947,74 @@ protected function _dispatchBeforeFind($query) * Helper function used to conditionally append fields to the select clause of * a query from the fields found in another query object. * - * @param \Cake\ORM\Query $query the query that will get the fields appended to - * @param \Cake\ORM\Query $surrogate the query having the fields to be copied from - * @param array $options options passed to the method `attachTo` + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query the query that will get the fields appended to + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $surrogate the query having the fields to be copied from + * @param array $options options passed to the method `attachTo` * @return void */ - protected function _appendFields($query, $surrogate, $options) + protected function _appendFields(SelectQuery $query, SelectQuery $surrogate, array $options): void { if ($query->getEagerLoader()->isAutoFieldsEnabled() === false) { return; } - $fields = $surrogate->clause('select') ?: $options['fields']; - $target = $this->_targetTable; - $autoFields = $surrogate->isAutoFieldsEnabled(); - - if (empty($fields) && !$autoFields) { - if ($options['includeFields'] && ($fields === null || $fields !== false)) { - $fields = $target->getSchema()->columns(); + $fields = array_merge($surrogate->clause('select'), $options['fields']); + + if ( + ($fields === [] && $options['includeFields']) || + $surrogate->isAutoFieldsEnabled() + ) { + $fields = array_merge($fields, $this->getTarget()->getSchema()->columns()); + } elseif ($fields !== []) { + // Ensure primary key fields are always included when specific fields are selected + // This prevents issues with entity hydration when only nullable columns are selected + $primaryKey = $this->getTarget()->getPrimaryKey(); + $primaryKeyFields = is_array($primaryKey) ? $primaryKey : [$primaryKey]; + + $fieldsToAdd = []; + foreach ($primaryKeyFields as $pkField) { + $found = false; + foreach ($fields as $field) { + if ( + is_string($field) && ( + $field === $pkField || + str_ends_with($field, '.' . $pkField) + ) + ) { + $found = true; + break; + } + } + if (!$found) { + $fieldsToAdd[] = $pkField; + } } - } - if ($autoFields === true) { - $fields = array_merge((array)$fields, $target->getSchema()->columns()); + if ($fieldsToAdd) { + $fields = array_merge($fields, $fieldsToAdd); + } } - if ($fields) { - $query->select($query->aliasFields($fields, $this->_name)); - } - $query->addDefaultTypes($target); + $query->select($query->aliasFields($fields, $this->_name)); + $query->addDefaultTypes($this->getTarget()); } /** * Adds a formatter function to the passed `$query` if the `$surrogate` query - * declares any other formatter. Since the `$surrogate` query correspond to + * declares any other formatter. Since the `$surrogate` query corresponds to * the associated target table, the resulting formatter will be the result of * applying the surrogate formatters to only the property corresponding to - * such table. + * such a table. * - * @param \Cake\ORM\Query $query the query that will get the formatter applied to - * @param \Cake\ORM\Query $surrogate the query having formatters for the associated + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query the query that will get the formatter applied to + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $surrogate the query having formatters for the associated * target table. - * @param array $options options passed to the method `attachTo` + * @param array $options options passed to the method `attachTo` * @return void */ - protected function _formatAssociationResults($query, $surrogate, $options) + protected function _formatAssociationResults(SelectQuery $query, SelectQuery $surrogate, array $options): void { - $formatters = $surrogate->formatResults(); + $formatters = $surrogate->getResultFormatters(); if (!$formatters || empty($options['propertyPath'])) { return; @@ -1175,26 +1022,41 @@ protected function _formatAssociationResults($query, $surrogate, $options) $property = $options['propertyPath']; $propertyPath = explode('.', $property); - $query->formatResults(function ($results) use ($formatters, $property, $propertyPath) { - $extracted = []; - foreach ($results as $result) { - foreach ($propertyPath as $propertyPathItem) { - if (!isset($result[$propertyPathItem])) { - $result = null; - break; + $query->formatResults( + function (CollectionInterface $results, SelectQuery $query) use ($formatters, $property, $propertyPath) { + $extracted = []; + foreach ($results as $result) { + foreach ($propertyPath as $propertyPathItem) { + if (!isset($result[$propertyPathItem])) { + $result = null; + break; + } + $result = $result[$propertyPathItem]; } - $result = $result[$propertyPathItem]; + $extracted[] = $result; } - $extracted[] = $result; - } - $extracted = new Collection($extracted); - foreach ($formatters as $callable) { - $extracted = new ResultSetDecorator($callable($extracted)); - } + $extracted = $query->resultSetFactory()->createResultSet($extracted); + $resultSetClass = $query->resultSetFactory()->getResultSetClass(); + foreach ($formatters as $callable) { + $extracted = $callable($extracted, $query); + if (!$extracted instanceof ResultSetInterface) { + $extracted = new $resultSetClass($extracted); + } + } + + $results = $results->insert($property, $extracted); + if ($query->isHydrationEnabled()) { + return $results->map(function (EntityInterface $result) { + $result->clean(); - /* @var \Cake\Collection\CollectionInterface $results */ - return $results->insert($property, $extracted); - }, Query::PREPEND); + return $result; + }); + } + + return $results; + }, + SelectQuery::PREPEND, + ); } /** @@ -1202,18 +1064,18 @@ protected function _formatAssociationResults($query, $surrogate, $options) * in the `$surrogate` query. * * Copies all contained associations from the `$surrogate` query into the - * passed `$query`. Containments are altered so that they respect the associations + * passed `$query`. Containments are altered so that they respect the association * chain from which they originated. * - * @param \Cake\ORM\Query $query the query that will get the associations attached to - * @param \Cake\ORM\Query $surrogate the query having the containments to be attached - * @param array $options options passed to the method `attachTo` + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query the query that will get the associations attached to + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $surrogate the query having the containments to be attached + * @param array $options options passed to the method `attachTo` * @return void */ - protected function _bindNewAssociations($query, $surrogate, $options) + protected function _bindNewAssociations(SelectQuery $query, SelectQuery $surrogate, array $options): void { $loader = $surrogate->getEagerLoader(); - $contain = $loader->contain(); + $contain = $loader->getContain(); $matching = $loader->getMatching(); if (!$contain && !$matching) { @@ -1226,13 +1088,15 @@ protected function _bindNewAssociations($query, $surrogate, $options) } $eagerLoader = $query->getEagerLoader(); - $eagerLoader->contain($newContain); + if ($newContain) { + $eagerLoader->contain($newContain); + } foreach ($matching as $alias => $value) { $eagerLoader->setMatching( $options['aliasPath'] . '.' . $alias, $value['queryBuilder'], - $value + $value, ); } } @@ -1241,12 +1105,12 @@ protected function _bindNewAssociations($query, $surrogate, $options) * Returns a single or multiple conditions to be appended to the generated join * clause for getting the results on the target table. * - * @param array $options list of options passed to attachTo method + * @param array $options list of options passed to attachTo method * @return array - * @throws \RuntimeException if the number of columns in the foreignKey do not + * @throws \Cake\Database\Exception\DatabaseException if the number of columns in the foreignKey do not * match the number of columns in the source table primaryKey */ - protected function _joinCondition($options) + protected function _joinCondition(array $options): array { $conditions = []; $tAlias = $this->_name; @@ -1254,28 +1118,30 @@ protected function _joinCondition($options) $foreignKey = (array)$options['foreignKey']; $bindingKey = (array)$this->getBindingKey(); + $targetOwns = $this->isOwningSide($this->getTarget()); if (count($foreignKey) !== count($bindingKey)) { - if (empty($bindingKey)) { - $table = $this->getTarget()->getTable(); - if ($this->isOwningSide($this->getSource())) { - $table = $this->getSource()->getTable(); - } - $msg = 'The "%s" table does not define a primary key, and cannot have join conditions generated.'; - throw new RuntimeException(sprintf($msg, $table)); + if (!$bindingKey) { + $table = $targetOwns ? $this->getTarget()->getTable() : $this->getSource()->getTable(); + $msg = 'The `%s` table does not define a primary key, and cannot have join conditions generated.'; + throw new DatabaseException(sprintf($msg, $table)); } - $msg = 'Cannot match provided foreignKey for "%s", got "(%s)" but expected foreign key for "(%s)"'; - throw new RuntimeException(sprintf( + $msg = 'Cannot match provided foreignKey for `%s`, got `(%s)` but expected foreign key for `(%s)`'; + throw new DatabaseException(sprintf( $msg, $this->_name, implode(', ', $foreignKey), - implode(', ', $bindingKey) + implode(', ', $bindingKey), )); } foreach ($foreignKey as $k => $f) { - $field = sprintf('%s.%s', $sAlias, $bindingKey[$k]); - $value = new IdentifierExpression(sprintf('%s.%s', $tAlias, $f)); + // Set foreign and binding aliases based on which side has the foreign key + $fAlias = $targetOwns ? $sAlias : $tAlias; + $bAlias = $targetOwns ? $tAlias : $sAlias; + + $field = sprintf('%s.%s', $bAlias, $bindingKey[$k]); + $value = new IdentifierExpression(sprintf('%s.%s', $fAlias, $f)); $conditions[$field] = $value; } @@ -1294,11 +1160,11 @@ protected function _joinCondition($options) * $query->contain(['Comments' => ['finder' => ['translations' => []]]]); * $query->contain(['Comments' => ['finder' => ['translations' => ['locales' => ['en_US']]]]]); * - * @param string|array $finderData The finder name or an array having the name as key + * @param array|string $finderData The finder name or an array having the name as key * and options as value. * @return array */ - protected function _extractFinder($finderData) + protected function _extractFinder(array|string $finderData): array { $finderData = (array)$finderData; @@ -1309,33 +1175,15 @@ protected function _extractFinder($finderData) return [key($finderData), current($finderData)]; } - /** - * Gets the table class name. - * - * @param string $alias The alias name you want to get. - * @param array $options Table options array. - * @return string - */ - protected function _getClassName($alias, array $options = []) - { - if (empty($options['className'])) { - $options['className'] = Inflector::camelize($alias); - } - - $className = App::className($options['className'], 'Model/Table', 'Table') ?: 'Cake\ORM\Table'; - - return ltrim($className, '\\'); - } - /** * Proxies property retrieval to the target table. This is handy for getting this * association's associations * * @param string $property the property name - * @return \Cake\ORM\Association - * @throws \RuntimeException if no association with such name exists + * @return self + * @throws \RuntimeException if no association with such a name exists */ - public function __get($property) + public function __get(string $property): Association { return $this->getTarget()->{$property}; } @@ -1345,11 +1193,11 @@ public function __get($property) * target table has another association with the passed name * * @param string $property the property name - * @return bool true if the property exists + * @return bool true if the association exists */ - public function __isset($property) + public function __isset(string $property): bool { - return isset($this->getTarget()->{$property}); + return $this->getTarget()->hasAssociation($property); } /** @@ -1360,7 +1208,7 @@ public function __isset($property) * @return mixed * @throws \BadMethodCallException */ - public function __call($method, $argument) + public function __call(string $method, array $argument): mixed { return $this->getTarget()->$method(...$argument); } @@ -1370,11 +1218,11 @@ public function __call($method, $argument) * * @return string Constant of either ONE_TO_ONE, MANY_TO_ONE, ONE_TO_MANY or MANY_TO_MANY. */ - abstract public function type(); + abstract public function type(): string; /** * Eager loads a list of records in the target table that are related to another - * set of records in the source table. Source records can specified in two ways: + * set of records in the source table. Source records can be specified in two ways: * first one is by passing a Query object setup to find on the source table and * the other way is by explicitly passing an array of primary key values from * the source table. @@ -1389,7 +1237,7 @@ abstract public function type(); * * Options array accepts the following keys: * - * - query: Query object setup to find the source table records + * - query: SelectQuery object setup to find the source table records * - keys: List of primary key values from the source table * - foreignKey: The name of the field used to relate both tables * - conditions: List of conditions to be passed to the query where() method @@ -1399,10 +1247,10 @@ abstract public function type(); * - strategy: The name of strategy to use for finding target table records * - nestKey: The array key under which results will be found when transforming the row * - * @param array $options The options for eager loading. + * @param array $options The options for eager loading. * @return \Closure */ - abstract public function eagerLoader(array $options); + abstract public function eagerLoader(array $options): Closure; /** * Handles cascading a delete from an associated model. @@ -1411,30 +1259,30 @@ abstract public function eagerLoader(array $options); * required. * * @param \Cake\Datasource\EntityInterface $entity The entity that started the cascaded delete. - * @param array $options The options for the original delete. + * @param array $options The options for the original delete. * @return bool Success */ - abstract public function cascadeDelete(EntityInterface $entity, array $options = []); + abstract public function cascadeDelete(EntityInterface $entity, array $options = []): bool; /** - * Returns whether or not the passed table is the owning side for this + * Returns whether the passed table is the owning side for this * association. This means that rows in the 'target' table would miss important * or required information if the row in 'source' did not exist. * * @param \Cake\ORM\Table $side The potential Table with ownership * @return bool */ - abstract public function isOwningSide(Table $side); + abstract public function isOwningSide(Table $side): bool; /** * Extract the target's association data our from the passed entity and proxies * the saving operation to the target table. * * @param \Cake\Datasource\EntityInterface $entity the data to be saved - * @param array $options The options for saving associated data. - * @return bool|\Cake\Datasource\EntityInterface false if $entity could not be saved, otherwise it returns + * @param array $options The options for saving associated data. + * @return \Cake\Datasource\EntityInterface|false false if $entity could not be saved, otherwise it returns * the saved entity * @see \Cake\ORM\Table::save() */ - abstract public function saveAssociated(EntityInterface $entity, array $options = []); + abstract public function saveAssociated(EntityInterface $entity, array $options = []): EntityInterface|false; } diff --git a/src/ORM/Association/BelongsTo.php b/src/ORM/Association/BelongsTo.php index 651f81e3035..a504900c476 100644 --- a/src/ORM/Association/BelongsTo.php +++ b/src/ORM/Association/BelongsTo.php @@ -1,4 +1,6 @@ */ - protected $_validStrategies = [ + protected array $_validStrategies = [ self::STRATEGY_JOIN, - self::STRATEGY_SELECT + self::STRATEGY_SELECT, ]; /** - * Gets the name of the field representing the foreign key to the target table. + * @inheritDoc + */ + public function getForeignKey(): array|string|false + { + return $this->_foreignKey ??= $this->_modelKey($this->getTarget()->getAlias()); + } + + /** + * Sets the name of the field representing the foreign key to the target table. * - * @return string + * @param array|string|false $key the key or keys to be used to link both tables together, if set to `false` + * no join conditions will be generated automatically. + * @return $this */ - public function getForeignKey() + public function setForeignKey(array|string|false $key) { - if ($this->_foreignKey === null) { - $this->_foreignKey = $this->_modelKey($this->getTarget()->getAlias()); - } + $this->_foreignKey = $key; - return $this->_foreignKey; + return $this; } /** @@ -61,10 +73,10 @@ public function getForeignKey() * BelongsTo associations are never cleared in a cascading delete scenario. * * @param \Cake\Datasource\EntityInterface $entity The entity that started the cascaded delete. - * @param array $options The options for the original delete. + * @param array $options The options for the original delete. * @return bool Success. */ - public function cascadeDelete(EntityInterface $entity, array $options = []) + public function cascadeDelete(EntityInterface $entity, array $options = []): bool { return true; } @@ -74,22 +86,22 @@ public function cascadeDelete(EntityInterface $entity, array $options = []) * * @return string */ - protected function _propertyName() + protected function _propertyName(): string { - list(, $name) = pluginSplit($this->_name); + [, $name] = pluginSplit($this->_name); return Inflector::underscore(Inflector::singularize($name)); } /** - * Returns whether or not the passed table is the owning side for this + * Returns whether the passed table is the owning side for this * association. This means that rows in the 'target' table would miss important * or required information if the row in 'source' did not exist. * * @param \Cake\ORM\Table $side The potential Table with ownership * @return bool */ - public function isOwningSide(Table $side) + public function isOwningSide(Table $side): bool { return $side === $this->getTarget(); } @@ -99,7 +111,7 @@ public function isOwningSide(Table $side) * * @return string */ - public function type() + public function type(): string { return self::MANY_TO_ONE; } @@ -111,15 +123,15 @@ public function type() * `$options` * * @param \Cake\Datasource\EntityInterface $entity an entity from the source table - * @param array $options options to be passed to the save method in the target table - * @return bool|\Cake\Datasource\EntityInterface false if $entity could not be saved, otherwise it returns + * @param array $options options to be passed to the save method in the target table + * @return \Cake\Datasource\EntityInterface|false false if $entity could not be saved, otherwise it returns * the saved entity * @see \Cake\ORM\Table::save() */ - public function saveAssociated(EntityInterface $entity, array $options = []) + public function saveAssociated(EntityInterface $entity, array $options = []): EntityInterface|false { $targetEntity = $entity->get($this->getProperty()); - if (empty($targetEntity) || !($targetEntity instanceof EntityInterface)) { + if (!$targetEntity instanceof EntityInterface) { return $entity; } @@ -129,62 +141,26 @@ public function saveAssociated(EntityInterface $entity, array $options = []) return false; } + /** @var array $foreignKeys */ + $foreignKeys = (array)$this->getForeignKey(); $properties = array_combine( - (array)$this->getForeignKey(), - $targetEntity->extract((array)$this->getBindingKey()) + $foreignKeys, + $targetEntity->extract((array)$this->getBindingKey()), ); - $entity->set($properties, ['guard' => false]); - return $entity; - } - - /** - * Returns a single or multiple conditions to be appended to the generated join - * clause for getting the results on the target table. - * - * @param array $options list of options passed to attachTo method - * @return array - * @throws \RuntimeException if the number of columns in the foreignKey do not - * match the number of columns in the target table primaryKey - */ - protected function _joinCondition($options) - { - $conditions = []; - $tAlias = $this->_name; - $sAlias = $this->_sourceTable->getAlias(); - $foreignKey = (array)$options['foreignKey']; - $bindingKey = (array)$this->getBindingKey(); - - if (count($foreignKey) !== count($bindingKey)) { - if (empty($bindingKey)) { - $msg = 'The "%s" table does not define a primary key. Please set one.'; - throw new RuntimeException(sprintf($msg, $this->getTarget()->getTable())); - } - - $msg = 'Cannot match provided foreignKey for "%s", got "(%s)" but expected foreign key for "(%s)"'; - throw new RuntimeException(sprintf( - $msg, - $this->_name, - implode(', ', $foreignKey), - implode(', ', $bindingKey) - )); - } - - foreach ($foreignKey as $k => $f) { - $field = sprintf('%s.%s', $tAlias, $bindingKey[$k]); - $value = new IdentifierExpression(sprintf('%s.%s', $sAlias, $f)); - $conditions[$field] = $value; + if (method_exists($entity, 'patch')) { + $entity = $entity->patch($properties, ['guard' => false]); + } else { + $entity->set($properties, ['guard' => false]); } - return $conditions; + return $entity; } /** - * {@inheritDoc} - * - * @return \Closure + * @inheritDoc */ - public function eagerLoader(array $options) + public function eagerLoader(array $options): Closure { $loader = new SelectLoader([ 'alias' => $this->getAlias(), @@ -194,7 +170,7 @@ public function eagerLoader(array $options) 'bindingKey' => $this->getBindingKey(), 'strategy' => $this->getStrategy(), 'associationType' => $this->type(), - 'finder' => [$this, 'find'] + 'finder' => $this->find(...), ]); return $loader->buildEagerLoader($options); diff --git a/src/ORM/Association/BelongsToMany.php b/src/ORM/Association/BelongsToMany.php index 89e35eb6928..e6072495cf9 100644 --- a/src/ORM/Association/BelongsToMany.php +++ b/src/ORM/Association/BelongsToMany.php @@ -1,4 +1,6 @@ |string|null */ - protected $_targetForeignKey; + protected array|string|null $_targetForeignKey = null; /** * The table instance for the junction relation. * - * @var string|\Cake\ORM\Table + * @var \Cake\ORM\Table|string|null */ - protected $_through; + protected Table|string|null $_through = null; /** * Valid strategies for this type of association * - * @var array + * @var array */ - protected $_validStrategies = [ + protected array $_validStrategies = [ self::STRATEGY_SELECT, - self::STRATEGY_SUBQUERY + self::STRATEGY_SUBQUERY, ]; /** @@ -134,36 +140,36 @@ class BelongsToMany extends Association * * @var bool */ - protected $_dependent = true; + protected bool $_dependent = true; /** * Filtered conditions that reference the target table. * - * @var null|array + * @var array|null */ - protected $_targetConditions; + protected ?array $_targetConditions = null; /** * Filtered conditions that reference the junction table. * - * @var null|array + * @var array|null */ - protected $_junctionConditions; + protected ?array $_junctionConditions = null; /** * Order in which target records should be returned * - * @var mixed + * @var \Cake\Database\ExpressionInterface|\Closure|array<\Cake\Database\ExpressionInterface|string>|string|null */ - protected $_sort; + protected ExpressionInterface|Closure|array|string|null $_sort = null; /** * Sets the name of the field representing the foreign key to the target table. * - * @param string $key the key to be used to link both tables together + * @param array|string $key the key to be used to link both tables together * @return $this */ - public function setTargetForeignKey($key) + public function setTargetForeignKey(array|string $key) { $this->_targetForeignKey = $key; @@ -173,67 +179,40 @@ public function setTargetForeignKey($key) /** * Gets the name of the field representing the foreign key to the target table. * - * @return string + * @return array|string */ - public function getTargetForeignKey() + public function getTargetForeignKey(): array|string { - if ($this->_targetForeignKey === null) { - $this->_targetForeignKey = $this->_modelKey($this->getTarget()->getAlias()); - } - - return $this->_targetForeignKey; - } - - /** - * Sets the name of the field representing the foreign key to the target table. - * If no parameters are passed current field is returned - * - * @deprecated 3.4.0 Use setTargetForeignKey()/getTargetForeignKey() instead. - * @param string|null $key the key to be used to link both tables together - * @return string - */ - public function targetForeignKey($key = null) - { - if ($key !== null) { - $this->setTargetForeignKey($key); - } - - return $this->getTargetForeignKey(); + return $this->_targetForeignKey ??= $this->_modelKey($this->getTarget()->getAlias()); } /** * Whether this association can be expressed directly in a query join * - * @param array $options custom options key that could alter the return value + * @param array $options custom options key that could alter the return value * @return bool if the 'matching' key in $option is true then this function * will return true, false otherwise */ - public function canBeJoined(array $options = []) + public function canBeJoined(array $options = []): bool { return !empty($options['matching']); } /** - * Gets the name of the field representing the foreign key to the source table. - * - * @return string + * @inheritDoc */ - public function getForeignKey() + public function getForeignKey(): array|string|false { - if ($this->_foreignKey === null) { - $this->_foreignKey = $this->_modelKey($this->getSource()->getTable()); - } - - return $this->_foreignKey; + return $this->_foreignKey ??= $this->_modelKey($this->getSource()->getTable()); } /** * Sets the sort order in which target records should be returned. * - * @param mixed $sort A find() compatible order clause + * @param \Cake\Database\ExpressionInterface|\Closure|array<\Cake\Database\ExpressionInterface|string>|string $sort A find() compatible order clause * @return $this */ - public function setSort($sort) + public function setSort(ExpressionInterface|Closure|array|string $sort) { $this->_sort = $sort; @@ -243,34 +222,17 @@ public function setSort($sort) /** * Gets the sort order in which target records should be returned. * - * @return mixed + * @return \Cake\Database\ExpressionInterface|\Closure|array<\Cake\Database\ExpressionInterface|string>|string|null */ - public function getSort() + public function getSort(): ExpressionInterface|Closure|array|string|null { return $this->_sort; } /** - * Sets the sort order in which target records should be returned. - * If no arguments are passed the currently configured value is returned - * - * @deprecated 3.5.0 Use setSort()/getSort() instead. - * @param mixed $sort A find() compatible order clause - * @return mixed - */ - public function sort($sort = null) - { - if ($sort !== null) { - $this->setSort($sort); - } - - return $this->getSort(); - } - - /** - * {@inheritDoc} + * @inheritDoc */ - public function defaultRowValue($row, $joined) + public function defaultRowValue(array $row, bool $joined): array { $sourceAlias = $this->getSource()->getAlias(); if (isset($row[$sourceAlias])) { @@ -284,17 +246,18 @@ public function defaultRowValue($row, $joined) * Sets the table instance for the junction relation. If no arguments * are passed, the current configured table instance is returned * - * @param string|\Cake\ORM\Table|null $table Name or instance for the join table + * @param \Cake\ORM\Table|string|null $table Name or instance for the join table * @return \Cake\ORM\Table + * @throws \InvalidArgumentException If the expected associations are incompatible with existing associations. */ - public function junction($table = null) + public function junction(Table|string|null $table = null): Table { - if ($table === null && $this->_junctionTable) { + if ($table === null && isset($this->_junctionTable)) { return $this->_junctionTable; } $tableLocator = $this->getTableLocator(); - if ($table === null && $this->_through) { + if ($table === null && $this->_through !== null) { $table = $this->_through; } elseif ($table === null) { $tableName = $this->_junctionTableName(); @@ -302,7 +265,7 @@ public function junction($table = null) $config = []; if (!$tableLocator->exists($tableAlias)) { - $config = ['table' => $tableName]; + $config = ['table' => $tableName, 'allowFallbackClass' => true]; // Propagate the connection if we'll get an auto-model if (!App::className($tableAlias, 'Model/Table', 'Table')) { @@ -315,8 +278,16 @@ public function junction($table = null) if (is_string($table)) { $table = $tableLocator->get($table); } + $source = $this->getSource(); $target = $this->getTarget(); + if ($source->getAlias() === $target->getAlias()) { + throw new InvalidArgumentException(sprintf( + 'The `%s` association on `%s` cannot target the same table.', + $this->getName(), + $source->getAlias(), + )); + } $this->_generateSourceAssociations($table, $source); $this->_generateTargetAssociations($table, $source, $target); @@ -325,6 +296,29 @@ public function junction($table = null) return $this->_junctionTable = $table; } + /** + * Set the junction property name. + * + * @param string $junctionProperty Property name. + * @return $this + */ + public function setJunctionProperty(string $junctionProperty) + { + $this->_junctionProperty = $junctionProperty; + + return $this; + } + + /** + * Get the junction property naeme. + * + * @return string + */ + public function getJunctionProperty(): string + { + return $this->_junctionProperty; + } + /** * Generate reciprocal associations as necessary. * @@ -341,19 +335,26 @@ public function junction($table = null) * @param \Cake\ORM\Table $target The target table. * @return void */ - protected function _generateTargetAssociations($junction, $source, $target) + protected function _generateTargetAssociations(Table $junction, Table $source, Table $target): void { $junctionAlias = $junction->getAlias(); $sAlias = $source->getAlias(); + $tAlias = $target->getAlias(); - if (!$target->association($junctionAlias)) { + $targetBindingKey = null; + if ($junction->hasAssociation($tAlias)) { + $targetBindingKey = $junction->getAssociation($tAlias)->getBindingKey(); + } + + if (!$target->hasAssociation($junctionAlias)) { $target->hasMany($junctionAlias, [ 'targetTable' => $junction, + 'bindingKey' => $targetBindingKey, 'foreignKey' => $this->getTargetForeignKey(), 'strategy' => $this->_strategy, ]); } - if (!$target->association($sAlias)) { + if (!$target->hasAssociation($sAlias)) { $target->belongsToMany($sAlias, [ 'sourceTable' => $target, 'targetTable' => $source, @@ -380,12 +381,20 @@ protected function _generateTargetAssociations($junction, $source, $target) * @param \Cake\ORM\Table $source The source table. * @return void */ - protected function _generateSourceAssociations($junction, $source) + protected function _generateSourceAssociations(Table $junction, Table $source): void { $junctionAlias = $junction->getAlias(); - if (!$source->association($junctionAlias)) { + $sAlias = $source->getAlias(); + + $sourceBindingKey = null; + if ($junction->hasAssociation($sAlias)) { + $sourceBindingKey = $junction->getAssociation($sAlias)->getBindingKey(); + } + + if (!$source->hasAssociation($junctionAlias)) { $source->hasMany($junctionAlias, [ 'targetTable' => $junction, + 'bindingKey' => $sourceBindingKey, 'foreignKey' => $this->getForeignKey(), 'strategy' => $this->_strategy, ]); @@ -407,22 +416,36 @@ protected function _generateSourceAssociations($junction, $source) * @param \Cake\ORM\Table $source The source table. * @param \Cake\ORM\Table $target The target table. * @return void + * @throws \InvalidArgumentException If the expected associations are incompatible with existing associations. */ - protected function _generateJunctionAssociations($junction, $source, $target) + protected function _generateJunctionAssociations(Table $junction, Table $source, Table $target): void { $tAlias = $target->getAlias(); $sAlias = $source->getAlias(); - if (!$junction->association($tAlias)) { + if (!$junction->hasAssociation($tAlias)) { $junction->belongsTo($tAlias, [ 'foreignKey' => $this->getTargetForeignKey(), - 'targetTable' => $target + 'targetTable' => $target, ]); + } else { + $belongsTo = $junction->getAssociation($tAlias); + if ( + $this->getTargetForeignKey() !== $belongsTo->getForeignKey() || + $target !== $belongsTo->getTarget() + ) { + throw new InvalidArgumentException( + "The existing `{$tAlias}` association on `{$junction->getAlias()}` " . + "is incompatible with the `{$this->getName()}` association on `{$source->getAlias()}`", + ); + } } - if (!$junction->association($sAlias)) { + + if (!$junction->hasAssociation($sAlias)) { $junction->belongsTo($sAlias, [ + 'bindingKey' => $this->getBindingKey(), 'foreignKey' => $this->getForeignKey(), - 'targetTable' => $source + 'targetTable' => $source, ]); } } @@ -440,11 +463,11 @@ protected function _generateJunctionAssociations($junction, $source, $target) * - fields: a list of fields in the target table to include in the result * - type: The type of join to be used (e.g. INNER) * - * @param \Cake\ORM\Query $query the query to be altered to include the target table data - * @param array $options Any extra options or overrides to be taken in account + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query the query to be altered to include the target table data + * @param array $options Any extra options or overrides to be taken in account * @return void */ - public function attachTo(Query $query, array $options = []) + public function attachTo(SelectQuery $query, array $options = []): void { if (!empty($options['negateMatch'])) { $this->_appendNotMatching($query, $options); @@ -453,17 +476,14 @@ public function attachTo(Query $query, array $options = []) } $junction = $this->junction(); - $belongsTo = $junction->association($this->getSource()->getAlias()); + $belongsTo = $junction->getAssociation($this->getSource()->getAlias()); $cond = $belongsTo->_joinCondition(['foreignKey' => $belongsTo->getForeignKey()]); $cond += $this->junctionConditions(); - $includeFields = null; - if (isset($options['includeFields'])) { - $includeFields = $options['includeFields']; - } + $includeFields = $options['includeFields'] ?? null; - // Attach the junction table as well we need it to populate _joinData. - $assoc = $this->_targetTable->association($junction->getAlias()); + // Attach the junction table as well we need it to populate junction property (_joinData). + $assoc = $this->getTarget()->getAssociation($junction->getAlias()); $newOptions = array_intersect_key($options, ['joinType' => 1, 'fields' => 1]); $newOptions += [ 'conditions' => $cond, @@ -477,52 +497,51 @@ public function attachTo(Query $query, array $options = []) $foreignKey = $this->getTargetForeignKey(); $thisJoin = $query->clause('join')[$this->getName()]; - $thisJoin['conditions']->add($assoc->_joinCondition(['foreignKey' => $foreignKey])); + /** @var \Cake\Database\Expression\QueryExpression $conditions */ + $conditions = $thisJoin['conditions']; + $conditions->add($assoc->_joinCondition(['foreignKey' => $foreignKey])); } /** - * {@inheritDoc} + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query The query to append to. + * @param array $options The options for not matching. + * @return void */ - protected function _appendNotMatching($query, $options) + protected function _appendNotMatching(SelectQuery $query, array $options): void { if (empty($options['negateMatch'])) { return; } - if (!isset($options['conditions'])) { - $options['conditions'] = []; - } + $options['conditions'] ??= []; $junction = $this->junction(); - $belongsTo = $junction->association($this->getSource()->getAlias()); + $belongsTo = $junction->getAssociation($this->getSource()->getAlias()); $conds = $belongsTo->_joinCondition(['foreignKey' => $belongsTo->getForeignKey()]); $subquery = $this->find() ->select(array_values($conds)) - ->where($options['conditions']) - ->andWhere($this->junctionConditions()); + ->where($options['conditions']); if (!empty($options['queryBuilder'])) { + assert(is_callable($options['queryBuilder'])); + /** @var \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $subquery */ $subquery = $options['queryBuilder']($subquery); } - $assoc = $junction->association($this->getTarget()->getAlias()); - $conditions = $assoc->_joinCondition([ - 'foreignKey' => $this->getTargetForeignKey() - ]); - $subquery = $this->_appendJunctionJoin($subquery, $conditions); + $subquery = $this->_appendJunctionJoin($subquery); $query - ->andWhere(function ($exp) use ($subquery, $conds) { + ->andWhere(function (QueryExpression $exp) use ($subquery, $conds) { $identifiers = []; foreach (array_keys($conds) as $field) { $identifiers[] = new IdentifierExpression($field); } - $identifiers = $subquery->newExpr()->add($identifiers)->setConjunction(','); + $identifiers = $subquery->expr()->add($identifiers)->setConjunction(','); $nullExp = clone $exp; return $exp - ->or_([ + ->or([ $exp->notIn($identifiers, $subquery), - $nullExp->and(array_map([$nullExp, 'isNull'], array_keys($conds))), + $nullExp->and(array_map($nullExp->isNull(...), array_keys($conds))), ]); }); } @@ -532,7 +551,7 @@ protected function _appendNotMatching($query, $options) * * @return string */ - public function type() + public function type(): string { return self::MANY_TO_MANY; } @@ -540,20 +559,18 @@ public function type() /** * Return false as join conditions are defined in the junction table * - * @param array $options list of options passed to attachTo method - * @return bool false + * @param array $options list of options passed to attachTo method + * @return array */ - protected function _joinCondition($options) + protected function _joinCondition(array $options): array { - return false; + return []; } /** - * {@inheritDoc} - * - * @return \Closure + * @inheritDoc */ - public function eagerLoader(array $options) + public function eagerLoader(array $options): Closure { $name = $this->_junctionAssociationName(); $loader = new SelectWithPivotLoader([ @@ -567,11 +584,11 @@ public function eagerLoader(array $options) 'sort' => $this->getSort(), 'junctionAssociationName' => $name, 'junctionProperty' => $this->_junctionProperty, - 'junctionAssoc' => $this->getTarget()->association($name), + 'junctionAssoc' => $this->getTarget()->getAssociation($name), 'junctionConditions' => $this->junctionConditions(), 'finder' => function () { return $this->_appendJunctionJoin($this->find(), []); - } + }, ]); return $loader->buildEagerLoader($options); @@ -581,33 +598,44 @@ public function eagerLoader(array $options) * Clear out the data in the junction table for a given entity. * * @param \Cake\Datasource\EntityInterface $entity The entity that started the cascading delete. - * @param array $options The options for the original delete. + * @param array $options The options for the original delete. * @return bool Success. */ - public function cascadeDelete(EntityInterface $entity, array $options = []) + public function cascadeDelete(EntityInterface $entity, array $options = []): bool { if (!$this->getDependent()) { return true; } - $foreignKey = (array)$this->getForeignKey(); - $bindingKey = (array)$this->getBindingKey(); + + /** @var array $foreignKeys */ + $foreignKeys = (array)$this->getForeignKey(); + $bindingKeys = (array)$this->getBindingKey(); $conditions = []; - if (!empty($bindingKey)) { - $conditions = array_combine($foreignKey, $entity->extract($bindingKey)); + if ($bindingKeys) { + $conditions = array_combine($foreignKeys, $entity->extract($bindingKeys)); } $table = $this->junction(); - $hasMany = $this->getSource()->association($table->getAlias()); + $hasMany = $this->getSource()->getAssociation($table->getAlias()); if ($this->_cascadeCallbacks) { - foreach ($hasMany->find('all')->where($conditions)->all()->toList() as $related) { - $table->delete($related, $options); + /** @var \Cake\Datasource\EntityInterface $related */ + foreach ($hasMany->find('all')->where($conditions)->toArray() as $related) { + $success = $table->delete($related, $options); + if (!$success) { + return false; + } } return true; } - $conditions = array_merge($conditions, $hasMany->getConditions()); + $assocConditions = $hasMany->getConditions(); + if (is_array($assocConditions)) { + $conditions = array_merge($conditions, $assocConditions); + } else { + $conditions[] = $assocConditions; + } $table->deleteAll($conditions); @@ -621,7 +649,7 @@ public function cascadeDelete(EntityInterface $entity, array $options = []) * @param \Cake\ORM\Table $side The potential Table with ownership * @return bool */ - public function isOwningSide(Table $side) + public function isOwningSide(Table $side): bool { return true; } @@ -633,10 +661,10 @@ public function isOwningSide(Table $side) * @throws \InvalidArgumentException if an invalid strategy name is passed * @return $this */ - public function setSaveStrategy($strategy) + public function setSaveStrategy(string $strategy) { - if (!in_array($strategy, [self::SAVE_APPEND, self::SAVE_REPLACE])) { - $msg = sprintf('Invalid save strategy "%s"', $strategy); + if (!in_array($strategy, [self::SAVE_APPEND, self::SAVE_REPLACE], true)) { + $msg = sprintf('Invalid save strategy `%s`', $strategy); throw new InvalidArgumentException($msg); } @@ -650,29 +678,11 @@ public function setSaveStrategy($strategy) * * @return string the strategy to be used for saving */ - public function getSaveStrategy() + public function getSaveStrategy(): string { return $this->_saveStrategy; } - /** - * Sets the strategy that should be used for saving. If called with no - * arguments, it will return the currently configured strategy - * - * @deprecated 3.4.0 Use setSaveStrategy()/getSaveStrategy() instead. - * @param string|null $strategy the strategy name to be used - * @throws \InvalidArgumentException if an invalid strategy name is passed - * @return string the strategy to be used for saving - */ - public function saveStrategy($strategy = null) - { - if ($strategy !== null) { - $this->setSaveStrategy($strategy); - } - - return $this->getSaveStrategy(); - } - /** * Takes an entity from the source table and looks if there is a field * matching the property name for this association. The found entity will be @@ -689,15 +699,15 @@ public function saveStrategy($strategy = null) * not deleted. * * @param \Cake\Datasource\EntityInterface $entity an entity from the source table - * @param array $options options to be passed to the save method in the target table + * @param array $options options to be passed to the save method in the target table * @throws \InvalidArgumentException if the property representing the association * in the parent entity cannot be traversed - * @return bool|\Cake\Datasource\EntityInterface false if $entity could not be saved, otherwise it returns + * @return \Cake\Datasource\EntityInterface|false false if $entity could not be saved, otherwise it returns * the saved entity * @see \Cake\ORM\Table::save() * @see \Cake\ORM\Association\BelongsToMany::replaceLinks() */ - public function saveAssociated(EntityInterface $entity, array $options = []) + public function saveAssociated(EntityInterface $entity, array $options = []): EntityInterface|false { $targetEntity = $entity->get($this->getProperty()); $strategy = $this->getSaveStrategy(); @@ -727,26 +737,25 @@ public function saveAssociated(EntityInterface $entity, array $options = []) * * @param \Cake\Datasource\EntityInterface $parentEntity the source entity containing the target * entities to be saved. - * @param array|\Traversable $entities list of entities to persist in target table and to + * @param array $entities list of entities to persist in target table and to * link to the parent entity - * @param array $options list of options accepted by `Table::save()` + * @param array $options list of options accepted by `Table::save()` * @throws \InvalidArgumentException if the property representing the association * in the parent entity cannot be traversed - * @return \Cake\Datasource\EntityInterface|bool The parent entity after all links have been + * @return \Cake\Datasource\EntityInterface|false The parent entity after all links have been * created if no errors happened, false otherwise */ - protected function _saveTarget(EntityInterface $parentEntity, $entities, $options) - { + protected function _saveTarget( + EntityInterface $parentEntity, + array $entities, + array $options, + ): EntityInterface|false { $joinAssociations = false; - if (!empty($options['associated'][$this->_junctionProperty]['associated'])) { - $joinAssociations = $options['associated'][$this->_junctionProperty]['associated']; - } - unset($options['associated'][$this->_junctionProperty]); - - if (!(is_array($entities) || $entities instanceof Traversable)) { - $name = $this->getProperty(); - $message = sprintf('Could not save %s, it cannot be traversed', $name); - throw new InvalidArgumentException($message); + if (isset($options['associated']) && is_array($options['associated'])) { + if (!empty($options['associated'][$this->_junctionProperty]['associated'])) { + $joinAssociations = $options['associated'][$this->_junctionProperty]['associated']; + } + unset($options['associated'][$this->_junctionProperty]); } $table = $this->getTarget(); @@ -772,11 +781,12 @@ protected function _saveTarget(EntityInterface $parentEntity, $entities, $option // Saving the new linked entity failed, copy errors back into the // original entity if applicable and abort. if (!empty($options['atomic'])) { - $original[$k]->errors($entity->errors()); - } - if (!$saved) { - return false; + /** @var \Cake\Datasource\EntityInterface $originalEntity */ + $originalEntity = $original[$k]; + $originalEntity->setErrors($entity->getErrors()); } + + return false; } $options['associated'] = $joinAssociations; @@ -797,44 +807,49 @@ protected function _saveTarget(EntityInterface $parentEntity, $entities, $option * * @param \Cake\Datasource\EntityInterface $sourceEntity the entity from source table in this * association - * @param array $targetEntities list of entities to link to link to the source entity using the + * @param array<\Cake\Datasource\EntityInterface> $targetEntities list of entities to link to link to the source entity using the * junction table - * @param array $options list of options accepted by `Table::save()` + * @param array $options list of options accepted by `Table::save()` * @return bool success */ - protected function _saveLinks(EntityInterface $sourceEntity, $targetEntities, $options) + protected function _saveLinks(EntityInterface $sourceEntity, array $targetEntities, array $options): bool { $target = $this->getTarget(); $junction = $this->junction(); $entityClass = $junction->getEntityClass(); - $belongsTo = $junction->association($target->getAlias()); + $belongsTo = $junction->getAssociation($target->getAlias()); + /** @var array $foreignKey */ $foreignKey = (array)$this->getForeignKey(); + /** @var array $assocForeignKey */ $assocForeignKey = (array)$belongsTo->getForeignKey(); - $targetPrimaryKey = (array)$target->getPrimaryKey(); + $targetBindingKey = (array)$belongsTo->getBindingKey(); $bindingKey = (array)$this->getBindingKey(); $jointProperty = $this->_junctionProperty; - $junctionAlias = $junction->getAlias(); + $junctionRegistryAlias = $junction->getRegistryAlias(); foreach ($targetEntities as $e) { $joint = $e->get($jointProperty); - if (!$joint || !($joint instanceof EntityInterface)) { - $joint = new $entityClass([], ['markNew' => true, 'source' => $junctionAlias]); + if (!($joint instanceof EntityInterface)) { + $joint = new $entityClass([], ['markNew' => true, 'source' => $junctionRegistryAlias]); } $sourceKeys = array_combine($foreignKey, $sourceEntity->extract($bindingKey)); - $targetKeys = array_combine($assocForeignKey, $e->extract($targetPrimaryKey)); + $targetKeys = array_combine($assocForeignKey, $e->extract($targetBindingKey)); + + $changedKeys = $sourceKeys !== $joint->extract($foreignKey) || + $targetKeys !== $joint->extract($assocForeignKey); - $changedKeys = ( - $sourceKeys !== $joint->extract($foreignKey) || - $targetKeys !== $joint->extract($assocForeignKey) - ); // Keys were changed, the junction table record _could_ be // new. By clearing the primary key values, and marking the entity - // as new, we let save() sort out whether or not we have a new link + // as new, we let save() sort out whether we have a new link // or if we are updating an existing link. if ($changedKeys) { - $joint->isNew(true); - $joint->unsetProperty($junction->getPrimaryKey()) - ->set(array_merge($sourceKeys, $targetKeys), ['guard' => false]); + $joint->setNew(true); + $joint->unset($junction->getPrimaryKey()); + if (method_exists($joint, 'patch')) { + $joint->patch(array_merge($sourceKeys, $targetKeys), ['guard' => false]); + } else { + $joint->set(array_merge($sourceKeys, $targetKeys), ['guard' => false]); + } } $saved = $junction->save($joint, $options); @@ -864,21 +879,21 @@ protected function _saveLinks(EntityInterface $sourceEntity, $targetEntities, $o * * ``` * $newTags = $tags->find('relevant')->toArray(); - * $articles->association('tags')->link($article, $newTags); + * $articles->getAssociation('tags')->link($article, $newTags); * ``` * * `$article->get('tags')` will contain all tags in `$newTags` after liking * * @param \Cake\Datasource\EntityInterface $sourceEntity the row belonging to the `source` side * of this association - * @param array $targetEntities list of entities belonging to the `target` side + * @param array<\Cake\Datasource\EntityInterface> $targetEntities list of entities belonging to the `target` side * of this association - * @param array $options list of options to be passed to the internal `save` call + * @param array $options list of options to be passed to the internal `save` call * @throws \InvalidArgumentException when any of the values in $targetEntities is * detected to not be already persisted * @return bool true on success, false otherwise */ - public function link(EntityInterface $sourceEntity, array $targetEntities, array $options = []) + public function link(EntityInterface $sourceEntity, array $targetEntities, array $options = []): bool { $this->_checkPersistenceStatus($sourceEntity, $targetEntities); $property = $this->getProperty(); @@ -889,7 +904,7 @@ public function link(EntityInterface $sourceEntity, array $targetEntities, array return $this->junction()->getConnection()->transactional( function () use ($sourceEntity, $targetEntities, $options) { return $this->_saveLinks($sourceEntity, $targetEntities, $options); - } + }, ); } @@ -903,7 +918,7 @@ function () use ($sourceEntity, $targetEntities, $options) { * Additionally to the default options accepted by `Table::delete()`, the following * keys are supported: * - * - cleanProperty: Whether or not to remove all the objects in `$targetEntities` that + * - cleanProperty: Whether to remove all the objects in `$targetEntities` that * are stored in `$sourceEntity` (default: true) * * By default this method will unset each of the entity objects stored inside the @@ -914,26 +929,26 @@ function () use ($sourceEntity, $targetEntities, $options) { * ``` * $article->tags = [$tag1, $tag2, $tag3, $tag4]; * $tags = [$tag1, $tag2, $tag3]; - * $articles->association('tags')->unlink($article, $tags); + * $articles->getAssociation('tags')->unlink($article, $tags); * ``` * * `$article->get('tags')` will contain only `[$tag4]` after deleting in the database * - * @param \Cake\Datasource\EntityInterface $sourceEntity an entity persisted in the source table for - * this association - * @param array $targetEntities list of entities persisted in the target table for - * this association - * @param array|bool $options list of options to be passed to the internal `delete` call, - * or a `boolean` - * @throws \InvalidArgumentException if non persisted entities are passed or if - * any of them is lacking a primary key value + * @param \Cake\Datasource\EntityInterface $sourceEntity An entity persisted in the source table for + * this association. + * @param array<\Cake\Datasource\EntityInterface> $targetEntities List of entities persisted in the target table for + * this association. + * @param array|bool $options List of options to be passed to the internal `delete` call, + * or a `boolean` as `cleanProperty` key shortcut. + * @throws \InvalidArgumentException If non-persisted entities are passed or if + * any of them is lacking a primary key value. * @return bool Success */ - public function unlink(EntityInterface $sourceEntity, array $targetEntities, $options = []) + public function unlink(EntityInterface $sourceEntity, array $targetEntities, array|bool $options = []): bool { if (is_bool($options)) { $options = [ - 'cleanProperty' => $options + 'cleanProperty' => $options, ]; } else { $options += ['cleanProperty' => true]; @@ -942,27 +957,26 @@ public function unlink(EntityInterface $sourceEntity, array $targetEntities, $op $this->_checkPersistenceStatus($sourceEntity, $targetEntities); $property = $this->getProperty(); - $this->junction()->getConnection()->transactional( - function () use ($sourceEntity, $targetEntities, $options) { - $links = $this->_collectJointEntities($sourceEntity, $targetEntities); - foreach ($links as $entity) { - $this->_junctionTable->delete($entity, $options); - } - } - ); + $links = $this->_collectJointEntities($sourceEntity, $targetEntities); + $return = $this->_junctionTable->deleteMany($links, $options); + if ($return === false) { + return false; + } + /** @var array<\Cake\Datasource\EntityInterface> $existing */ $existing = $sourceEntity->get($property) ?: []; if (!$options['cleanProperty'] || empty($existing)) { return true; } + /** @var \SplObjectStorage<\Cake\Datasource\EntityInterface, null> $storage */ $storage = new SplObjectStorage(); foreach ($targetEntities as $e) { - $storage->attach($e); + $storage->offsetSet($e); } foreach ($existing as $k => $e) { - if ($storage->contains($e)) { + if ($storage->offsetExists($e)) { unset($existing[$k]); } } @@ -974,12 +988,13 @@ function () use ($sourceEntity, $targetEntities, $options) { } /** - * {@inheritDoc} + * @inheritDoc */ - public function setConditions($conditions) + public function setConditions(Closure|array $conditions) { parent::setConditions($conditions); - $this->_targetConditions = $this->_junctionConditions = null; + $this->_targetConditions = null; + $this->_junctionConditions = null; return $this; } @@ -987,10 +1002,10 @@ public function setConditions($conditions) /** * Sets the current join table, either the name of the Table instance or the instance itself. * - * @param string|\Cake\ORM\Table $through Name of the Table instance or the instance itself + * @param \Cake\ORM\Table|string $through Name of the Table instance or the instance itself * @return $this */ - public function setThrough($through) + public function setThrough(Table|string $through) { $this->_through = $through; @@ -999,10 +1014,11 @@ public function setThrough($through) /** * Gets the current join table, either the name of the Table instance or the instance itself. + * Returns null if not defined. * - * @return string|\Cake\ORM\Table + * @return \Cake\ORM\Table|string|null */ - public function getThrough() + public function getThrough(): Table|string|null { return $this->_through; } @@ -1013,11 +1029,11 @@ public function getThrough() * Any string expressions, or expression objects will * also be returned in this list. * - * @return mixed Generally an array. If the conditions + * @return \Closure|array|null Generally an array. If the conditions * are not an array, the association conditions will be * returned unmodified. */ - protected function targetConditions() + protected function targetConditions(): mixed { if ($this->_targetConditions !== null) { return $this->_targetConditions; @@ -1029,7 +1045,7 @@ protected function targetConditions() $matching = []; $alias = $this->getAlias() . '.'; foreach ($conditions as $field => $value) { - if (is_string($field) && strpos($field, $alias) === 0) { + if (is_string($field) && str_starts_with($field, $alias)) { $matching[$field] = $value; } elseif (is_int($field) || $value instanceof ExpressionInterface) { $matching[$field] = $value; @@ -1045,7 +1061,7 @@ protected function targetConditions() * * @return array */ - protected function junctionConditions() + protected function junctionConditions(): array { if ($this->_junctionConditions !== null) { return $this->_junctionConditions; @@ -1058,12 +1074,12 @@ protected function junctionConditions() $alias = $this->_junctionAssociationName() . '.'; foreach ($conditions as $field => $value) { $isString = is_string($field); - if ($isString && strpos($field, $alias) === 0) { + if ($isString && str_starts_with($field, $alias)) { $matching[$field] = $value; } // Assume that operators contain junction conditions. // Trying to manage complex conditions could result in incorrect queries. - if ($isString && in_array(strtoupper($field), ['OR', 'NOT', 'AND', 'XOR'])) { + if ($isString && in_array(strtoupper($field), ['OR', 'NOT', 'AND', 'XOR'], true)) { $matching[$field] = $value; } } @@ -1076,59 +1092,65 @@ protected function junctionConditions() * and modifies the query accordingly based of this association * configuration. * - * If your association includes conditions, the junction table will be + * If your association includes conditions or a finder, the junction table will be * included in the query's contained associations. * - * @param string|array|null $type the type of query to perform, if an array is passed, + * @param array|string|null $type the type of query to perform, if an array is passed, * it will be interpreted as the `$options` parameter - * @param array $options The options to for the find + * @param mixed ...$args Arguments that match up to finder-specific parameters * @see \Cake\ORM\Table::find() - * @return \Cake\ORM\Query + * @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> */ - public function find($type = null, array $options = []) + public function find(array|string|null $type = null, mixed ...$args): SelectQuery { $type = $type ?: $this->getFinder(); - list($type, $opts) = $this->_extractFinder($type); + [$type, $opts] = $this->_extractFinder($type); + + $args += $opts; + $query = $this->getTarget() - ->find($type, $options + $opts) + ->find($type, ...$args) ->where($this->targetConditions()) ->addDefaultTypes($this->getTarget()); - if (!$this->junctionConditions()) { - return $query; + if ($this->junctionConditions()) { + return $this->_appendJunctionJoin($query); } - $belongsTo = $this->junction()->association($this->getTarget()->getAlias()); - $conditions = $belongsTo->_joinCondition([ - 'foreignKey' => $this->getTargetForeignKey() - ]); - $conditions += $this->junctionConditions(); - - return $this->_appendJunctionJoin($query, $conditions); + return $query; } /** * Append a join to the junction table. * - * @param \Cake\ORM\Query $query The query to append. - * @param string|array $conditions The query conditions to use. - * @return \Cake\ORM\Query The modified query. + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query The query to append. + * @param array|null $conditions The query conditions to use. + * @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> The modified query. */ - protected function _appendJunctionJoin($query, $conditions) + protected function _appendJunctionJoin(SelectQuery $query, ?array $conditions = null): SelectQuery { + $junctionTable = $this->junction(); + if ($conditions === null) { + $belongsTo = $junctionTable->getAssociation($this->getTarget()->getAlias()); + $conditions = $belongsTo->_joinCondition([ + 'foreignKey' => $this->getTargetForeignKey(), + ]); + $conditions += $this->junctionConditions(); + } + $name = $this->_junctionAssociationName(); - $joins = $query->join(); + $joins = $query->clause('join'); + assert(is_array($joins)); $matching = [ $name => [ - 'table' => $this->junction()->getTable(), + 'table' => $junctionTable->getTable(), 'conditions' => $conditions, - 'type' => QueryInterface::JOIN_TYPE_INNER - ] + 'type' => SelectQuery::JOIN_TYPE_INNER, + ], ]; - $assoc = $this->getTarget()->association($name); $query - ->addDefaultTypes($assoc->getTarget()) + ->addDefaultTypes($junctionTable) ->join($matching + $joins, [], true); return $query; @@ -1169,7 +1191,7 @@ protected function _appendJunctionJoin($query, $conditions) * $article->tags = [$tag1, $tag2, $tag3, $tag4]; * $articles->save($article); * $tags = [$tag1, $tag3]; - * $articles->association('tags')->replaceLinks($article, $tags); + * $articles->getAssociation('tags')->replaceLinks($article, $tags); * ``` * * `$article->get('tags')` will contain only `[$tag1, $tag3]` at the end @@ -1177,37 +1199,64 @@ protected function _appendJunctionJoin($query, $conditions) * @param \Cake\Datasource\EntityInterface $sourceEntity an entity persisted in the source table for * this association * @param array $targetEntities list of entities from the target table to be linked - * @param array $options list of options to be passed to the internal `save`/`delete` calls + * @param array $options list of options to be passed to the internal `save`/`delete` calls * when persisting/updating new links, or deleting existing ones * @throws \InvalidArgumentException if non persisted entities are passed or if * any of them is lacking a primary key value * @return bool success */ - public function replaceLinks(EntityInterface $sourceEntity, array $targetEntities, array $options = []) + public function replaceLinks(EntityInterface $sourceEntity, array $targetEntities, array $options = []): bool { $bindingKey = (array)$this->getBindingKey(); $primaryValue = $sourceEntity->extract($bindingKey); - if (count(array_filter($primaryValue, 'strlen')) !== count($bindingKey)) { + if (count(Hash::filter($primaryValue)) !== count($bindingKey)) { $message = 'Could not find primary key value for source entity'; throw new InvalidArgumentException($message); } return $this->junction()->getConnection()->transactional( function () use ($sourceEntity, $targetEntities, $primaryValue, $options) { - $foreignKey = array_map([$this->_junctionTable, 'aliasField'], (array)$this->getForeignKey()); - $hasMany = $this->getSource()->association($this->_junctionTable->getAlias()); - $existing = $hasMany->find('all') - ->where(array_combine($foreignKey, $primaryValue)); - - $associationConditions = $this->getConditions(); - if ($associationConditions) { - $existing->contain($this->getTarget()->getAlias()); - $existing->andWhere($associationConditions); + $junction = $this->junction(); + $target = $this->getTarget(); + + /** @var array $foreignKey */ + $foreignKey = (array)$this->getForeignKey(); + $assocForeignKey = (array)$junction->getAssociation($target->getAlias())->getForeignKey(); + $prefixedForeignKey = array_map($junction->aliasField(...), $foreignKey); + + $junctionPrimaryKey = (array)$junction->getPrimaryKey(); + $junctionQueryAlias = $junction->getAlias() . '__matches'; + $keys = []; + $matchesConditions = []; + /** @var string $key */ + foreach (array_merge($assocForeignKey, $junctionPrimaryKey) as $key) { + $aliased = $junction->aliasField($key); + $keys[$key] = $aliased; + $matchesConditions[$aliased] = new IdentifierExpression($junctionQueryAlias . '.' . $key); } + // Use association to create row selection + // with finders & association conditions. + $matches = $this->_appendJunctionJoin($this->find()) + ->select($keys) + ->where(array_combine($prefixedForeignKey, $primaryValue)); + + // Create a subquery join to ensure we get + // the correct entity passed to callbacks. + /** @var \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $existing */ + $existing = $junction->selectQuery() + ->from([$junctionQueryAlias => $matches]) + ->innerJoin( + [$junction->getAlias() => $junction->getTable()], + $matchesConditions, + ); + $jointEntities = $this->_collectJointEntities($sourceEntity, $targetEntities); $inserts = $this->_diffLinks($existing, $jointEntities, $targetEntities, $options); + if ($inserts === false) { + return false; + } if ($inserts && !$this->_saveTarget($sourceEntity, $inserts, $options)) { return false; @@ -1215,11 +1264,11 @@ function () use ($sourceEntity, $targetEntities, $primaryValue, $options) { $property = $this->getProperty(); - if (count($inserts)) { + if ($inserts !== []) { $inserted = array_combine( array_keys($inserts), - (array)$sourceEntity->get($property) - ); + (array)$sourceEntity->get($property), + ) ?: []; $targetEntities = $inserted + $targetEntities; } @@ -1228,7 +1277,7 @@ function () use ($sourceEntity, $targetEntities, $primaryValue, $options) { $sourceEntity->setDirty($property, false); return true; - } + }, ); } @@ -1237,42 +1286,67 @@ function () use ($sourceEntity, $targetEntities, $primaryValue, $options) { * `$existing` and `$jointEntities`. This method will return the values from * `$targetEntities` that were not deleted from calculating the difference. * - * @param \Cake\ORM\Query $existing a query for getting existing links - * @param array $jointEntities link entities that should be persisted + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $existing a query for getting existing links + * @param array<\Cake\Datasource\EntityInterface> $jointEntities link entities that should be persisted * @param array $targetEntities entities in target table that are related to * the `$jointEntities` - * @param array $options list of options accepted by `Table::delete()` - * @return array + * @param array $options list of options accepted by `Table::delete()` + * @return array|false Array of entities not deleted or false in case of deletion failure for atomic saves. */ - protected function _diffLinks($existing, $jointEntities, $targetEntities, $options = []) - { + protected function _diffLinks( + SelectQuery $existing, + array $jointEntities, + array $targetEntities, + array $options = [], + ): array|false { $junction = $this->junction(); $target = $this->getTarget(); - $belongsTo = $junction->association($target->getAlias()); + $belongsTo = $junction->getAssociation($target->getAlias()); + /** @var array $foreignKey */ $foreignKey = (array)$this->getForeignKey(); + /** @var array $assocForeignKey */ $assocForeignKey = (array)$belongsTo->getForeignKey(); $keys = array_merge($foreignKey, $assocForeignKey); - $deletes = $indexed = $present = []; + $deletes = []; + $unmatchedEntityKeys = []; + $present = []; foreach ($jointEntities as $i => $entity) { - $indexed[$i] = $entity->extract($keys); + $unmatchedEntityKeys[$i] = $entity->extract($keys); $present[$i] = array_values($entity->extract($assocForeignKey)); } - foreach ($existing as $result) { - $fields = $result->extract($keys); + foreach ($existing as $existingLink) { + /** @var \Cake\ORM\Entity $existingLink */ + $existingKeys = $existingLink->extract($keys); $found = false; - foreach ($indexed as $i => $data) { - if ($fields === $data) { - unset($indexed[$i]); + foreach ($unmatchedEntityKeys as $i => $unmatchedKeys) { + $matched = false; + foreach ($keys as $key) { + if (is_object($unmatchedKeys[$key]) && is_object($existingKeys[$key])) { + // If both sides are an object then use == so that value objects + // are seen as equivalent. + $matched = $existingKeys[$key] == $unmatchedKeys[$key]; + } else { + // Use strict equality for all other values. + $matched = $existingKeys[$key] === $unmatchedKeys[$key]; + } + // Stop checks on first failure. + if (!$matched) { + break; + } + } + if ($matched) { + // Remove the unmatched entity so we don't look at it again. + unset($unmatchedEntityKeys[$i]); $found = true; break; } } if (!$found) { - $deletes[] = $result; + $deletes[] = $existingLink; } } @@ -1291,9 +1365,9 @@ protected function _diffLinks($existing, $jointEntities, $targetEntities, $optio } } - if ($deletes) { - foreach ($deletes as $entity) { - $junction->delete($entity, $options); + foreach ($deletes as $entity) { + if (!$junction->delete($entity, $options) && !empty($options['atomic'])) { + return false; } } @@ -1305,12 +1379,12 @@ protected function _diffLinks($existing, $jointEntities, $targetEntities, $optio * * @param \Cake\Datasource\EntityInterface $sourceEntity the row belonging to the `source` side * of this association - * @param array $targetEntities list of entities belonging to the `target` side + * @param array<\Cake\Datasource\EntityInterface> $targetEntities list of entities belonging to the `target` side * of this association * @return bool * @throws \InvalidArgumentException */ - protected function _checkPersistenceStatus($sourceEntity, array $targetEntities) + protected function _checkPersistenceStatus(EntityInterface $sourceEntity, array $targetEntities): bool { if ($sourceEntity->isNew()) { $error = 'Source entity needs to be persisted before links can be created or removed.'; @@ -1337,9 +1411,9 @@ protected function _checkPersistenceStatus($sourceEntity, array $targetEntities) * association. * @throws \InvalidArgumentException if any of the entities is lacking a primary * key value - * @return array + * @return array<\Cake\Datasource\EntityInterface> */ - protected function _collectJointEntities($sourceEntity, $targetEntities) + protected function _collectJointEntities(EntityInterface $sourceEntity, array $targetEntities): array { $target = $this->getTarget(); $source = $this->getSource(); @@ -1356,7 +1430,7 @@ protected function _collectJointEntities($sourceEntity, $targetEntities) } $joint = $entity->get($jointProperty); - if (!$joint || !($joint instanceof EntityInterface)) { + if (!($joint instanceof EntityInterface)) { $missing[] = $entity->extract($primary); continue; } @@ -1364,20 +1438,32 @@ protected function _collectJointEntities($sourceEntity, $targetEntities) $result[] = $joint; } - if (empty($missing)) { + if (!$missing) { return $result; } - $belongsTo = $junction->association($target->getAlias()); - $hasMany = $source->association($junction->getAlias()); + $belongsTo = $junction->getAssociation($target->getAlias()); + $hasMany = $source->getAssociation($junction->getAlias()); + /** @var array $foreignKey */ $foreignKey = (array)$this->getForeignKey(); + $foreignKey = array_map(function (string $key) { + return $key . ' IS'; + }, $foreignKey); + /** @var array $assocForeignKey */ $assocForeignKey = (array)$belongsTo->getForeignKey(); + $assocForeignKey = array_map(function (string $key) { + return $key . ' IS'; + }, $assocForeignKey); $sourceKey = $sourceEntity->extract((array)$source->getPrimaryKey()); + $unions = []; foreach ($missing as $key) { - $unions[] = $hasMany->find('all') + /** @var \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface> $unionQuery */ + $unionQuery = $hasMany->find() ->where(array_combine($foreignKey, $sourceKey)) - ->andWhere(array_combine($assocForeignKey, $key)); + ->where(array_combine($assocForeignKey, $key)); + + $unions[] = $unionQuery; } $query = array_shift($unions); @@ -1395,11 +1481,11 @@ protected function _collectJointEntities($sourceEntity, $targetEntities) * * @return string */ - protected function _junctionAssociationName() + protected function _junctionAssociationName(): string { - if (!$this->_junctionAssociationName) { + if (!isset($this->_junctionAssociationName)) { $this->_junctionAssociationName = $this->getTarget() - ->association($this->junction()->getAlias()) + ->getAssociation($this->junction()->getAlias()) ->getName(); } @@ -1414,13 +1500,13 @@ protected function _junctionAssociationName() * @param string|null $name The name of the junction table. * @return string */ - protected function _junctionTableName($name = null) + protected function _junctionTableName(?string $name = null): string { if ($name === null) { if (empty($this->_junctionTableName)) { - $tablesNames = array_map('\Cake\Utility\Inflector::underscore', [ + $tablesNames = array_map('Cake\Utility\Inflector::underscore', [ $this->getSource()->getTable(), - $this->getTarget()->getTable() + $this->getTarget()->getTable(), ]); sort($tablesNames); $this->_junctionTableName = implode('_', $tablesNames); @@ -1435,25 +1521,30 @@ protected function _junctionTableName($name = null) /** * Parse extra options passed in the constructor. * - * @param array $opts original list of options passed in constructor + * @param array $options original list of options passed in constructor * @return void */ - protected function _options(array $opts) + protected function _options(array $options): void { - if (!empty($opts['targetForeignKey'])) { - $this->setTargetForeignKey($opts['targetForeignKey']); + if (!empty($options['targetForeignKey'])) { + $this->setTargetForeignKey($options['targetForeignKey']); } - if (!empty($opts['joinTable'])) { - $this->_junctionTableName($opts['joinTable']); + if (!empty($options['joinTable'])) { + $this->_junctionTableName($options['joinTable']); } - if (!empty($opts['through'])) { - $this->setThrough($opts['through']); + if (!empty($options['through'])) { + $this->setThrough($options['through']); } - if (!empty($opts['saveStrategy'])) { - $this->setSaveStrategy($opts['saveStrategy']); + if (!empty($options['saveStrategy'])) { + $this->setSaveStrategy($options['saveStrategy']); } - if (isset($opts['sort'])) { - $this->setSort($opts['sort']); + if (isset($options['sort'])) { + $this->setSort($options['sort']); + } + if (isset($options['junctionProperty'])) { + assert(is_string($options['junctionProperty']), '`junctionProperty` must be a string'); + + $this->_junctionProperty = $options['junctionProperty']; } } } diff --git a/src/ORM/Association/DependentDeleteHelper.php b/src/ORM/Association/DependentDeleteHelper.php index 5bd74aff618..e260615089d 100644 --- a/src/ORM/Association/DependentDeleteHelper.php +++ b/src/ORM/Association/DependentDeleteHelper.php @@ -1,16 +1,18 @@ $options The options for the original delete. * @return bool Success. */ - public function cascadeDelete(Association $association, EntityInterface $entity, array $options = []) + public function cascadeDelete(Association $association, EntityInterface $entity, array $options = []): bool { if (!$association->getDependent()) { return true; } $table = $association->getTarget(); - $foreignKey = array_map([$association, 'aliasField'], (array)$association->getForeignKey()); + /** @var callable $callable */ + $callable = $association->aliasField(...); + $foreignKey = array_map($callable, (array)$association->getForeignKey()); $bindingKey = (array)$association->getBindingKey(); - $conditions = array_combine($foreignKey, $entity->extract($bindingKey)); + $bindingValue = $entity->extract($bindingKey); + if (in_array(null, $bindingValue, true)) { + return true; + } + $conditions = array_combine($foreignKey, $bindingValue); if ($association->getCascadeCallbacks()) { - foreach ($association->find()->where($conditions)->all()->toList() as $related) { - $table->delete($related, $options); + /** @var \Cake\Datasource\EntityInterface $related */ + foreach ($association->find()->where($conditions)->toArray() as $related) { + $success = $table->delete($related, $options); + if (!$success) { + return false; + } } return true; } - $conditions = array_merge($conditions, $association->getConditions()); - return (bool)$table->deleteAll($conditions); + $association->deleteAll($conditions); + + return true; } } diff --git a/src/ORM/Association/DependentDeleteTrait.php b/src/ORM/Association/DependentDeleteTrait.php deleted file mode 100644 index f172d4c9a30..00000000000 --- a/src/ORM/Association/DependentDeleteTrait.php +++ /dev/null @@ -1,45 +0,0 @@ -cascadeDelete($this, $entity, $options); - } -} diff --git a/src/ORM/Association/HasMany.php b/src/ORM/Association/HasMany.php index f7bab7b2229..2fc46d71f6a 100644 --- a/src/ORM/Association/HasMany.php +++ b/src/ORM/Association/HasMany.php @@ -1,6 +1,7 @@ |string|null */ - protected $_sort; + protected ExpressionInterface|Closure|array|string|null $_sort = null; /** * The type of join to be used when adding the association to a query * * @var string */ - protected $_joinType = QueryInterface::JOIN_TYPE_INNER; + protected string $_joinType = SelectQuery::JOIN_TYPE_INNER; /** * The strategy name to be used to fetch associated records. * * @var string */ - protected $_strategy = self::STRATEGY_SELECT; + protected string $_strategy = self::STRATEGY_SUBQUERY; /** * Valid strategies for this type of association * - * @var array + * @var array */ - protected $_validStrategies = [ + protected array $_validStrategies = [ self::STRATEGY_SELECT, - self::STRATEGY_SUBQUERY + self::STRATEGY_SUBQUERY, ]; /** @@ -72,31 +76,31 @@ class HasMany extends Association * * @var string */ - const SAVE_APPEND = 'append'; + public const SAVE_APPEND = 'append'; /** * Saving strategy that will replace the links with the provided set * * @var string */ - const SAVE_REPLACE = 'replace'; + public const SAVE_REPLACE = 'replace'; /** * Saving strategy to be used by this association * * @var string */ - protected $_saveStrategy = self::SAVE_APPEND; + protected string $_saveStrategy = self::SAVE_APPEND; /** - * Returns whether or not the passed table is the owning side for this + * Returns whether the passed table is the owning side for this * association. This means that rows in the 'target' table would miss important * or required information if the row in 'source' did not exist. * * @param \Cake\ORM\Table $side The potential Table with ownership * @return bool */ - public function isOwningSide(Table $side) + public function isOwningSide(Table $side): bool { return $side === $this->getSource(); } @@ -108,10 +112,10 @@ public function isOwningSide(Table $side) * @throws \InvalidArgumentException if an invalid strategy name is passed * @return $this */ - public function setSaveStrategy($strategy) + public function setSaveStrategy(string $strategy) { - if (!in_array($strategy, [self::SAVE_APPEND, self::SAVE_REPLACE])) { - $msg = sprintf('Invalid save strategy "%s"', $strategy); + if (!in_array($strategy, [self::SAVE_APPEND, self::SAVE_REPLACE], true)) { + $msg = sprintf('Invalid save strategy `%s`', $strategy); throw new InvalidArgumentException($msg); } @@ -125,29 +129,11 @@ public function setSaveStrategy($strategy) * * @return string the strategy to be used for saving */ - public function getSaveStrategy() + public function getSaveStrategy(): string { return $this->_saveStrategy; } - /** - * Sets the strategy that should be used for saving. If called with no - * arguments, it will return the currently configured strategy - * - * @deprecated 3.4.0 Use setSaveStrategy()/getSaveStrategy() instead. - * @param string|null $strategy the strategy name to be used - * @throws \InvalidArgumentException if an invalid strategy name is passed - * @return string the strategy to be used for saving - */ - public function saveStrategy($strategy = null) - { - if ($strategy !== null) { - $this->setSaveStrategy($strategy); - } - - return $this->getSaveStrategy(); - } - /** * Takes an entity from the source table and looks if there is a field * matching the property name for this association. The found entity will be @@ -155,19 +141,20 @@ public function saveStrategy($strategy = null) * `$options` * * @param \Cake\Datasource\EntityInterface $entity an entity from the source table - * @param array $options options to be passed to the save method in the target table - * @return bool|\Cake\Datasource\EntityInterface false if $entity could not be saved, otherwise it returns + * @param array $options options to be passed to the save method in the target table + * @return \Cake\Datasource\EntityInterface|false false if $entity could not be saved, otherwise it returns * the saved entity * @see \Cake\ORM\Table::save() * @throws \InvalidArgumentException when the association data cannot be traversed. */ - public function saveAssociated(EntityInterface $entity, array $options = []) + public function saveAssociated(EntityInterface $entity, array $options = []): EntityInterface|false { $targetEntities = $entity->get($this->getProperty()); $isEmpty = in_array($targetEntities, [null, [], '', false], true); if ($isEmpty) { - if ($entity->isNew() || + if ( + $entity->isNew() || $this->getSaveStrategy() !== self::SAVE_REPLACE ) { return $entity; @@ -176,27 +163,31 @@ public function saveAssociated(EntityInterface $entity, array $options = []) $targetEntities = []; } - if (!is_array($targetEntities) && - !($targetEntities instanceof Traversable) - ) { + if (!is_iterable($targetEntities)) { $name = $this->getProperty(); $message = sprintf('Could not save %s, it cannot be traversed', $name); throw new InvalidArgumentException($message); } + /** @var array $foreignKeys */ + $foreignKeys = (array)$this->getForeignKey(); $foreignKeyReference = array_combine( - (array)$this->getForeignKey(), - $entity->extract((array)$this->getBindingKey()) + $foreignKeys, + $entity->extract((array)$this->getBindingKey()), ); $options['_sourceTable'] = $this->getSource(); - if ($this->_saveStrategy === self::SAVE_REPLACE && + if ( + $this->_saveStrategy === self::SAVE_REPLACE && !$this->_unlinkAssociated($foreignKeyReference, $entity, $this->getTarget(), $targetEntities, $options) ) { return false; } + if (!is_array($targetEntities)) { + $targetEntities = iterator_to_array($targetEntities); + } if (!$this->_saveTarget($foreignKeyReference, $entity, $targetEntities, $options)) { return false; } @@ -212,13 +203,17 @@ public function saveAssociated(EntityInterface $entity, array $options = []) * target entity, and the parent entity. * @param \Cake\Datasource\EntityInterface $parentEntity The source entity containing the target * entities to be saved. - * @param array|\Traversable $entities list of entities to persist in target table and to - * link to the parent entity - * @param array $options list of options accepted by `Table::save()`. + * @param array $entities list of entities + * to persist in target table and to link to the parent entity + * @param array $options list of options accepted by `Table::save()`. * @return bool `true` on success, `false` otherwise. */ - protected function _saveTarget(array $foreignKeyReference, EntityInterface $parentEntity, $entities, array $options) - { + protected function _saveTarget( + array $foreignKeyReference, + EntityInterface $parentEntity, + array $entities, + array $options, + ): bool { $foreignKey = array_keys($foreignKeyReference); $table = $this->getTarget(); $original = $entities; @@ -233,7 +228,11 @@ protected function _saveTarget(array $foreignKeyReference, EntityInterface $pare } if ($foreignKeyReference !== $entity->extract($foreignKey)) { - $entity->set($foreignKeyReference, ['guard' => false]); + if (method_exists($entity, 'patch')) { + $entity->patch($foreignKeyReference, ['guard' => false]); + } else { + $entity->set($foreignKeyReference, ['guard' => false]); + } } if ($table->save($entity, $options)) { @@ -242,8 +241,12 @@ protected function _saveTarget(array $foreignKeyReference, EntityInterface $pare } if (!empty($options['atomic'])) { - $original[$k]->errors($entity->errors()); - $entity->set($this->getProperty(), $original); + /** @var \Cake\ORM\Entity $originEntity */ + $originEntity = $original[$k]; + $originEntity->setErrors($entity->getErrors()); + if ($entity instanceof InvalidPropertyInterface) { + $originEntity->setInvalid($entity->getInvalid()); + } return false; } @@ -274,30 +277,48 @@ protected function _saveTarget(array $foreignKeyReference, EntityInterface $pare * * @param \Cake\Datasource\EntityInterface $sourceEntity the row belonging to the `source` side * of this association - * @param array $targetEntities list of entities belonging to the `target` side + * @param array<\Cake\Datasource\EntityInterface> $targetEntities list of entities belonging to the `target` side * of this association - * @param array $options list of options to be passed to the internal `save` call + * @param array $options list of options to be passed to the internal `save` call * @return bool true on success, false otherwise */ - public function link(EntityInterface $sourceEntity, array $targetEntities, array $options = []) + public function link(EntityInterface $sourceEntity, array $targetEntities, array $options = []): bool { $saveStrategy = $this->getSaveStrategy(); $this->setSaveStrategy(self::SAVE_APPEND); $property = $this->getProperty(); - $currentEntities = array_unique( - array_merge( - (array)$sourceEntity->get($property), - $targetEntities - ) - ); + /** @var array<\Cake\Datasource\EntityInterface> $currentEntities */ + $currentEntities = (array)$sourceEntity->get($property); + if ($currentEntities === []) { + $currentEntities = $targetEntities; + } else { + $pkFields = (array)$this->getTarget()->getPrimaryKey(); + /** @var array<\Cake\Datasource\EntityInterface> $currentEntities */ + $targetEntities = (new Collection($targetEntities)) + ->reject( + function (EntityInterface $entity) use ($currentEntities, $pkFields) { + if ($entity->isNew()) { + return false; + } + + foreach ($currentEntities as $cEntity) { + if ($entity->extract($pkFields) === $cEntity->extract($pkFields)) { + return true; + } + } + + return false; + }, + ) + ->toList(); - $sourceEntity->set($property, $currentEntities); + $currentEntities = array_merge($currentEntities, $targetEntities); + } - $savedEntity = $this->getConnection()->transactional(function () use ($sourceEntity, $options) { - return $this->saveAssociated($sourceEntity, $options); - }); + $sourceEntity->set($property, $currentEntities); + $savedEntity = $this->getConnection()->transactional(fn() => $this->saveAssociated($sourceEntity, $options)); $ok = ($savedEntity instanceof EntityInterface); $this->setSaveStrategy($saveStrategy); @@ -320,7 +341,7 @@ public function link(EntityInterface $sourceEntity, array $targetEntities, array * Additionally to the default options accepted by `Table::delete()`, the following * keys are supported: * - * - cleanProperty: Whether or not to remove all the objects in `$targetEntities` that + * - cleanProperty: Whether to remove all the objects in `$targetEntities` that * are stored in `$sourceEntity` (default: true) * * By default this method will unset each of the entity objects stored inside the @@ -344,22 +365,23 @@ public function link(EntityInterface $sourceEntity, array $targetEntities, array * this association * @param array $targetEntities list of entities persisted in the target table for * this association - * @param array $options list of options to be passed to the internal `delete` call + * @param array|bool $options list of options to be passed to the internal `delete` call. + * If boolean it will be used a value for "cleanProperty" option. * @throws \InvalidArgumentException if non persisted entities are passed or if * any of them is lacking a primary key value - * @return void + * @return bool */ - public function unlink(EntityInterface $sourceEntity, array $targetEntities, $options = []) + public function unlink(EntityInterface $sourceEntity, array $targetEntities, array|bool $options = []): bool { if (is_bool($options)) { $options = [ - 'cleanProperty' => $options + 'cleanProperty' => $options, ]; } else { $options += ['cleanProperty' => true]; } - if (count($targetEntities) === 0) { - return; + if ($targetEntities === []) { + return true; } $foreignKey = (array)$this->getForeignKey(); @@ -369,13 +391,17 @@ public function unlink(EntityInterface $sourceEntity, array $targetEntities, $op $conditions = [ 'OR' => (new Collection($targetEntities)) - ->map(function ($entity) use ($targetPrimaryKey) { + ->map(function (EntityInterface $entity) use ($targetPrimaryKey) { + /** @var array $targetPrimaryKey */ return $entity->extract($targetPrimaryKey); }) - ->toList() + ->toList(), ]; - $this->_unlink($foreignKey, $target, $conditions, $options); + $return = $this->_unlink($foreignKey, $target, $conditions, $options); + if (!$return) { + return false; + } $result = $sourceEntity->get($property); if ($options['cleanProperty'] && $result !== null) { @@ -384,14 +410,16 @@ public function unlink(EntityInterface $sourceEntity, array $targetEntities, $op (new Collection($sourceEntity->get($property))) ->reject( function ($assoc) use ($targetEntities) { - return in_array($assoc, $targetEntities); - } + return in_array($assoc, $targetEntities, true); + }, ) - ->toList() + ->toList(), ); } $sourceEntity->setDirty($property, false); + + return true; } /** @@ -423,7 +451,7 @@ function ($assoc) use ($targetEntities) { * $author->articles = [$article1, $article2, $article3, $article4]; * $authors->save($author); * $articles = [$article1, $article3]; - * $authors->association('articles')->replace($author, $articles); + * $authors->getAssociation('articles')->replace($author, $articles); * ``` * * `$author->get('articles')` will contain only `[$article1, $article3]` at the end @@ -431,13 +459,13 @@ function ($assoc) use ($targetEntities) { * @param \Cake\Datasource\EntityInterface $sourceEntity an entity persisted in the source table for * this association * @param array $targetEntities list of entities from the target table to be linked - * @param array $options list of options to be passed to the internal `save`/`delete` calls + * @param array $options list of options to be passed to the internal `save`/`delete` calls * when persisting/updating new links, or deleting existing ones * @throws \InvalidArgumentException if non persisted entities are passed or if * any of them is lacking a primary key value * @return bool success */ - public function replace(EntityInterface $sourceEntity, array $targetEntities, array $options = []) + public function replace(EntityInterface $sourceEntity, array $targetEntities, array $options = []): bool { $property = $this->getProperty(); $sourceEntity->set($property, $targetEntities); @@ -447,6 +475,7 @@ public function replace(EntityInterface $sourceEntity, array $targetEntities, ar $ok = ($result instanceof EntityInterface); if ($ok) { + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable $sourceEntity = $result; } $this->setSaveStrategy($saveStrategy); @@ -455,41 +484,46 @@ public function replace(EntityInterface $sourceEntity, array $targetEntities, ar } /** - * Deletes/sets null the related objects according to the dependency between source and targets and foreign key nullability - * Skips deleting records present in $remainingEntities + * Deletes/sets null the related objects according to the dependency between source and targets + * and foreign key nullability. Skips deleting records present in $remainingEntities * * @param array $foreignKeyReference The foreign key reference defining the link between the * target entity, and the parent entity. * @param \Cake\Datasource\EntityInterface $entity the entity which should have its associated entities unassigned * @param \Cake\ORM\Table $target The associated table - * @param array $remainingEntities Entities that should not be deleted - * @param array $options list of options accepted by `Table::delete()` + * @param iterable $remainingEntities Entities that should not be deleted + * @param array $options list of options accepted by `Table::delete()` * @return bool success */ - protected function _unlinkAssociated(array $foreignKeyReference, EntityInterface $entity, Table $target, array $remainingEntities = [], array $options = []) - { + protected function _unlinkAssociated( + array $foreignKeyReference, + EntityInterface $entity, + Table $target, + iterable $remainingEntities = [], + array $options = [], + ): bool { $primaryKey = (array)$target->getPrimaryKey(); $exclusions = new Collection($remainingEntities); $exclusions = $exclusions->map( - function ($ent) use ($primaryKey) { + function (EntityInterface $ent) use ($primaryKey) { return $ent->extract($primaryKey); - } + }, ) ->filter( function ($v) { - return !in_array(null, array_values($v), true); - } + return !in_array(null, $v, true); + }, ) - ->toArray(); + ->toList(); $conditions = $foreignKeyReference; - if (count($exclusions) > 0) { + if ($exclusions !== []) { $conditions = [ 'NOT' => [ - 'OR' => $exclusions + 'OR' => $exclusions, ], - $foreignKeyReference + $foreignKeyReference, ]; } @@ -498,44 +532,49 @@ function ($v) { /** * Deletes/sets null the related objects matching $conditions. - * The action which is taken depends on the dependency between source and targets and also on foreign key nullability + * + * The action which is taken depends on the dependency between source and + * targets and also on foreign key nullability. * * @param array $foreignKey array of foreign key properties * @param \Cake\ORM\Table $target The associated table * @param array $conditions The conditions that specifies what are the objects to be unlinked - * @param array $options list of options accepted by `Table::delete()` + * @param array $options list of options accepted by `Table::delete()` * @return bool success */ - protected function _unlink(array $foreignKey, Table $target, array $conditions = [], array $options = []) + protected function _unlink(array $foreignKey, Table $target, array $conditions = [], array $options = []): bool { $mustBeDependent = (!$this->_foreignKeyAcceptsNull($target, $foreignKey) || $this->getDependent()); if ($mustBeDependent) { if ($this->_cascadeCallbacks) { $conditions = new QueryExpression($conditions); - $conditions->traverse(function ($entry) use ($target) { + $conditions->traverse(function ($entry) use ($target): void { if ($entry instanceof FieldInterface) { - $entry->setField($target->aliasField($entry->getField())); + $field = $entry->getField(); + if (is_string($field)) { + $entry->setField($target->aliasField($field)); + } } }); - $query = $this->find('all')->where($conditions); - $ok = true; - foreach ($query as $assoc) { - $ok = $ok && $target->delete($assoc, $options); + /** @var \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface> $query */ + $query = $this->find()->where($conditions); + + $return = $target->deleteMany($query->all(), $options); + if ($return === false) { + return false; } - return $ok; + return true; } - $conditions = array_merge($conditions, $this->getConditions()); - $target->deleteAll($conditions); + $this->deleteAll($conditions); return true; } $updateFields = array_fill_keys($foreignKey, null); - $conditions = array_merge($conditions, $this->getConditions()); - $target->updateAll($updateFields, $conditions); + $this->updateAll($updateFields, $conditions); return true; } @@ -547,16 +586,15 @@ protected function _unlink(array $foreignKey, Table $target, array $conditions = * @param array $properties the list of fields that compose the foreign key * @return bool */ - protected function _foreignKeyAcceptsNull(Table $table, array $properties) + protected function _foreignKeyAcceptsNull(Table $table, array $properties): bool { return !in_array( false, array_map( - function ($prop) use ($table) { - return $table->getSchema()->isNullable($prop); - }, - $properties - ) + $table->getSchema()->isNullable(...), + $properties, + ), + true, ); } @@ -565,7 +603,7 @@ function ($prop) use ($table) { * * @return string */ - public function type() + public function type(): string { return self::ONE_TO_MANY; } @@ -573,36 +611,30 @@ public function type() /** * Whether this association can be expressed directly in a query join * - * @param array $options custom options key that could alter the return value + * @param array $options custom options key that could alter the return value * @return bool if the 'matching' key in $option is true then this function * will return true, false otherwise */ - public function canBeJoined(array $options = []) + public function canBeJoined(array $options = []): bool { return !empty($options['matching']); } /** - * Gets the name of the field representing the foreign key to the source table. - * - * @return string + * @inheritDoc */ - public function getForeignKey() + public function getForeignKey(): array|string|false { - if ($this->_foreignKey === null) { - $this->_foreignKey = $this->_modelKey($this->getSource()->getTable()); - } - - return $this->_foreignKey; + return $this->_foreignKey ??= $this->_modelKey($this->getSource()->getTable()); } /** * Sets the sort order in which target records should be returned. * - * @param mixed $sort A find() compatible order clause + * @param \Cake\Database\ExpressionInterface|\Closure|array<\Cake\Database\ExpressionInterface|string>|string $sort A find() compatible order clause * @return $this */ - public function setSort($sort) + public function setSort(ExpressionInterface|Closure|array|string $sort) { $this->_sort = $sort; @@ -612,34 +644,17 @@ public function setSort($sort) /** * Gets the sort order in which target records should be returned. * - * @return mixed + * @return \Cake\Database\ExpressionInterface|\Closure|array<\Cake\Database\ExpressionInterface|string>|string|null */ - public function getSort() + public function getSort(): ExpressionInterface|Closure|array|string|null { return $this->_sort; } /** - * Sets the sort order in which target records should be returned. - * If no arguments are passed the currently configured value is returned - * - * @deprecated 3.4.0 Use setSort()/getSort() instead. - * @param mixed $sort A find() compatible order clause - * @return mixed + * @inheritDoc */ - public function sort($sort = null) - { - if ($sort !== null) { - $this->setSort($sort); - } - - return $this->getSort(); - } - - /** - * {@inheritDoc} - */ - public function defaultRowValue($row, $joined) + public function defaultRowValue(array $row, bool $joined): array { $sourceAlias = $this->getSource()->getAlias(); if (isset($row[$sourceAlias])) { @@ -652,25 +667,23 @@ public function defaultRowValue($row, $joined) /** * Parse extra options passed in the constructor. * - * @param array $opts original list of options passed in constructor + * @param array $options original list of options passed in constructor * @return void */ - protected function _options(array $opts) + protected function _options(array $options): void { - if (!empty($opts['saveStrategy'])) { - $this->setSaveStrategy($opts['saveStrategy']); + if (!empty($options['saveStrategy'])) { + $this->setSaveStrategy($options['saveStrategy']); } - if (isset($opts['sort'])) { - $this->setSort($opts['sort']); + if (isset($options['sort'])) { + $this->setSort($options['sort']); } } /** - * {@inheritDoc} - * - * @return \Closure + * @inheritDoc */ - public function eagerLoader(array $options) + public function eagerLoader(array $options): Closure { $loader = new SelectLoader([ 'alias' => $this->getAlias(), @@ -681,16 +694,16 @@ public function eagerLoader(array $options) 'strategy' => $this->getStrategy(), 'associationType' => $this->type(), 'sort' => $this->getSort(), - 'finder' => [$this, 'find'] + 'finder' => $this->find(...), ]); return $loader->buildEagerLoader($options); } /** - * {@inheritDoc} + * @inheritDoc */ - public function cascadeDelete(EntityInterface $entity, array $options = []) + public function cascadeDelete(EntityInterface $entity, array $options = []): bool { $helper = new DependentDeleteHelper(); diff --git a/src/ORM/Association/HasOne.php b/src/ORM/Association/HasOne.php index 88e6b49f419..51012c7733f 100644 --- a/src/ORM/Association/HasOne.php +++ b/src/ORM/Association/HasOne.php @@ -1,4 +1,6 @@ */ - protected $_validStrategies = [ + protected array $_validStrategies = [ self::STRATEGY_JOIN, - self::STRATEGY_SELECT + self::STRATEGY_SELECT, ]; /** - * Gets the name of the field representing the foreign key to the target table. + * @inheritDoc + */ + public function getForeignKey(): array|string|false + { + return $this->_foreignKey ??= $this->_modelKey($this->getSource()->getAlias()); + } + + /** + * Sets the name of the field representing the foreign key to the target table. * - * @return string + * @param array|string|false $key the key or keys to be used to link both tables together, if set to `false` + * no join conditions will be generated automatically. + * @return $this */ - public function getForeignKey() + public function setForeignKey(array|string|false $key) { - if ($this->_foreignKey === null) { - $this->_foreignKey = $this->_modelKey($this->getSource()->getAlias()); - } + $this->_foreignKey = $key; - return $this->_foreignKey; + return $this; } /** @@ -58,22 +72,22 @@ public function getForeignKey() * * @return string */ - protected function _propertyName() + protected function _propertyName(): string { - list(, $name) = pluginSplit($this->_name); + [, $name] = pluginSplit($this->_name); return Inflector::underscore(Inflector::singularize($name)); } /** - * Returns whether or not the passed table is the owning side for this + * Returns whether the passed table is the owning side for this * association. This means that rows in the 'target' table would miss important * or required information if the row in 'source' did not exist. * * @param \Cake\ORM\Table $side The potential Table with ownership * @return bool */ - public function isOwningSide(Table $side) + public function isOwningSide(Table $side): bool { return $side === $this->getSource(); } @@ -83,7 +97,7 @@ public function isOwningSide(Table $side) * * @return string */ - public function type() + public function type(): string { return self::ONE_TO_ONE; } @@ -95,26 +109,32 @@ public function type() * `$options` * * @param \Cake\Datasource\EntityInterface $entity an entity from the source table - * @param array $options options to be passed to the save method in the target table - * @return bool|\Cake\Datasource\EntityInterface false if $entity could not be saved, otherwise it returns + * @param array $options options to be passed to the save method in the target table + * @return \Cake\Datasource\EntityInterface|false false if $entity could not be saved, otherwise it returns * the saved entity * @see \Cake\ORM\Table::save() */ - public function saveAssociated(EntityInterface $entity, array $options = []) + public function saveAssociated(EntityInterface $entity, array $options = []): EntityInterface|false { $targetEntity = $entity->get($this->getProperty()); - if (empty($targetEntity) || !($targetEntity instanceof EntityInterface)) { + if (!$targetEntity instanceof EntityInterface) { return $entity; } + /** @var array $foreignKeys */ + $foreignKeys = (array)$this->getForeignKey(); $properties = array_combine( - (array)$this->getForeignKey(), - $entity->extract((array)$this->getBindingKey()) + $foreignKeys, + $entity->extract((array)$this->getBindingKey()), ); - $targetEntity->set($properties, ['guard' => false]); + if (method_exists($targetEntity, 'patch')) { + $targetEntity = $targetEntity->patch($properties, ['guard' => false]); + } else { + $targetEntity->set($properties, ['guard' => false]); + } if (!$this->getTarget()->save($targetEntity, $options)) { - $targetEntity->unsetProperty(array_keys($properties)); + $targetEntity->unset(array_keys($properties)); return false; } @@ -123,11 +143,9 @@ public function saveAssociated(EntityInterface $entity, array $options = []) } /** - * {@inheritDoc} - * - * @return \Closure + * @inheritDoc */ - public function eagerLoader(array $options) + public function eagerLoader(array $options): Closure { $loader = new SelectLoader([ 'alias' => $this->getAlias(), @@ -137,16 +155,16 @@ public function eagerLoader(array $options) 'bindingKey' => $this->getBindingKey(), 'strategy' => $this->getStrategy(), 'associationType' => $this->type(), - 'finder' => [$this, 'find'] + 'finder' => $this->find(...), ]); return $loader->buildEagerLoader($options); } /** - * {@inheritDoc} + * @inheritDoc */ - public function cascadeDelete(EntityInterface $entity, array $options = []) + public function cascadeDelete(EntityInterface $entity, array $options = []): bool { $helper = new DependentDeleteHelper(); diff --git a/src/ORM/Association/Loader/SelectLoader.php b/src/ORM/Association/Loader/SelectLoader.php index d22e6c5d37b..971e66cf729 100644 --- a/src/ORM/Association/Loader/SelectLoader.php +++ b/src/ORM/Association/Loader/SelectLoader.php @@ -1,4 +1,6 @@ $options Properties to be copied to this class */ public function __construct(array $options) { @@ -108,17 +115,17 @@ public function __construct(array $options) $this->bindingKey = $options['bindingKey']; $this->finder = $options['finder']; $this->associationType = $options['associationType']; - $this->sort = isset($options['sort']) ? $options['sort'] : null; + $this->sort = $options['sort'] ?? null; } /** * Returns a callable that can be used for injecting association results into a given * iterator. The options accepted by this method are the same as `Association::eagerLoader()` * - * @param array $options Same options as `Association::eagerLoader()` - * @return callable + * @param array $options Same options as `Association::eagerLoader()` + * @return \Closure */ - public function buildEagerLoader(array $options) + public function buildEagerLoader(array $options): Closure { $options += $this->_defaultOptions(); $fetchQuery = $this->_buildQuery($options); @@ -130,16 +137,16 @@ public function buildEagerLoader(array $options) /** * Returns the default options to use for the eagerLoader * - * @return array + * @return array */ - protected function _defaultOptions() + protected function _defaultOptions(): array { return [ 'foreignKey' => $this->foreignKey, 'conditions' => [], 'strategy' => $this->strategy, 'nestKey' => $this->alias, - 'sort' => $this->sort + 'sort' => $this->sort, ]; } @@ -148,43 +155,53 @@ protected function _defaultOptions() * in the target table that are associated to those specified in $options from * the source table * - * @param array $options options accepted by eagerLoader() - * @return \Cake\ORM\Query + * @param array $options options accepted by eagerLoader() + * @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> * @throws \InvalidArgumentException When a key is required for associations but not selected. */ - protected function _buildQuery($options) + protected function _buildQuery(array $options): SelectQuery { $key = $this->_linkField($options); $filter = $options['keys']; $useSubquery = $options['strategy'] === Association::STRATEGY_SUBQUERY; $finder = $this->finder; + $options['fields'] ??= []; - if (!isset($options['fields'])) { - $options['fields'] = []; - } - - /* @var \Cake\ORM\Query $query */ $query = $finder(); + assert($query instanceof SelectQuery); if (isset($options['finder'])) { - list($finderName, $opts) = $this->_extractFinder($options['finder']); - $query = $query->find($finderName, $opts); + [$finderName, $opts] = $this->_extractFinder($options['finder']); + $query = $query->find($finderName, ...$opts); } + /** @var \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $selectQuery */ + $selectQuery = $options['query']; + + // Disable hydration for external queries when parent has DTO projection + // The DTO's setFromArray() expects arrays, not entities + $shouldHydrate = $selectQuery->isHydrationEnabled() && !$selectQuery->isDtoProjectionEnabled(); + $fetchQuery = $query ->select($options['fields']) ->where($options['conditions']) ->eagerLoaded(true) - ->enableHydration($options['query']->isHydrationEnabled()); + ->enableHydration($shouldHydrate) + ->setConnectionRole($selectQuery->getConnectionRole()); + if ($selectQuery->isResultsCastingEnabled()) { + $fetchQuery->enableResultsCasting(); + } else { + $fetchQuery->disableResultsCasting(); + } if ($useSubquery) { - $filter = $this->_buildSubquery($options['query']); + $filter = $this->_buildSubquery($selectQuery); $fetchQuery = $this->_addFilteringJoin($fetchQuery, $key, $filter); } else { $fetchQuery = $this->_addFilteringCondition($fetchQuery, $key, $filter); } if (!empty($options['sort'])) { - $fetchQuery->order($options['sort']); + $fetchQuery->orderBy($options['sort']); } if (!empty($options['contain'])) { @@ -192,6 +209,8 @@ protected function _buildQuery($options) } if (!empty($options['queryBuilder'])) { + assert(is_callable($options['queryBuilder'])); + /** @var \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $fetchQuery */ $fetchQuery = $options['queryBuilder']($fetchQuery); } @@ -212,11 +231,11 @@ protected function _buildQuery($options) * $query->contain(['Comments' => ['finder' => ['translations' => []]]]); * $query->contain(['Comments' => ['finder' => ['translations' => ['locales' => ['en_US']]]]]); * - * @param string|array $finderData The finder name or an array having the name as key + * @param array|string $finderData The finder name or an array having the name as key * and options as value. * @return array */ - protected function _extractFinder($finderData) + protected function _extractFinder(array|string $finderData): array { $finderData = (array)$finderData; @@ -230,43 +249,39 @@ protected function _extractFinder($finderData) /** * Checks that the fetching query either has auto fields on or * has the foreignKey fields selected. - * If the required fields are missing, throws an exception. + * If the required fields are missing, automatically adds them to ensure + * entities can be properly identified and loaded. * - * @param \Cake\ORM\Query $fetchQuery The association fetching query - * @param array $key The foreign key fields to check + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $fetchQuery The association fetching query + * @param array $key The foreign key fields to check * @return void - * @throws InvalidArgumentException + * @throws \InvalidArgumentException */ - protected function _assertFieldsPresent($fetchQuery, $key) + protected function _assertFieldsPresent(SelectQuery $fetchQuery, array $key): void { + if ($fetchQuery->isAutoFieldsEnabled()) { + return; + } + $select = $fetchQuery->aliasFields($fetchQuery->clause('select')); - if (empty($select)) { + if (!$select) { return; } - $missingKey = function ($fieldList, $key) { - foreach ($key as $keyField) { - if (!in_array($keyField, $fieldList, true)) { - return true; + + $missingFields = []; + foreach ($key as $keyField) { + if (!in_array($keyField, $select, true)) { + $driver = $fetchQuery->getDriver(); + $quoted = $driver->quoteIdentifier($keyField); + if (!in_array($quoted, $select, true)) { + $missingFields[] = $keyField; } } - - return false; - }; - - $missingFields = $missingKey($select, $key); - if ($missingFields) { - $driver = $fetchQuery->getConnection()->getDriver(); - $quoted = array_map([$driver, 'quoteIdentifier'], $key); - $missingFields = $missingKey($select, $quoted); } + // Automatically add missing primary key fields to the query if ($missingFields) { - throw new InvalidArgumentException( - sprintf( - 'You are required to select the "%s" field(s)', - implode(', ', (array)$key) - ) - ); + $fetchQuery->select($missingFields); } } @@ -275,19 +290,32 @@ protected function _assertFieldsPresent($fetchQuery, $key) * target table query given a filter key and some filtering values when the * filtering needs to be done using a subquery. * - * @param \Cake\ORM\Query $query Target table's query - * @param string|array $key the fields that should be used for filtering - * @param \Cake\ORM\Query $subquery The Subquery to use for filtering - * @return \Cake\ORM\Query + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query Target table's query + * @param array|string $key the fields that should be used for filtering + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $subquery The Subquery to use for filtering + * @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> */ - protected function _addFilteringJoin($query, $key, $subquery) + protected function _addFilteringJoin(SelectQuery $query, array|string $key, SelectQuery $subquery): SelectQuery { $filter = []; + $joinFields = []; $aliasedTable = $this->sourceAlias; + $keyCount = count((array)$key); + + // When source and target use the same alias (self-referential associations + // like a tree structure), the subquery join alias would collide with the + // outer query's table alias, causing ambiguous column references. + // Use a suffixed alias to avoid the collision. + if ($aliasedTable === $this->targetAlias) { + $aliasedTable = $this->sourceAlias . '_subquery'; + } foreach ($subquery->clause('select') as $aliasedField => $field) { if (is_int($aliasedField)) { - $filter[] = new IdentifierExpression($field); + $filter[] = $field; + if (count($joinFields) < $keyCount) { + $joinFields[] = $this->_rewriteJoinIdentifier($query, $field, $aliasedTable); + } } else { $filter[$aliasedField] = $field; } @@ -295,36 +323,71 @@ protected function _addFilteringJoin($query, $key, $subquery) $subquery->select($filter, true); if (is_array($key)) { - $conditions = $this->_createTupleCondition($query, $key, $filter, '='); + $conditions = $this->_createTupleCondition($query, $key, $joinFields, '='); } else { - $filter = current($filter); + $conditions = $query->expr([$key => $joinFields[0]]); } - $conditions = isset($conditions) ? $conditions : $query->newExpr([$key => $filter]); - return $query->innerJoin( [$aliasedTable => $subquery], - $conditions + $conditions, ); } + /** + * Rewrites a subquery field reference for use in the outer join condition. + * + * The subquery body must continue to reference its own internal table alias, + * while the outer join condition must reference the alias assigned to the + * derived table itself. + * + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query Target table's query + * @param mixed $field The original selected field. + * @param string $aliasedTable The alias assigned to the joined subquery. + * @return mixed + */ + protected function _rewriteJoinIdentifier(SelectQuery $query, mixed $field, string $aliasedTable): mixed + { + if (!is_string($field)) { + return $field; + } + + $identifier = $field; + $selectAlias = null; + if (preg_match('/^' . preg_quote($this->sourceAlias, '/') . '\.(.+)$/', $field, $matches)) { + $selectAlias = $this->sourceAlias . '__' . $matches[1]; + $identifier = $aliasedTable . '.' . $selectAlias; + } + + $driver = $query->getDriver(); + if ( + $selectAlias !== null && + !$driver->isAutoQuotingEnabled() && + $driver->newCompiler() instanceof PostgresCompiler + ) { + $identifier = $aliasedTable . '.' . $driver->quoteIdentifier($selectAlias); + } + + return new IdentifierExpression($identifier); + } + /** * Appends any conditions required to load the relevant set of records in the * target table query given a filter key and some filtering values. * - * @param \Cake\ORM\Query $query Target table's query - * @param string|array $key The fields that should be used for filtering + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query Target table's query + * @param array|string $key The fields that should be used for filtering * @param mixed $filter The value that should be used to match for $key - * @return \Cake\ORM\Query + * @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> */ - protected function _addFilteringCondition($query, $key, $filter) + protected function _addFilteringCondition(SelectQuery $query, array|string $key, mixed $filter): SelectQuery { if (is_array($key)) { $conditions = $this->_createTupleCondition($query, $key, $filter, 'IN'); + } else { + $conditions = [$key . ' IN' => $filter]; } - $conditions = isset($conditions) ? $conditions : [$key . ' IN' => $filter]; - return $query->andWhere($conditions); } @@ -332,14 +395,18 @@ protected function _addFilteringCondition($query, $key, $filter) * Returns a TupleComparison object that can be used for matching all the fields * from $keys with the tuple values in $filter using the provided operator. * - * @param \Cake\ORM\Query $query Target table's query - * @param array $keys the fields that should be used for filtering + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query Target table's query + * @param array $keys the fields that should be used for filtering * @param mixed $filter the value that should be used to match for $key * @param string $operator The operator for comparing the tuples * @return \Cake\Database\Expression\TupleComparison */ - protected function _createTupleCondition($query, $keys, $filter, $operator) - { + protected function _createTupleCondition( + SelectQuery $query, + array $keys, + mixed $filter, + string $operator, + ): TupleComparison { $types = []; $defaults = $query->getDefaultTypes(); foreach ($keys as $k) { @@ -355,10 +422,11 @@ protected function _createTupleCondition($query, $keys, $filter, $operator) * Generates a string used as a table field that contains the values upon * which the filter should be applied * - * @param array $options The options for getting the link field. - * @return string|array + * @param array $options The options for getting the link field. + * @return array|string + * @throws \Cake\Database\Exception\DatabaseException */ - protected function _linkField($options) + protected function _linkField(array $options): array|string { $links = []; $name = $this->alias; @@ -366,10 +434,10 @@ protected function _linkField($options) if ($options['foreignKey'] === false && $this->associationType === Association::ONE_TO_MANY) { $msg = 'Cannot have foreignKey = false for hasMany associations. ' . 'You must provide a foreignKey column.'; - throw new RuntimeException($msg); + throw new DatabaseException($msg); } - $keys = in_array($this->associationType, [Association::ONE_TO_ONE, Association::ONE_TO_MANY]) ? + $keys = in_array($this->associationType, [Association::ONE_TO_ONE, Association::ONE_TO_MANY], true) ? $this->foreignKey : $this->bindingKey; @@ -389,26 +457,35 @@ protected function _linkField($options) * target table, it is constructed by cloning the original query that was used * to load records in the source table. * - * @param \Cake\ORM\Query $query the original query used to load source records - * @return \Cake\ORM\Query + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query the original query used to load source records + * @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> */ - protected function _buildSubquery($query) + protected function _buildSubquery(SelectQuery $query): SelectQuery { $filterQuery = clone $query; - $filterQuery->enableAutoFields(false); + $filterQuery->disableAutoFields(); $filterQuery->mapReduce(null, null, true); $filterQuery->formatResults(null, true); $filterQuery->contain([], true); - $filterQuery->valueBinder(new ValueBinder()); + $filterQuery->setValueBinder(new ValueBinder()); + + // Only remove limit and order when BOTH are missing or when order exists without limit + // When limit exists with order, preserve both for proper subquery results + $hasLimit = $filterQuery->clause('limit') !== null; + $hasOrder = $filterQuery->clause('order') !== null; - if (!$filterQuery->clause('limit')) { + // Remove order if there's no limit to avoid SQL grouping errors + // But preserve both when they exist together + if (!$hasLimit) { $filterQuery->limit(null); - $filterQuery->order([], true); $filterQuery->offset(null); + if ($hasOrder) { + $filterQuery->orderBy([], true); + } } $fields = $this->_subqueryFields($query); - $filterQuery->select($fields['select'], true)->group($fields['group']); + $filterQuery->select($fields['select'], true)->groupBy($fields['group']); return $filterQuery; } @@ -416,14 +493,18 @@ protected function _buildSubquery($query) /** * Calculate the fields that need to participate in a subquery. * - * Normally this includes the binding key columns. If there is a an ORDER BY, - * those columns are also included as the fields may be calculated or constant values, - * that need to be present to ensure the correct association data is loaded. + * Normally this includes the binding key columns. If the subquery keeps an ORDER BY, whatever + * it sorts on joins the GROUP BY too, and aliased columns additionally stay in the SELECT list + * as they may be calculated or constant values needed to load the correct association data. + * + * When a HAVING clause is present the original SELECT aliases are preserved as + * well, since HAVING may reference computed aliases that would otherwise be + * dropped from the reduced subquery SELECT list. * - * @param \Cake\ORM\Query $query The query to get fields from. - * @return array The list of fields for the subquery. + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query The query to get fields from. + * @return array The list of fields for the subquery. */ - protected function _subqueryFields($query) + protected function _subqueryFields(SelectQuery $query): array { $keys = (array)$this->bindingKey; @@ -432,48 +513,161 @@ protected function _subqueryFields($query) } $fields = $query->aliasFields($keys, $this->sourceAlias); - $group = $fields = array_values($fields); + $group = array_values($fields); + $fields = $group; + + $columns = $query->clause('select'); + /** @var \Cake\Database\Expression\QueryExpression|null $order */ $order = $query->clause('order'); - if ($order) { - $columns = $query->clause('select'); - $order->iterateParts(function ($direction, $field) use (&$fields, $columns) { - if (isset($columns[$field])) { - $fields[$field] = $columns[$field]; + // _buildSubquery() only keeps the ORDER BY when the query is limited. + if ($order && $query->clause('limit') !== null) { + // iterateParts() rebuilds the expression from the callback's return value, so each part + // has to be handed back. Returning nothing would strip the ORDER BY from $query itself, + // which is the caller's query, not a clone of it. + $order->iterateParts(function ($direction, $field) use (&$fields, &$group, $columns) { + // A numeric key carries its own column and must not be read as a SELECT offset. + if (is_string($field) && isset($columns[$field])) { + $column = $columns[$field]; + $fields[$field] = $column; + } else { + $column = $this->orderColumn($field, $direction); + } + + // Constant values such as `select(['score' => 100])` are not columns. + if (!is_string($column) && !$column instanceof ExpressionInterface) { + return $direction; } + + if (!$this->isAggregate($column) && !in_array($column, $group, true)) { + $group[] = $column; + } + + return $direction; }); } + $having = $query->clause('having'); + if ($having instanceof QueryExpression && $having->count() > 0) { + $reserved = []; + foreach ($keys as $k) { + $reserved[strtolower(trim((string)$k, '`"[]'))] = true; + } + + $havingSql = $having->sql(new ValueBinder()); + foreach ($columns as $alias => $column) { + if (!is_string($alias) || isset($fields[$alias])) { + continue; + } + $cleanAlias = trim($alias, '`"[]'); + if (isset($reserved[strtolower($cleanAlias)])) { + continue; + } + if (preg_match('/\b' . preg_quote($cleanAlias, '/') . '\b/', $havingSql) !== 1) { + continue; + } + $fields[$alias] = $column; + + if (!$this->isAggregate($column)) { + $group[] = $column; + } + } + } + return ['select' => $fields, 'group' => $group]; } + /** + * Resolves the column an ORDER BY part sorts on, so it can join the subquery GROUP BY. + * + * Associative parts (`['Articles.title' => 'ASC']`) carry it as their key, orderByAsc()/ + * orderByDesc() and raw expressions in the value. Plain SQL fragments such as + * `orderBy('Articles.title DESC')` cannot be told apart from their direction. + * + * @param mixed $field The key of the ORDER BY part. + * @param mixed $direction The value of the ORDER BY part. + * @return \Cake\Database\ExpressionInterface|string|null The column, or null if it cannot be resolved. + */ + protected function orderColumn(mixed $field, mixed $direction): ExpressionInterface|string|null + { + if (is_string($field)) { + return $field; + } + + if ($direction instanceof FieldInterface) { + $field = $direction->getField(); + + return is_string($field) || $field instanceof ExpressionInterface ? $field : null; + } + + if ($direction instanceof ExpressionInterface) { + return $direction; + } + + return null; + } + + /** + * Checks whether a column aggregates rows, in which case it must stay out of the GROUP BY. + * + * @param mixed $column The column to check. + * @return bool + */ + protected function isAggregate(mixed $column): bool + { + if (!$column instanceof ExpressionInterface) { + return false; + } + if ($column instanceof AggregateExpression) { + return true; + } + + $isAggregate = false; + $column->traverse(function ($sub) use (&$isAggregate): void { + if ($sub instanceof AggregateExpression) { + $isAggregate = true; + } + }); + + return $isAggregate; + } + /** * Builds an array containing the results from fetchQuery indexed by * the foreignKey value corresponding to this association. * - * @param \Cake\ORM\Query $fetchQuery The query to get results from - * @param array $options The options passed to the eager loader - * @return array + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $fetchQuery The query to get results from + * @param array $options The options passed to the eager loader + * @return array */ - protected function _buildResultMap($fetchQuery, $options) + protected function _buildResultMap(SelectQuery $fetchQuery, array $options): array { $resultMap = []; - $singleResult = in_array($this->associationType, [Association::MANY_TO_ONE, Association::ONE_TO_ONE]); - $keys = in_array($this->associationType, [Association::ONE_TO_ONE, Association::ONE_TO_MANY]) ? + $singleResult = in_array($this->associationType, [Association::MANY_TO_ONE, Association::ONE_TO_ONE], true); + $keys = in_array($this->associationType, [Association::ONE_TO_ONE, Association::ONE_TO_MANY], true) ? $this->foreignKey : $this->bindingKey; $key = (array)$keys; - foreach ($fetchQuery->all() as $result) { + $preserveKeys = $fetchQuery->getOptions()['preserveKeys'] ?? false; + + foreach ($fetchQuery->all() as $i => $result) { $values = []; foreach ($key as $k) { $values[] = $result[$k]; } + if ($singleResult) { $resultMap[implode(';', $values)] = $result; - } else { - $resultMap[implode(';', $values)][] = $result; + continue; } + + if ($preserveKeys) { + $resultMap[implode(';', $values)][$i] = $result; + continue; + } + + $resultMap[implode(';', $values)][] = $result; } return $resultMap; @@ -483,13 +677,13 @@ protected function _buildResultMap($fetchQuery, $options) * Returns a callable to be used for each row in a query result set * for injecting the eager loaded rows * - * @param \Cake\ORM\Query $fetchQuery the Query used to fetch results - * @param array $resultMap an array with the foreignKey as keys and + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $fetchQuery the Query used to fetch results + * @param array $resultMap an array with the foreignKey as keys and * the corresponding target table results as value. - * @param array $options The options passed to the eagerLoader method + * @param array $options The options passed to the eagerLoader method * @return \Closure */ - protected function _resultInjector($fetchQuery, $resultMap, $options) + protected function _resultInjector(SelectQuery $fetchQuery, array $resultMap, array $options): Closure { $keys = $this->associationType === Association::MANY_TO_ONE ? $this->foreignKey : @@ -498,7 +692,7 @@ protected function _resultInjector($fetchQuery, $resultMap, $options) $sourceKeys = []; foreach ((array)$keys as $key) { $f = $fetchQuery->aliasField($key, $this->sourceAlias); - $sourceKeys[] = key($f); + $sourceKeys[] = (string)key($f); } $nestKey = $options['nestKey']; @@ -522,12 +716,12 @@ protected function _resultInjector($fetchQuery, $resultMap, $options) * for injecting the eager loaded rows when the matching needs to * be done with multiple foreign keys * - * @param array $resultMap A keyed arrays containing the target table - * @param array $sourceKeys An array with aliased keys to match + * @param array $resultMap A keyed arrays containing the target table + * @param array $sourceKeys An array with aliased keys to match * @param string $nestKey The key under which results should be nested * @return \Closure */ - protected function _multiKeysInjector($resultMap, $sourceKeys, $nestKey) + protected function _multiKeysInjector(array $resultMap, array $sourceKeys, string $nestKey): Closure { return function ($row) use ($resultMap, $sourceKeys, $nestKey) { $values = []; diff --git a/src/ORM/Association/Loader/SelectWithPivotLoader.php b/src/ORM/Association/Loader/SelectWithPivotLoader.php index 733a8c7faaa..cc64ad9541b 100644 --- a/src/ORM/Association/Loader/SelectWithPivotLoader.php +++ b/src/ORM/Association/Loader/SelectWithPivotLoader.php @@ -1,4 +1,6 @@ */ - protected $junctionAssoc; + protected HasMany $junctionAssoc; /** * Custom conditions for the junction association * - * @var string|array|\Cake\Database\ExpressionInterface|callable|null + * @var \Cake\Database\ExpressionInterface|\Closure|array|string|null */ - protected $junctionConditions; + protected ExpressionInterface|Closure|array|string|null $junctionConditions = null; /** - * {@inheritDoc} - * + * @inheritDoc */ public function __construct(array $options) { @@ -72,17 +76,18 @@ public function __construct(array $options) * * This is used for eager loading records on the target table based on conditions. * - * @param array $options options accepted by eagerLoader() - * @return \Cake\ORM\Query + * @param array $options options accepted by eagerLoader() + * @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> * @throws \InvalidArgumentException When a key is required for associations but not selected. */ - protected function _buildQuery($options) + protected function _buildQuery(array $options): SelectQuery { $name = $this->junctionAssociationName; $assoc = $this->junctionAssoc; $queryBuilder = false; if (!empty($options['queryBuilder'])) { + assert(is_callable($options['queryBuilder'])); $queryBuilder = $options['queryBuilder']; unset($options['queryBuilder']); } @@ -90,6 +95,7 @@ protected function _buildQuery($options) $query = parent::_buildQuery($options); if ($queryBuilder) { + /** @var \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query */ $query = $queryBuilder($query); } @@ -102,11 +108,12 @@ protected function _buildQuery($options) $tempName = $this->alias . '_CJoin'; $schema = $assoc->getSchema(); - $joinFields = $types = []; + $joinFields = []; + $types = []; foreach ($schema->typeMap() as $f => $type) { $key = $tempName . '__' . $f; - $joinFields[$key] = "$name.$f"; + $joinFields[$key] = "{$name}.{$f}"; $types[$key] = $type; } @@ -128,14 +135,24 @@ protected function _buildQuery($options) return $query; } + /** + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $fetchQuery The association fetching query + * @param array $key The foreign key fields to check + * @return void + */ + protected function _assertFieldsPresent(SelectQuery $fetchQuery, array $key): void + { + // _buildQuery() manually adds in required fields from junction table + } + /** * Generates a string used as a table field that contains the values upon * which the filter should be applied * - * @param array $options the options to use for getting the link field. - * @return array|string + * @param array $options the options to use for getting the link field. + * @return array|string */ - protected function _linkField($options) + protected function _linkField(array $options): array|string { $links = []; $name = $this->junctionAssociationName; @@ -145,7 +162,7 @@ protected function _linkField($options) } if (count($links) === 1) { - return $links[0]; + return array_pop($links); } return $links; @@ -155,21 +172,22 @@ protected function _linkField($options) * Builds an array containing the results from fetchQuery indexed by * the foreignKey value corresponding to this association. * - * @param \Cake\ORM\Query $fetchQuery The query to get results from - * @param array $options The options passed to the eager loader - * @return array - * @throws \RuntimeException when the association property is not part of the results set. + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $fetchQuery The query to get results from + * @param array $options The options passed to the eager loader + * @return array + * @throws \Cake\Database\Exception\DatabaseException when the association property is not part of the results set. */ - protected function _buildResultMap($fetchQuery, $options) + protected function _buildResultMap(SelectQuery $fetchQuery, array $options): array { $resultMap = []; $key = (array)$options['foreignKey']; + $preserveKeys = $fetchQuery->getOptions()['preserveKeys'] ?? false; - foreach ($fetchQuery->all() as $result) { + foreach ($fetchQuery->all() as $i => $result) { if (!isset($result[$this->junctionProperty])) { - throw new RuntimeException(sprintf( - '"%s" is missing from the belongsToMany results. Results cannot be created.', - $this->junctionProperty + throw new DatabaseException(sprintf( + '`%s` is missing from the belongsToMany results. Results cannot be created.', + $this->junctionProperty, )); } @@ -177,6 +195,12 @@ protected function _buildResultMap($fetchQuery, $options) foreach ($key as $k) { $values[] = $result[$this->junctionProperty][$k]; } + + if ($preserveKeys) { + $resultMap[implode(';', $values)][$i] = $result; + continue; + } + $resultMap[implode(';', $values)][] = $result; } diff --git a/src/ORM/AssociationCollection.php b/src/ORM/AssociationCollection.php index 4f063c2783d..05667b364d6 100644 --- a/src/ORM/AssociationCollection.php +++ b/src/ORM/AssociationCollection.php @@ -1,4 +1,6 @@ */ class AssociationCollection implements IteratorAggregate { - use AssociationsNormalizerTrait; + use LocatorAwareTrait; /** * Stored associations * - * @var \Cake\ORM\Association[] + * @var array + */ + protected array $_items = []; + + /** + * Constructor. + * + * Sets the default table locator for associations. + * If no locator is provided, the global one will be used. + * + * @param \Cake\ORM\Locator\LocatorInterface|null $tableLocator Table locator instance. */ - protected $_items = []; + public function __construct(?LocatorInterface $tableLocator = null) + { + if ($tableLocator !== null) { + $this->_tableLocator = $tableLocator; + } + } /** * Add an association to the collection @@ -43,15 +68,42 @@ class AssociationCollection implements IteratorAggregate * If the alias added contains a `.` the part preceding the `.` will be dropped. * This makes using plugins simpler as the Plugin.Class syntax is frequently used. * + * @template T of \Cake\ORM\Association * @param string $alias The association alias - * @param \Cake\ORM\Association $association The association to add. - * @return \Cake\ORM\Association The association object being added. + * @param T $association The association to add. + * @return T The association object being added. + * @throws \Cake\Core\Exception\CakeException If the alias is already added. + */ + public function add(string $alias, Association $association): Association + { + [, $alias] = pluginSplit($alias); + + if (isset($this->_items[$alias])) { + throw new CakeException(sprintf('Association alias `%s` is already set.', $alias)); + } + + return $this->_items[$alias] = $association; + } + + /** + * Creates and adds the Association object to this collection. + * + * @template T of \Cake\ORM\Association + * @param class-string $className The name of association class. + * @param string $associated The alias for the target table. + * @param array $options List of options to configure the association definition. + * @return T + * @throws \InvalidArgumentException */ - public function add($alias, Association $association) + public function load(string $className, string $associated, array $options = []): Association { - list(, $alias) = pluginSplit($alias); + $options += [ + 'tableLocator' => $this->getTableLocator(), + ]; - return $this->_items[strtolower($alias)] = $association; + $association = new $className($associated, $options); + + return $this->add($association->getName(), $association); } /** @@ -60,14 +112,9 @@ public function add($alias, Association $association) * @param string $alias The association alias to get. * @return \Cake\ORM\Association|null Either the association or null. */ - public function get($alias) + public function get(string $alias): ?Association { - $alias = strtolower($alias); - if (isset($this->_items[$alias])) { - return $this->_items[$alias]; - } - - return null; + return $this->_items[$alias] ?? null; } /** @@ -76,7 +123,7 @@ public function get($alias) * @param string $prop The property to find an association by. * @return \Cake\ORM\Association|null Either the association or null. */ - public function getByProperty($prop) + public function getByProperty(string $prop): ?Association { foreach ($this->_items as $assoc) { if ($assoc->getProperty() === $prop) { @@ -91,19 +138,19 @@ public function getByProperty($prop) * Check for an attached association by name. * * @param string $alias The association alias to get. - * @return bool Whether or not the association exists. + * @return bool Whether the association exists. */ - public function has($alias) + public function has(string $alias): bool { - return isset($this->_items[strtolower($alias)]); + return isset($this->_items[$alias]); } /** * Get the names of all the associations in the collection. * - * @return array + * @return array */ - public function keys() + public function keys(): array { return array_keys($this->_items); } @@ -111,30 +158,17 @@ public function keys() /** * Get an array of associations matching a specific type. * - * @param string|array $class The type of associations you want. + * @param array|string $class The type of associations you want. * For example 'BelongsTo' or array like ['BelongsTo', 'HasOne'] - * @return array An array of Association objects. - * @deprecated 3.5.3 Use getByType() instead. - */ - public function type($class) - { - return $this->getByType($class); - } - - /** - * Get an array of associations matching a specific type. - * - * @param string|array $class The type of associations you want. - * For example 'BelongsTo' or array like ['BelongsTo', 'HasOne'] - * @return array An array of Association objects. + * @return array<\Cake\ORM\Association> An array of Association objects. * @since 3.5.3 */ - public function getByType($class) + public function getByType(array|string $class): array { $class = array_map('strtolower', (array)$class); - $out = array_filter($this->_items, function ($assoc) use ($class) { - list(, $name) = namespaceSplit(get_class($assoc)); + $out = array_filter($this->_items, function (Association $assoc) use ($class) { + [, $name] = namespaceSplit($assoc::class); return in_array(strtolower($name), $class, true); }); @@ -145,24 +179,24 @@ public function getByType($class) /** * Drop/remove an association. * - * Once removed the association will not longer be reachable + * Once removed the association will no longer be reachable * * @param string $alias The alias name. * @return void */ - public function remove($alias) + public function remove(string $alias): void { - unset($this->_items[strtolower($alias)]); + unset($this->_items[$alias]); } /** * Remove all registered associations. * - * Once removed associations will not longer be reachable + * Once removed associations will no longer be reachable * * @return void */ - public function removeAll() + public function removeAll(): void { foreach ($this->_items as $alias => $object) { $this->remove($alias); @@ -179,12 +213,12 @@ public function removeAll() * @param \Cake\Datasource\EntityInterface $entity The entity to save associated data for. * @param array $associations The list of associations to save parents from. * associations not in this list will not be saved. - * @param array $options The options for the save operation. + * @param array $options The options for the save operation. * @return bool Success */ - public function saveParents(Table $table, EntityInterface $entity, $associations, array $options = []) + public function saveParents(Table $table, EntityInterface $entity, array $associations, array $options = []): bool { - if (empty($associations)) { + if (!$associations) { return true; } @@ -201,12 +235,12 @@ public function saveParents(Table $table, EntityInterface $entity, $associations * @param \Cake\Datasource\EntityInterface $entity The entity to save associated data for. * @param array $associations The list of associations to save children from. * associations not in this list will not be saved. - * @param array $options The options for the save operation. + * @param array $options The options for the save operation. * @return bool Success */ - public function saveChildren(Table $table, EntityInterface $entity, array $associations, array $options) + public function saveChildren(Table $table, EntityInterface $entity, array $associations, array $options): bool { - if (empty($associations)) { + if (!$associations) { return true; } @@ -219,14 +253,19 @@ public function saveChildren(Table $table, EntityInterface $entity, array $assoc * @param \Cake\ORM\Table $table The table the save is currently operating on * @param \Cake\Datasource\EntityInterface $entity The entity to save * @param array $associations Array of associations to save. - * @param array $options Original options + * @param array $options Original options * @param bool $owningSide Compared with association classes' * isOwningSide method. * @return bool Success * @throws \InvalidArgumentException When an unknown alias is used. */ - protected function _saveAssociations($table, $entity, $associations, $options, $owningSide) - { + protected function _saveAssociations( + Table $table, + EntityInterface $entity, + array $associations, + array $options, + bool $owningSide, + ): bool { unset($options['associated']); foreach ($associations as $alias => $nested) { if (is_int($alias)) { @@ -236,9 +275,9 @@ protected function _saveAssociations($table, $entity, $associations, $options, $ $relation = $this->get($alias); if (!$relation) { $msg = sprintf( - 'Cannot save %s, it is not associated to %s', + 'Cannot save `%s`, it is not associated to `%s`.', $alias, - $table->getAlias() + $table->getAlias(), ); throw new InvalidArgumentException($msg); } @@ -258,17 +297,21 @@ protected function _saveAssociations($table, $entity, $associations, $options, $ * * @param \Cake\ORM\Association $association The association object to save with. * @param \Cake\Datasource\EntityInterface $entity The entity to save - * @param array $nested Options for deeper associations - * @param array $options Original options + * @param array $nested Options for deeper associations + * @param array $options Original options * @return bool Success */ - protected function _save($association, $entity, $nested, $options) - { + protected function _save( + Association $association, + EntityInterface $entity, + array $nested, + array $options, + ): bool { if (!$entity->isDirty($association->getProperty())) { return true; } - if (!empty($nested)) { - $options = (array)$nested + $options; + if ($nested) { + $options = $nested + $options; } return (bool)$association->saveAssociated($entity, $options); @@ -279,25 +322,10 @@ protected function _save($association, $entity, $nested, $options) * Cascade first across associations for which cascadeCallbacks is true. * * @param \Cake\Datasource\EntityInterface $entity The entity to delete associations for. - * @param array $options The options used in the delete operation. - * @return void + * @param array $options The options used in the delete operation. + * @return bool */ - public function cascadeDelete(EntityInterface $entity, array $options) - { - $noCascade = $this->_getNoCascadeItems($entity, $options); - foreach ($noCascade as $assoc) { - $assoc->cascadeDelete($entity, $options); - } - } - - /** - * Returns items that have no cascade callback. - * - * @param \Cake\Datasource\EntityInterface $entity The entity to delete associations for. - * @param array $options The options used in the delete operation. - * @return \Cake\ORM\Association[] - */ - protected function _getNoCascadeItems($entity, $options) + public function cascadeDelete(EntityInterface $entity, array $options): bool { $noCascade = []; foreach ($this->_items as $assoc) { @@ -305,10 +333,20 @@ protected function _getNoCascadeItems($entity, $options) $noCascade[] = $assoc; continue; } - $assoc->cascadeDelete($entity, $options); + $success = $assoc->cascadeDelete($entity, $options); + if (!$success) { + return false; + } } - return $noCascade; + foreach ($noCascade as $assoc) { + $success = $assoc->cascadeDelete($entity, $options); + if (!$success) { + return false; + } + } + + return true; } /** @@ -316,16 +354,16 @@ protected function _getNoCascadeItems($entity, $options) * array. If true is passed, then it returns all association names * in this collection. * - * @param bool|array $keys the list of association names to normalize + * @param array|string|bool $keys the list of association names to normalize * @return array */ - public function normalizeKeys($keys) + public function normalizeKeys(array|string|bool $keys): array { if ($keys === true) { $keys = $this->keys(); } - if (empty($keys)) { + if (!$keys) { return []; } @@ -335,9 +373,9 @@ public function normalizeKeys($keys) /** * Allow looping through the associations * - * @return \ArrayIterator + * @return \Traversable */ - public function getIterator() + public function getIterator(): Traversable { return new ArrayIterator($this->_items); } diff --git a/src/ORM/AssociationsNormalizerTrait.php b/src/ORM/AssociationsNormalizerTrait.php index 335804b4187..a85c5549758 100644 --- a/src/ORM/AssociationsNormalizerTrait.php +++ b/src/ORM/AssociationsNormalizerTrait.php @@ -1,4 +1,6 @@ ['Second', 'Third']] + * - Mixed with options: ['First' => ['Second', 'onlyIds' => true]] * - * @param array $associations The array of included associations. + * @param array|string $associations The array of included associations. * @return array An array having dot notation transformed into nested arrays */ - protected function _normalizeAssociations($associations) + protected function _normalizeAssociations(array|string $associations): array { $result = []; foreach ((array)$associations as $table => $options) { - $pointer =& $result; + $pointer = &$result; if (is_int($table)) { $table = $options; $options = []; } - if (!strpos($table, '.')) { + // Handle nested array format like contain() + // Only transform if the array looks like it contains associations (not just a simple array value) + if (is_array($options) && !isset($options['associated']) && $this->_shouldExtractAssociations($options)) { + [$nestedAssociations, $actualOptions] = $this->_extractAssociations($options); + if ($nestedAssociations) { + $actualOptions['associated'] = $this->_normalizeAssociations($nestedAssociations); + } + $options = $actualOptions; + } + + if (!str_contains($table, '.')) { $result[$table] = $options; continue; } @@ -47,21 +63,114 @@ protected function _normalizeAssociations($associations) $path = explode('.', $table); $table = array_pop($path); $first = array_shift($path); + assert(is_string($first)); + $pointer += [$first => []]; - $pointer =& $pointer[$first]; + $pointer = &$pointer[$first]; $pointer += ['associated' => []]; foreach ($path as $t) { $pointer += ['associated' => []]; $pointer['associated'] += [$t => []]; $pointer['associated'][$t] += ['associated' => []]; - $pointer =& $pointer['associated'][$t]; + $pointer = &$pointer['associated'][$t]; } $pointer['associated'] += [$table => []]; $pointer['associated'][$table] = $options + $pointer['associated'][$table]; } - return isset($result['associated']) ? $result['associated'] : $result; + return $result['associated'] ?? $result; + } + + /** + * Determines if an array should have associations extracted from it. + * + * Returns true if the array appears to be mixing association names with options, + * or if it contains nested association structures (like contain() format). + * Returns false for simple arrays that should be kept as-is. + * + * Uses CakePHP naming conventions to detect associations vs options: + * - Association names start with uppercase (CamelCase): Users, Articles + * - Option keys start with lowercase (camelCase): onlyIds, conditions + * - Special data keys start with underscore: _joinData, _ids + * + * @param array $options The options array to check. + * @return bool + */ + protected function _shouldExtractAssociations(array $options): bool + { + // Empty arrays should not be transformed + if (!$options) { + return false; + } + + $hasOptionKey = false; + $hasStringKeys = false; + $hasNestedArrayValues = false; + $hasMultipleItems = count($options) > 1; + + foreach ($options as $key => $value) { + if (is_string($key)) { + $hasStringKeys = true; + // Option keys start with lowercase letter (camelCase convention) + if (preg_match('/^[a-z]/', $key)) { + $hasOptionKey = true; + } + } + // Check if value is an array (potential nested association) + if (is_array($value)) { + $hasNestedArrayValues = true; + } + } + + // Only extract associations if: + // 1. We have an option key (mixing associations and options) + // 2. We have string keys AND nested array values (contain-like format with nested associations) + // 3. We have multiple items (likely a list of associations like ['Users', 'Comments']) + return $hasOptionKey || ($hasStringKeys && $hasNestedArrayValues) || $hasMultipleItems; + } + + /** + * Extracts association names from options array, separating them from actual options. + * + * Uses CakePHP naming conventions to distinguish associations from options: + * - Association names start with uppercase (CamelCase): Users, Articles + * - Special data keys start with underscore: _joinData, _ids (treated as associations) + * - Option keys start with lowercase (camelCase): onlyIds, conditions + * + * This allows the same nested array format as contain(): + * - ['Users', 'Comments'] → associations + * - ['Users' => [...], 'Comments'] → associations + * - ['onlyIds' => true, 'validate' => false] → options only + * - ['Users', 'onlyIds' => true] → mixed + * + * @param array $options The options array that may contain nested associations. + * @return array An array with two elements: [associations, options] + */ + protected function _extractAssociations(array $options): array + { + $associations = []; + $actualOptions = []; + + foreach ($options as $key => $value) { + // Numeric keys are always association names (string values like 'Users') + if (is_int($key)) { + $associations[] = $value; + continue; + } + + // String keys starting with uppercase or underscore are associations/data keys + // This follows CakePHP conventions: CamelCase for models, _prefix for special data + if (preg_match('/^[A-Z_]/', $key)) { + $associations[$key] = $value; + continue; + } + + // Everything else (lowercase start) is an option key + $actualOptions[$key] = $value; + } + + return [$associations, $actualOptions]; } } diff --git a/src/ORM/Attribute/CollectionOf.php b/src/ORM/Attribute/CollectionOf.php new file mode 100644 index 00000000000..418d025ad85 --- /dev/null +++ b/src/ORM/Attribute/CollectionOf.php @@ -0,0 +1,50 @@ + */ - protected static $_reflectionCache = []; + protected static array $_reflectionCache = []; /** * Default configuration * * These are merged with user-provided configuration when the behavior is used. * - * @var array + * @var array */ - protected $_defaultConfig = []; + protected array $_defaultConfig = []; /** * Constructor @@ -148,19 +148,19 @@ class Behavior implements EventListenerInterface * Merges config with the default and store in the config property * * @param \Cake\ORM\Table $table The table this behavior is attached to. - * @param array $config The config for this behavior. + * @param array $config The config for this behavior. */ public function __construct(Table $table, array $config = []) { $config = $this->_resolveMethodAliases( 'implementedFinders', $this->_defaultConfig, - $config + $config, ); $config = $this->_resolveMethodAliases( 'implementedMethods', $this->_defaultConfig, - $config + $config, ); $this->_table = $table; $this->setConfig($config); @@ -173,10 +173,10 @@ public function __construct(Table $table, array $config = []) * Implement this method to avoid having to overwrite * the constructor and call parent. * - * @param array $config The configuration settings provided to this behavior. + * @param array $config The configuration settings provided to this behavior. * @return void */ - public function initialize(array $config) + public function initialize(array $config): void { } @@ -185,7 +185,7 @@ public function initialize(array $config) * * @return \Cake\ORM\Table The bound table instance. */ - public function getTable() + public function table(): Table { return $this->_table; } @@ -194,16 +194,16 @@ public function getTable() * Removes aliased methods that would otherwise be duplicated by userland configuration. * * @param string $key The key to filter. - * @param array $defaults The default method mappings. - * @param array $config The customized method mappings. + * @param array $defaults The default method mappings. + * @param array $config The customized method mappings. * @return array A de-duped list of config data. */ - protected function _resolveMethodAliases($key, $defaults, $config) + protected function _resolveMethodAliases(string $key, array $defaults, array $config): array { if (!isset($defaults[$key], $config[$key])) { return $config; } - if (isset($config[$key]) && $config[$key] === []) { + if ($config[$key] === []) { $this->setConfig($key, [], false); unset($config[$key]); @@ -213,9 +213,7 @@ protected function _resolveMethodAliases($key, $defaults, $config) $indexed = array_flip($defaults[$key]); $indexedCustom = array_flip($config[$key]); foreach ($indexed as $method => $alias) { - if (!isset($indexedCustom[$method])) { - $indexedCustom[$method] = $alias; - } + $indexedCustom[$method] ??= $alias; } $this->setConfig($key, array_flip($indexedCustom), false); unset($config[$key]); @@ -229,9 +227,9 @@ protected function _resolveMethodAliases($key, $defaults, $config) * Checks that implemented keys contain values pointing at callable. * * @return void - * @throws \Cake\Core\Exception\Exception if config are invalid + * @throws \Cake\Core\Exception\CakeException if config are invalid */ - public function verifyConfig() + public function verifyConfig(): void { $keys = ['implementedFinders', 'implementedMethods']; foreach ($keys as $key) { @@ -241,7 +239,11 @@ public function verifyConfig() foreach ($this->_config[$key] as $method) { if (!is_callable([$this, $method])) { - throw new Exception(sprintf('The method %s is not callable on class %s', $method, get_class($this))); + throw new CakeException(sprintf( + 'The method `%s` is not callable on class `%s`.', + $method, + static::class, + )); } } } @@ -256,12 +258,13 @@ public function verifyConfig() * Override this method if you need to add non-conventional event listeners. * Or if you want your behavior to listen to non-standard events. * - * @return array + * @return array */ - public function implementedEvents() + public function implementedEvents(): array { $eventMap = [ 'Model.beforeMarshal' => 'beforeMarshal', + 'Model.afterMarshal' => 'afterMarshal', 'Model.beforeFind' => 'beforeFind', 'Model.beforeSave' => 'beforeSave', 'Model.afterSave' => 'afterSave', @@ -275,7 +278,7 @@ public function implementedEvents() 'Model.afterRules' => 'afterRules', ]; $config = $this->getConfig(); - $priority = isset($config['priority']) ? $config['priority'] : null; + $priority = $config['priority'] ?? null; $events = []; foreach ($eventMap as $event => $method) { @@ -287,7 +290,7 @@ public function implementedEvents() } else { $events[$event] = [ 'callable' => $method, - 'priority' => $priority + 'priority' => $priority, ]; } } @@ -307,19 +310,20 @@ public function implementedEvents() * ] * ``` * - * With the above example, a call to `$Table->find('this')` will call `$Behavior->findThis()` - * and a call to `$Table->find('alias')` will call `$Behavior->findMethodName()` + * With the above example, a call to `$table->find('this')` will call `$behavior->findThis()` + * and a call to `$table->find('alias')` will call `$behavior->findMethodName()` * * It is recommended, though not required, to define implementedFinders in the config property * of child classes such that it is not necessary to use reflections to derive the available * method list. See core behaviors for examples * * @return array + * @throws \ReflectionException */ - public function implementedFinders() + public function implementedFinders(): array { $methods = $this->getConfig('implementedFinders'); - if (isset($methods)) { + if ($methods !== null) { return $methods; } @@ -334,23 +338,25 @@ public function implementedFinders() * ``` * [ * 'method' => 'method', - * 'aliasedmethod' => 'somethingElse' + * 'aliasedMethod' => 'somethingElse' * ] * ``` * - * With the above example, a call to `$Table->method()` will call `$Behavior->method()` - * and a call to `$Table->aliasedmethod()` will call `$Behavior->somethingElse()` + * With the above example, a call to `$table->method()` will call `$behavior->method()` + * and a call to `$table->aliasedMethod()` will call `$behavior->somethingElse()` * * It is recommended, though not required, to define implementedFinders in the config property * of child classes such that it is not necessary to use reflections to derive the available * method list. See core behaviors for examples * * @return array + * @throws \ReflectionException + * @deprecated 5.3.0 Calling behavior methods on the table instance is deprecated. */ - public function implementedMethods() + public function implementedMethods(): array { $methods = $this->getConfig('implementedMethods'); - if (isset($methods)) { + if ($methods !== null) { return $methods; } @@ -365,26 +371,27 @@ public function implementedMethods() * declared on Cake\ORM\Behavior * * @return array + * @throws \ReflectionException */ - protected function _reflectionCache() + protected function _reflectionCache(): array { - $class = get_class($this); + $class = static::class; if (isset(self::$_reflectionCache[$class])) { return self::$_reflectionCache[$class]; } $events = $this->implementedEvents(); $eventMethods = []; - foreach ($events as $e => $binding) { + foreach ($events as $binding) { if (is_array($binding) && isset($binding['callable'])) { - /* @var string $callable */ $callable = $binding['callable']; + assert(is_string($callable)); $binding = $callable; } $eventMethods[$binding] = true; } - $baseClass = 'Cake\ORM\Behavior'; + $baseClass = self::class; if (isset(self::$_reflectionCache[$baseClass])) { $baseMethods = self::$_reflectionCache[$baseClass]; } else { @@ -394,20 +401,21 @@ protected function _reflectionCache() $return = [ 'finders' => [], - 'methods' => [] + 'methods' => [], ]; $reflection = new ReflectionClass($class); foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { $methodName = $method->getName(); - if (in_array($methodName, $baseMethods) || + if ( + in_array($methodName, $baseMethods, true) || isset($eventMethods[$methodName]) ) { continue; } - if (substr($methodName, 0, 4) === 'find') { + if (str_starts_with($methodName, 'find')) { $return['finders'][lcfirst(substr($methodName, 4))] = $methodName; } else { $return['methods'][$methodName] = $methodName; diff --git a/src/ORM/Behavior/CounterCacheBehavior.php b/src/ORM/Behavior/CounterCacheBehavior.php index 39e064bea9b..811eb819b0d 100644 --- a/src/ORM/Behavior/CounterCacheBehavior.php +++ b/src/ORM/Behavior/CounterCacheBehavior.php @@ -1,4 +1,6 @@ [ - * 'posts_published' => function (Event $event, EntityInterface $entity, Table $table) { + * 'posts_published' => function (EventInterface $event, EntityInterface $entity, Table $table) { * $query = $table->find('all')->where([ * 'published' => true, * 'user_id' => $entity->get('user_id') @@ -76,6 +82,9 @@ * ] * ``` * + * When using a lambda function you can return `false` to disable updating the counter value + * for the current operation. + * * Ignore updating the field if it is dirty * ``` * [ @@ -96,32 +105,32 @@ */ class CounterCacheBehavior extends Behavior { - /** * Store the fields which should be ignored * - * @var array + * @var array> */ - protected $_ignoreDirty = []; + protected array $_ignoreDirty = []; /** * beforeSave callback. * * Check if a field, which should be ignored, is dirty * - * @param \Cake\Event\Event $event The beforeSave event that was fired + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event The beforeSave event that was fired * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved - * @param \ArrayObject $options The options for the query + * @param \ArrayObject $options The options for the query * @return void */ - public function beforeSave(Event $event, EntityInterface $entity, $options) + public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void { if (isset($options['ignoreCounterCache']) && $options['ignoreCounterCache'] === true) { return; } foreach ($this->_config as $assoc => $settings) { - $assoc = $this->_table->association($assoc); + $assoc = $this->_table->getAssociation($assoc); + /** @var string|int $field */ foreach ($settings as $field => $config) { if (is_int($field)) { continue; @@ -129,11 +138,14 @@ public function beforeSave(Event $event, EntityInterface $entity, $options) $registryAlias = $assoc->getTarget()->getRegistryAlias(); $entityAlias = $assoc->getProperty(); + /** @var \Cake\Datasource\EntityInterface $assocEntity */ + $assocEntity = $entity->$entityAlias; - if (!is_callable($config) && + if ( + !is_callable($config) && isset($config['ignoreDirty']) && $config['ignoreDirty'] === true && - $entity->$entityAlias->isDirty($field) + $assocEntity->isDirty($field) ) { $this->_ignoreDirty[$registryAlias][$field] = true; } @@ -146,12 +158,12 @@ public function beforeSave(Event $event, EntityInterface $entity, $options) * * Makes sure to update counter cache when a new record is created or updated. * - * @param \Cake\Event\Event $event The afterSave event that was fired. + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event The afterSave event that was fired. * @param \Cake\Datasource\EntityInterface $entity The entity that was saved. - * @param \ArrayObject $options The options for the query + * @param \ArrayObject $options The options for the query * @return void */ - public function afterSave(Event $event, EntityInterface $entity, $options) + public function afterSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void { if (isset($options['ignoreCounterCache']) && $options['ignoreCounterCache'] === true) { return; @@ -166,12 +178,12 @@ public function afterSave(Event $event, EntityInterface $entity, $options) * * Makes sure to update counter cache when a record is deleted. * - * @param \Cake\Event\Event $event The afterDelete event that was fired. + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event The afterDelete event that was fired. * @param \Cake\Datasource\EntityInterface $entity The entity that was deleted. - * @param \ArrayObject $options The options for the query + * @param \ArrayObject $options The options for the query * @return void */ - public function afterDelete(Event $event, EntityInterface $entity, $options) + public function afterDelete(EventInterface $event, EntityInterface $entity, ArrayObject $options): void { if (isset($options['ignoreCounterCache']) && $options['ignoreCounterCache'] === true) { return; @@ -180,17 +192,113 @@ public function afterDelete(Event $event, EntityInterface $entity, $options) $this->_processAssociations($event, $entity); } + /** + * Update counter cache for a batch of records. + * + * Counter caches configured to use closures will not be updated by the method. + * + * @param string|null $assocName The association name to update counter cache for. + * If null, all configured associations will be processed. + * @param int $limit The number of records to update per page/iteration. + * @param int|null $page The page/iteration number. If null (default), all + * records will be updated one page at a time. + * @return void + * @since 5.2.0 + */ + public function updateCounterCache(?string $assocName = null, int $limit = 100, ?int $page = null): void + { + $config = $this->_config; + if ($assocName !== null) { + $config = [$assocName => $config[$assocName]]; + } + + foreach ($config as $assoc => $settings) { + /** @var \Cake\ORM\Association\BelongsTo<\Cake\ORM\Table> $belongsTo */ + $belongsTo = $this->_table->getAssociation($assoc); + + foreach ($settings as $field => $config) { + if ($config instanceof Closure) { + // Skip counter cache fields that use a closure + continue; + } + + if (is_int($field)) { + $field = $config; + $config = []; + } + + $this->updateCountForAssociation($belongsTo, $field, $config, $limit, $page); + } + } + } + + /** + * Update counter cache for the given association. + * + * @param \Cake\ORM\Association\BelongsTo<\Cake\ORM\Table> $assoc The association object. + * @param string $field Counter cache field. + * @param array $config Config array. + * @param int $limit Limit. + * @param int|null $page Page number. + * @return void + */ + protected function updateCountForAssociation( + BelongsTo $assoc, + string $field, + array $config, + int $limit = 100, + ?int $page = null, + ): void { + $primaryKeys = (array)$assoc->getBindingKey(); + /** @var array $foreignKeys */ + $foreignKeys = (array)$assoc->getForeignKey(); + + $query = $assoc->getTarget()->find() + ->select($primaryKeys) + ->limit($limit); + + foreach ($primaryKeys as $key) { + $query->orderByAsc($key); + } + + $singlePage = $page !== null; + $page ??= 1; + + do { + $results = $query + ->page($page++) + ->all(); + + /** @var \Cake\Datasource\EntityInterface $entity */ + foreach ($results as $entity) { + $updateConditions = $entity->extract($primaryKeys); + + foreach ($updateConditions as $f => $value) { + if ($value === null) { + $updateConditions[$f . ' IS'] = $value; + unset($updateConditions[$f]); + } + } + + $countConditions = array_combine($foreignKeys, $updateConditions); + + $count = $this->_getCount($config, $countConditions); + $assoc->getTarget()->updateAll([$field => $count], $updateConditions); + } + } while (!$singlePage && $results->count() === $limit); + } + /** * Iterate all associations and update counter caches. * - * @param \Cake\Event\Event $event Event instance. + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event Event instance. * @param \Cake\Datasource\EntityInterface $entity Entity. * @return void */ - protected function _processAssociations(Event $event, EntityInterface $entity) + protected function _processAssociations(EventInterface $event, EntityInterface $entity): void { foreach ($this->_config as $assoc => $settings) { - $assoc = $this->_table->association($assoc); + $assoc = $this->_table->getAssociation($assoc); $this->_processAssociation($event, $entity, $assoc, $settings); } } @@ -198,21 +306,35 @@ protected function _processAssociations(Event $event, EntityInterface $entity) /** * Updates counter cache for a single association * - * @param \Cake\Event\Event $event Event instance. + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event Event instance. * @param \Cake\Datasource\EntityInterface $entity Entity * @param \Cake\ORM\Association $assoc The association object - * @param array $settings The settings for for counter cache for this association + * @param array $settings The settings for counter cache for this association * @return void * @throws \RuntimeException If invalid callable is passed. */ - protected function _processAssociation(Event $event, EntityInterface $entity, Association $assoc, array $settings) - { + protected function _processAssociation( + EventInterface $event, + EntityInterface $entity, + Association $assoc, + array $settings, + ): void { + /** @var array $foreignKeys */ $foreignKeys = (array)$assoc->getForeignKey(); - $primaryKeys = (array)$assoc->getBindingKey(); $countConditions = $entity->extract($foreignKeys); + + foreach ($countConditions as $field => $value) { + if ($value === null) { + $countConditions[$field . ' IS'] = $value; + unset($countConditions[$field]); + } + } + + $primaryKeys = (array)$assoc->getBindingKey(); $updateConditions = array_combine($primaryKeys, $countConditions); - $countOriginalConditions = $entity->extractOriginalChanged($foreignKeys); + $countOriginalConditions = $entity->extractOriginalChanged($foreignKeys); + $updateOriginalConditions = null; if ($countOriginalConditions !== []) { $updateOriginalConditions = array_combine($primaryKeys, $countOriginalConditions); } @@ -223,45 +345,59 @@ protected function _processAssociation(Event $event, EntityInterface $entity, As $config = []; } - if (isset($this->_ignoreDirty[$assoc->getTarget()->getRegistryAlias()][$field]) && - $this->_ignoreDirty[$assoc->getTarget()->getRegistryAlias()][$field] === true + if ( + isset($this->_ignoreDirty[$assoc->getTarget()->getRegistryAlias()][$field]) && + $this->_ignoreDirty[$assoc->getTarget()->getRegistryAlias()][$field] ) { continue; } - if (is_callable($config)) { - if (is_string($config)) { - throw new RuntimeException('You must not use a string as callable.'); + if ($this->_shouldUpdateCount($updateConditions)) { + if ($config instanceof Closure) { + $count = $config($event, $entity, $this->_table, false); + } else { + $count = $this->_getCount($config, $countConditions); + } + if ($count !== false) { + $assoc->getTarget()->updateAll([$field => $count], $updateConditions); } - $count = $config($event, $entity, $this->_table, false); - } else { - $count = $this->_getCount($config, $countConditions); } - $assoc->getTarget()->updateAll([$field => $count], $updateConditions); - - if (isset($updateOriginalConditions)) { - if (is_callable($config)) { - if (is_string($config)) { - throw new RuntimeException('You must not use a string as callable.'); - } + if ($updateOriginalConditions && $this->_shouldUpdateCount($updateOriginalConditions)) { + if ($config instanceof Closure) { $count = $config($event, $entity, $this->_table, true); } else { $count = $this->_getCount($config, $countOriginalConditions); } - $assoc->getTarget()->updateAll([$field => $count], $updateOriginalConditions); + if ($count !== false) { + $assoc->getTarget()->updateAll([$field => $count], $updateOriginalConditions); + } } } } + /** + * Checks if the count should be updated given a set of conditions. + * + * @param array $conditions Conditions to update count. + * @return bool True if the count update should happen, false otherwise. + */ + protected function _shouldUpdateCount(array $conditions): bool + { + return !empty(array_filter($conditions, function ($value) { + return $value !== null; + })); + } + /** * Fetches and returns the count for a single field in an association * - * @param array $config The counter cache configuration for a single field + * @param array $config The counter cache configuration for a single field * @param array $conditions Additional conditions given to the query - * @return int The number of relations matching the given config and conditions + * @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array>|int The query to fetch the number of + * relations matching the given config and conditions or the number itself. */ - protected function _getCount(array $config, array $conditions) + protected function _getCount(array $config, array $conditions): SelectQuery|int { $finder = 'all'; if (!empty($config['finder'])) { @@ -269,12 +405,15 @@ protected function _getCount(array $config, array $conditions) unset($config['finder']); } - if (!isset($config['conditions'])) { - $config['conditions'] = []; + $config['conditions'] = array_merge($conditions, $config['conditions'] ?? []); + $query = $this->_table->find($finder, ...$config); + + if (isset($config['useSubQuery']) && $config['useSubQuery'] === false) { + return $query->count(); } - $config['conditions'] = array_merge($conditions, $config['conditions']); - $query = $this->_table->find($finder, $config); - return $query->count(); + return $query + ->select(['count' => $query->func()->count('*')], true) + ->orderBy([], true); } } diff --git a/src/ORM/Behavior/TimestampBehavior.php b/src/ORM/Behavior/TimestampBehavior.php index 2c675665ae9..24e99314b47 100644 --- a/src/ORM/Behavior/TimestampBehavior.php +++ b/src/ORM/Behavior/TimestampBehavior.php @@ -1,4 +1,6 @@ */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ 'implementedFinders' => [], 'implementedMethods' => [ 'timestamp' => 'timestamp', - 'touch' => 'touch' + 'touch' => 'touch', ], 'events' => [ 'Model.beforeSave' => [ 'created' => 'new', - 'modified' => 'always' - ] + 'modified' => 'always', + ], ], - 'refreshTimestamp' => true + 'refreshTimestamp' => true, ]; /** * Current timestamp * - * @var \DateTime + * @var \Cake\I18n\DateTime|null */ - protected $_ts; + protected ?DateTime $_ts = null; /** * Initialize hook @@ -67,10 +73,10 @@ class TimestampBehavior extends Behavior * If events are specified - do *not* merge them with existing events, * overwrite the events to listen on * - * @param array $config The config for this behavior. + * @param array $config The config for this behavior. * @return void */ - public function initialize(array $config) + public function initialize(array $config): void { if (isset($config['events'])) { $this->setConfig('events', $config['events'], false); @@ -80,35 +86,41 @@ public function initialize(array $config) /** * There is only one event handler, it can be configured to be called for any event * - * @param \Cake\Event\Event $event Event instance. + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event Event instance. * @param \Cake\Datasource\EntityInterface $entity Entity instance. - * @throws \UnexpectedValueException if a field's when value is misdefined - * @return bool Returns true irrespective of the behavior logic, the save will not be prevented. - * @throws \UnexpectedValueException When the value for an event is not 'always', 'new' or 'existing' + * @throws \UnexpectedValueException If a field's value is misdefined. + * @throws \UnexpectedValueException When the value for an event is not 'always', 'new' or 'existing'. + * @return void */ - public function handleEvent(Event $event, EntityInterface $entity) + public function handleEvent(EventInterface $event, EntityInterface $entity): void { $eventName = $event->getName(); $events = $this->_config['events']; - $new = $entity->isNew() !== false; + $new = $entity->isNew(); $refresh = $this->_config['refreshTimestamp']; foreach ($events[$eventName] as $field => $when) { - if (!in_array($when, ['always', 'new', 'existing'])) { - throw new UnexpectedValueException( - sprintf('When should be one of "always", "new" or "existing". The passed value "%s" is invalid', $when) - ); + if (!in_array($when, ['always', 'new', 'existing'], true)) { + throw new UnexpectedValueException(sprintf( + 'When should be one of "always", "new" or "existing". The passed value `%s` is invalid.', + $when, + )); } - if ($when === 'always' || - ($when === 'new' && $new) || - ($when === 'existing' && !$new) + if ( + $when === 'always' || + ( + $when === 'new' && + $new + ) || + ( + $when === 'existing' && + !$new + ) ) { $this->_updateField($entity, $field, $refresh); } } - - return true; } /** @@ -116,10 +128,11 @@ public function handleEvent(Event $event, EntityInterface $entity) * * The implemented events of this behavior depend on configuration * - * @return array + * @return array */ - public function implementedEvents() + public function implementedEvents(): array { + /** @var array */ return array_fill_keys(array_keys($this->_config['events']), 'handleEvent'); } @@ -130,19 +143,19 @@ public function implementedEvents() * If an explicit date time is passed, the config option `refreshTimestamp` is * automatically set to false. * - * @param \DateTime|null $ts Timestamp + * @param \DateTimeInterface|null $ts Timestamp * @param bool $refreshTimestamp If true timestamp is refreshed. - * @return \DateTime + * @return \Cake\I18n\DateTime */ - public function timestamp(DateTime $ts = null, $refreshTimestamp = false) + public function timestamp(?DateTimeInterface $ts = null, bool $refreshTimestamp = false): DateTime { if ($ts) { if ($this->_config['refreshTimestamp']) { $this->_config['refreshTimestamp'] = false; } - $this->_ts = new Time($ts); + $this->_ts = new DateTime($ts); } elseif ($this->_ts === null || $refreshTimestamp) { - $this->_ts = new Time(); + $this->_ts = new DateTime(); } return $this->_ts; @@ -159,7 +172,7 @@ public function timestamp(DateTime $ts = null, $refreshTimestamp = false) * @param string $eventName Event name. * @return bool true if a field is updated, false if no action performed */ - public function touch(EntityInterface $entity, $eventName = 'Model.beforeSave') + public function touch(EntityInterface $entity, string $eventName = 'Model.beforeSave'): bool { $events = $this->_config['events']; if (empty($events[$eventName])) { @@ -170,7 +183,7 @@ public function touch(EntityInterface $entity, $eventName = 'Model.beforeSave') $refresh = $this->_config['refreshTimestamp']; foreach ($events[$eventName] as $field => $when) { - if (in_array($when, ['always', 'existing'])) { + if (in_array($when, ['always', 'existing'], true)) { $return = true; $entity->setDirty($field, false); $this->_updateField($entity, $field, $refresh); @@ -188,11 +201,28 @@ public function touch(EntityInterface $entity, $eventName = 'Model.beforeSave') * @param bool $refreshTimestamp Whether to refresh timestamp. * @return void */ - protected function _updateField($entity, $field, $refreshTimestamp) + protected function _updateField(EntityInterface $entity, string $field, bool $refreshTimestamp): void { if ($entity->isDirty($field)) { return; } - $entity->set($field, $this->timestamp(null, $refreshTimestamp)); + + $ts = $this->timestamp(null, $refreshTimestamp); + + $columnType = $this->table()->getSchema()->getColumnType($field); + if (!$columnType) { + return; + } + + $type = TypeFactory::build($columnType); + assert( + $type instanceof DateTimeType, + sprintf('TimestampBehavior only supports columns of type `%s`.', DateTimeType::class), + ); + + /** @var class-string<\Cake\I18n\DateTime> $class */ + $class = $type->getDateTimeClassName(); + + $entity->set($field, new $class($ts)); } } diff --git a/src/ORM/Behavior/Translate/EavStrategy.php b/src/ORM/Behavior/Translate/EavStrategy.php new file mode 100644 index 00000000000..c5573678286 --- /dev/null +++ b/src/ORM/Behavior/Translate/EavStrategy.php @@ -0,0 +1,548 @@ + + */ + protected array $_defaultConfig = [ + 'fields' => [], + 'translationTable' => 'I18n', + 'defaultLocale' => null, + 'referenceName' => null, + 'allowEmptyTranslations' => true, + 'onlyTranslated' => false, + 'strategy' => 'subquery', + 'tableLocator' => null, + 'validator' => false, + ]; + + /** + * Constructor + * + * @param \Cake\ORM\Table $table The table this strategy is attached to. + * @param array $config The config for this strategy. + */ + public function __construct(Table $table, array $config = []) + { + if (isset($config['tableLocator'])) { + $this->_tableLocator = $config['tableLocator']; + } + + $this->setConfig($config); + $this->table = $table; + $this->translationTable = $this->getTableLocator()->get( + $this->_config['translationTable'], + ['allowFallbackClass' => true], + ); + + $this->setupAssociations(); + } + + /** + * Creates the associations between the bound table and every field passed to + * this method. + * + * Additionally it creates a `i18n` HasMany association that will be + * used for fetching all translations for each record in the bound table. + * + * @return void + */ + protected function setupAssociations(): void + { + $fields = $this->_config['fields']; + $table = $this->_config['translationTable']; + $model = $this->_config['referenceName']; + $strategy = $this->_config['strategy']; + $filter = $this->_config['onlyTranslated']; + + $targetAlias = $this->translationTable->getAlias(); + $alias = $this->table->getAlias(); + $tableLocator = $this->getTableLocator(); + + foreach ($fields as $field) { + $name = $alias . '_' . $field . '_translation'; + + if (!$tableLocator->exists($name)) { + $fieldTable = $tableLocator->get($name, [ + 'className' => $table, + 'alias' => $name, + 'table' => $this->translationTable->getTable(), + 'allowFallbackClass' => true, + ]); + } else { + $fieldTable = $tableLocator->get($name); + } + + $conditions = [ + $name . '.model' => $model, + $name . '.field' => $field, + ]; + if (!$this->_config['allowEmptyTranslations']) { + $conditions[$name . '.content !='] = ''; + } + + if ($this->table->associations()->has($name)) { + $this->table->associations()->remove($name); + } + + $this->table->hasOne($name, [ + 'targetTable' => $fieldTable, + 'foreignKey' => 'foreign_key', + 'joinType' => $filter ? SelectQuery::JOIN_TYPE_INNER : SelectQuery::JOIN_TYPE_LEFT, + 'conditions' => $conditions, + 'propertyName' => $field . '_translation', + ]); + } + + $conditions = ["{$targetAlias}.model" => $model]; + if (!$this->_config['allowEmptyTranslations']) { + $conditions["{$targetAlias}.content !="] = ''; + } + + if ($this->table->associations()->has($targetAlias)) { + $this->table->associations()->remove($targetAlias); + } + $this->table->hasMany($targetAlias, [ + 'className' => $table, + 'foreignKey' => 'foreign_key', + 'strategy' => $strategy, + 'conditions' => $conditions, + 'propertyName' => '_i18n', + 'dependent' => true, + ]); + } + + /** + * Callback method that listens to the `beforeFind` event in the bound + * table. It modifies the passed query by eager loading the translated fields + * and adding a formatter to copy the values into the main table records. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event The beforeFind event that was fired. + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query Query + * @param \ArrayObject $options The options for the query + * @return void + */ + public function beforeFind(EventInterface $event, SelectQuery $query, ArrayObject $options): void + { + $locale = $options['locale'] ?? $this->getLocale(); + + if ($locale === $this->getConfig('defaultLocale')) { + return; + } + + $conditions = function (string $field, string $locale, SelectQuery $query, array $select) { + return function (SelectQuery $q) use ($field, $locale, $query, $select) { + $table = $q->getRepository(); + $q->where([$table->aliasField('locale') => $locale]); + + if ( + $query->isAutoFieldsEnabled() || + in_array($field, $select, true) || + in_array($this->table->aliasField($field), $select, true) + ) { + $q->select(['id', 'content']); + } + + return $q; + }; + }; + + $contain = []; + $fields = $this->_config['fields']; + $alias = $this->table->getAlias(); + $select = $query->clause('select'); + + $changeFilter = isset($options['filterByCurrentLocale']) && + $options['filterByCurrentLocale'] !== $this->_config['onlyTranslated']; + + foreach ($fields as $field) { + $name = $alias . '_' . $field . '_translation'; + + $contain[$name]['queryBuilder'] = $conditions( + $field, + $locale, + $query, + $select, + ); + + if ($changeFilter) { + $filter = $options['filterByCurrentLocale'] + ? SelectQuery::JOIN_TYPE_INNER + : SelectQuery::JOIN_TYPE_LEFT; + $contain[$name]['joinType'] = $filter; + } + } + + $query->contain($contain); + $query->formatResults( + fn(CollectionInterface $results) => $this->rowMapper($results, $locale), + SelectQuery::PREPEND, + ); + } + + /** + * Modifies the entity before it is saved so that translated fields are persisted + * in the database too. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event The beforeSave event that was fired + * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved + * @param \ArrayObject $options the options passed to the save method + * @return void + */ + public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void + { + $locale = $entity->has('_locale') ? $entity->get('_locale') : $this->getLocale(); + $newOptions = [$this->translationTable->getAlias() => ['validate' => false]]; + $options['associated'] = $newOptions + $options['associated']; + + // Check early if empty translations are present in the entity. + // If this is the case, unset them to prevent persistence. + // This only applies if $this->_config['allowEmptyTranslations'] is false + if ($this->_config['allowEmptyTranslations'] === false) { + $this->unsetEmptyFields($entity); + } + + $this->bundleTranslatedFields($entity); + /** @var array $bundled */ + $bundled = $entity->has('_i18n') ? $entity->get('_i18n') : []; + $noBundled = count($bundled) === 0; + + // No additional translation records need to be saved, + // as the entity is in the default locale. + if ($noBundled && $locale === $this->getConfig('defaultLocale')) { + return; + } + + $values = $entity->extract($this->_config['fields'], true); + $fields = array_keys($values); + $noFields = $fields === []; + + // If there are no fields and no bundled translations, or both fields + // in the default locale and bundled translations we can + // skip the remaining logic as it is not necessary. + if ($noFields && $noBundled || ($fields && $bundled)) { + return; + } + + /** @var string $primaryKey */ + $primaryKey = current((array)$this->table->getPrimaryKey()); + $key = $entity->has($primaryKey) ? $entity->get($primaryKey) : null; + + // When we have no key and bundled translations, we + // need to mark the entity dirty so the root + // entity persists. + if ($noFields && $bundled && !$key) { + foreach ($this->_config['fields'] as $field) { + $entity->setDirty($field, true); + } + + return; + } + + if ($noFields) { + return; + } + + $model = $this->_config['referenceName']; + + $preexistent = []; + if ($key) { + /** @var \Traversable $preexistent */ + $preexistent = $this->translationTable->find() + ->select(['id', 'field']) + ->where([ + 'field IN' => $fields, + 'locale' => $locale, + 'foreign_key' => $key, + 'model' => $model, + ]) + ->all() + ->indexBy('field'); + } + + $modified = []; + foreach ($preexistent as $field => $translation) { + $translation->set('content', $values[$field]); + $modified[$field] = $translation; + } + + $entityClass = $this->translationTable->getEntityClass(); + $new = array_diff_key($values, $modified); + foreach ($new as $field => $content) { + $new[$field] = new $entityClass(compact('locale', 'field', 'content', 'model'), [ + 'useSetters' => false, + 'markNew' => true, + ]); + } + + $entity->set('_i18n', array_merge($bundled, array_values($modified + $new))); + $entity->set('_locale', $locale, ['setter' => false]); + $entity->setDirty('_locale', false); + + foreach ($fields as $field) { + $entity->setDirty($field, false); + } + } + + /** + * Returns a fully aliased field name for translated fields. + * + * If the requested field is configured as a translation field, the `content` + * field with an alias of a corresponding association is returned. Table-aliased + * field name is returned for all other fields. + * + * @param string $field Field name to be aliased. + * @return string + */ + public function translationField(string $field): string + { + $table = $this->table; + if ($this->getLocale() === $this->getConfig('defaultLocale')) { + return $table->aliasField($field); + } + $associationName = $table->getAlias() . '_' . $field . '_translation'; + + if ($table->associations()->has($associationName)) { + return $associationName . '.content'; + } + + return $table->aliasField($field); + } + + /** + * Modifies the results from a table find in order to merge the translated fields + * into each entity for a given locale. + * + * @param \Cake\Collection\CollectionInterface $results Results to map. + * @param string $locale Locale string + * @return \Cake\Collection\CollectionInterface + */ + protected function rowMapper(CollectionInterface $results, string $locale): CollectionInterface + { + return $results->map(function ($row) use ($locale) { + /** @var \Cake\Datasource\EntityInterface|array|null $row */ + if ($row === null) { + return $row; + } + $hydrated = $row instanceof EntityInterface; + + foreach ($this->_config['fields'] as $field) { + $name = $field . '_translation'; + $translation = $row[$name] ?? null; + + if ($translation === null || $translation === false) { + unset($row[$name]); + continue; + } + + $content = $translation['content'] ?? null; + if ($content !== null) { + $row[$field] = $content; + + if ($hydrated) { + /** @var \Cake\Datasource\EntityInterface $row */ + $row->setDirty($field, false); + } + } + + unset($row[$name]); + } + + $row['_locale'] = $locale; + if ($hydrated) { + /** @var \Cake\Datasource\EntityInterface $row */ + $row->setDirty('_locale', false); + } + + return $row; + }); + } + + /** + * Modifies the results from a table find in order to merge full translation + * records into each entity under the `_translations` key. + * + * @param \Cake\Collection\CollectionInterface $results Results to modify. + * @return \Cake\Collection\CollectionInterface + */ + public function groupTranslations(CollectionInterface $results): CollectionInterface + { + return $results->map(function ($row) { + if (!$row instanceof EntityInterface) { + return $row; + } + + $translations = $row->has('_i18n') ? $row->get('_i18n') : []; + if ($translations === []) { + if ($row->has('_translations')) { + return $row; + } + + $row->set('_translations', []) + ->setDirty('_translations', false); + unset($row['_i18n']); + + return $row; + } + + $grouped = new Collection($translations); + + $entityClass = $this->table->getEntityClass(); + $result = []; + foreach ($grouped->combine('field', 'content', 'locale') as $locale => $keys) { + $translation = new $entityClass($keys + ['locale' => $locale], [ + 'markNew' => false, + 'useSetters' => false, + 'markClean' => true, + ]); + $result[$locale] = $translation; + } + + $row->set('_translations', $result, ['setter' => false, 'guard' => false]) + ->setDirty('_translations', false); + unset($row['_i18n']); + + return $row; + }); + } + + /** + * Helper method used to generated multiple translated field entities + * out of the data found in the `_translations` property in the passed + * entity. The result will be put into its `_i18n` property. + * + * @param \Cake\Datasource\EntityInterface $entity Entity + * @return void + */ + protected function bundleTranslatedFields(EntityInterface $entity): void + { + /** @var array $translations */ + $translations = $entity->has('_translations') ? (array)$entity->get('_translations') : []; + + if (!$translations && !$entity->isDirty('_translations')) { + return; + } + + $fields = $this->_config['fields']; + if ($entity->isNew()) { + $key = null; + } else { + $primaryKey = (array)$this->table->getPrimaryKey(); + $key = $entity->get((string)current($primaryKey)); + } + $find = []; + /** @var array<\Cake\Datasource\EntityInterface> $contents */ + $contents = []; + $entityClass = $this->translationTable->getEntityClass(); + + foreach ($translations as $lang => $translation) { + foreach ($fields as $field) { + if (!$translation->isDirty($field)) { + continue; + } + $find[] = ['locale' => $lang, 'field' => $field, 'foreign_key IS' => $key]; + $contents[] = new $entityClass(['content' => $translation->get($field)], [ + 'useSetters' => false, + ]); + } + } + + if (!$find) { + return; + } + + $results = $this->findExistingTranslations($find); + + foreach ($find as $i => $translation) { + if (!empty($results[$i])) { + $contents[$i]->set('id', $results[$i], ['setter' => false]); + $contents[$i]->setNew(false); + } else { + $translation['model'] = $this->_config['referenceName']; + unset($translation['foreign_key IS']); + if (method_exists($contents[$i], 'patch')) { + $contents[$i]->patch($translation, ['setter' => false, 'guard' => false]); + } else { + $contents[$i]->set($translation, ['setter' => false, 'guard' => false]); + } + $contents[$i]->setNew(true); + } + } + + $entity->set('_i18n', $contents); + } + + /** + * Returns the ids found for each of the condition arrays passed for the + * translations table. Each records is indexed by the corresponding position + * to the conditions array. + * + * @param array $ruleSet An array of array of conditions to be used for finding each + * @return array + */ + protected function findExistingTranslations(array $ruleSet): array + { + $association = $this->table->getAssociation($this->translationTable->getAlias()); + + $query = $association->find() + ->select(['id', 'num' => 0]) + ->where(current($ruleSet)) + ->disableHydration(); + + unset($ruleSet[0]); + foreach ($ruleSet as $i => $conditions) { + $q = $association->find() + ->select(['id', 'num' => $i]) + ->where($conditions); + $query->unionAll($q); + } + + return $query->all()->combine('num', 'id')->toArray(); + } +} diff --git a/src/ORM/Behavior/Translate/ShadowTableStrategy.php b/src/ORM/Behavior/Translate/ShadowTableStrategy.php new file mode 100644 index 00000000000..5eae820753e --- /dev/null +++ b/src/ORM/Behavior/Translate/ShadowTableStrategy.php @@ -0,0 +1,671 @@ + + */ + protected array $_defaultConfig = [ + 'fields' => [], + 'defaultLocale' => null, + 'referenceName' => null, + 'allowEmptyTranslations' => true, + 'onlyTranslated' => false, + 'strategy' => 'subquery', + 'tableLocator' => null, + 'validator' => false, + ]; + + /** + * Constructor + * + * @param \Cake\ORM\Table $table Table instance. + * @param array $config Configuration. + */ + public function __construct(Table $table, array $config = []) + { + $tableAlias = $table->getAlias(); + [$plugin] = pluginSplit($table->getRegistryAlias(), true); + $tableReferenceName = $config['referenceName']; + + $config += [ + 'mainTableAlias' => $tableAlias, + 'translationTable' => $plugin . $tableReferenceName . 'Translations', + 'hasOneAlias' => $tableAlias . 'Translation', + ]; + + if (isset($config['tableLocator'])) { + $this->_tableLocator = $config['tableLocator']; + } + + $this->setConfig($config); + $this->table = $table; + $this->translationTable = $this->getTableLocator()->get( + $this->_config['translationTable'], + ['allowFallbackClass' => true], + ); + + $this->setupAssociations(); + } + + /** + * Create a hasMany association for all records. + * + * Don't create a hasOne association here as the join conditions are modified + * in before find - so create/modify it there. + * + * @return void + */ + protected function setupAssociations(): void + { + $config = $this->getConfig(); + + $targetAlias = $this->translationTable->getAlias(); + + if ($this->table->associations()->has($targetAlias)) { + $this->table->associations()->remove($targetAlias); + } + + $this->table->hasMany($targetAlias, [ + 'className' => $config['translationTable'], + 'foreignKey' => 'id', + 'strategy' => $config['strategy'], + 'propertyName' => '_i18n', + 'dependent' => true, + ]); + } + + /** + * Callback method that listens to the `beforeFind` event in the bound + * table. It modifies the passed query by eager loading the translated fields + * and adding a formatter to copy the values into the main table records. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event The beforeFind event that was fired. + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query Query. + * @param \ArrayObject $options The options for the query. + * @return void + */ + public function beforeFind(EventInterface $event, SelectQuery $query, ArrayObject $options): void + { + $locale = $options['locale'] ?? $this->getLocale(); + $config = $this->getConfig(); + + if ($locale === $config['defaultLocale']) { + return; + } + + $this->setupHasOneAssociation($locale, $options); + + $fieldsAdded = $this->addFieldsToQuery($query, $config); + $orderByTranslatedField = $this->iterateClause($query, 'order', $config); + $filteredByTranslatedField = + $this->traverseClause($query, 'where', $config) || + $config['onlyTranslated'] || + ($options['filterByCurrentLocale'] ?? null); + + if (!$fieldsAdded && !$orderByTranslatedField && !$filteredByTranslatedField) { + return; + } + + $query->contain([$config['hasOneAlias']]); + + $query->formatResults( + fn(CollectionInterface $results) => $this->rowMapper($results, $locale), + SelectQuery::PREPEND, + ); + } + + /** + * Create a hasOne association for record with required locale. + * + * @param string $locale Locale + * @param \ArrayObject $options Find options + * @return void + */ + protected function setupHasOneAssociation(string $locale, ArrayObject $options): void + { + $config = $this->getConfig(); + + [$plugin] = pluginSplit($config['translationTable']); + $hasOneTargetAlias = $plugin ? ($plugin . '.' . $config['hasOneAlias']) : $config['hasOneAlias']; + if (!$this->getTableLocator()->exists($hasOneTargetAlias)) { + // Load table before hand with fallback class usage enabled + $this->getTableLocator()->get( + $hasOneTargetAlias, + [ + 'className' => $config['translationTable'], + 'allowFallbackClass' => true, + ], + ); + } + + if (isset($options['filterByCurrentLocale'])) { + $joinType = $options['filterByCurrentLocale'] ? 'INNER' : 'LEFT'; + } else { + $joinType = $config['onlyTranslated'] ? 'INNER' : 'LEFT'; + } + + if ($this->table->associations()->has($config['hasOneAlias'])) { + $this->table->associations()->remove($config['hasOneAlias']); + } + + $this->table->hasOne($config['hasOneAlias'], [ + 'foreignKey' => ['id'], + 'joinType' => $joinType, + 'propertyName' => 'translation', + 'className' => $config['translationTable'], + 'conditions' => [ + $config['hasOneAlias'] . '.locale' => $locale, + ], + ]); + } + + /** + * Add translation fields to query. + * + * If the query is using autofields (directly or implicitly) add the + * main table's fields to the query first. + * + * Only add translations for fields that are in the main table, always + * add the locale field though. + * + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query The query to check. + * @param array $config The config to use for adding fields. + * @return bool Whether a join to the translation table is required. + */ + protected function addFieldsToQuery(SelectQuery $query, array $config): bool + { + if ($query->isAutoFieldsEnabled()) { + return true; + } + + $select = array_filter($query->clause('select'), is_string(...)); + + if (!$select) { + return true; + } + + $alias = $config['mainTableAlias']; + $joinRequired = false; + foreach ($this->translatedFields() as $field) { + if (array_intersect($select, [$field, "{$alias}.{$field}"])) { + $joinRequired = true; + $query->select($query->aliasField($field, $config['hasOneAlias'])); + } + } + + if ($joinRequired) { + $query->select($query->aliasField('locale', $config['hasOneAlias'])); + } + + return $joinRequired; + } + + /** + * Iterate over a clause to alias fields. + * + * The objective here is to transparently prevent ambiguous field errors by + * prefixing fields with the appropriate table alias. This method currently + * expects to receive an order clause only. + * + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query the query to check. + * @param string $name The clause name. + * @param array $config The config to use for adding fields. + * @return bool Whether a join to the translation table is required. + */ + protected function iterateClause(SelectQuery $query, string $name = '', array $config = []): bool + { + $clause = $query->clause($name); + assert($clause === null || $clause instanceof QueryExpression); + if (!$clause || !$clause->count()) { + return false; + } + + $alias = $config['hasOneAlias']; + $fields = $this->translatedFields(); + $mainTableAlias = $config['mainTableAlias']; + $mainTableFields = $this->mainFields(); + $joinRequired = false; + + $clause->iterateParts( + function ($c, &$field) use ($fields, $alias, $mainTableAlias, $mainTableFields, &$joinRequired) { + if (!is_string($field) || str_contains($field, '.')) { + return $c; + } + + if (in_array($field, $fields, true)) { + $joinRequired = true; + $field = "{$alias}.{$field}"; + } elseif (in_array($field, $mainTableFields, true)) { + $field = "{$mainTableAlias}.{$field}"; + } + + return $c; + }, + ); + + return $joinRequired; + } + + /** + * Traverse over a clause to alias fields. + * + * The objective here is to transparently prevent ambiguous field errors by + * prefixing fields with the appropriate table alias. This method currently + * expects to receive a where clause only. + * + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query the query to check. + * @param string $name The clause name. + * @param array $config The config to use for adding fields. + * @return bool Whether a join to the translation table is required. + */ + protected function traverseClause(SelectQuery $query, string $name = '', array $config = []): bool + { + /** @var \Cake\Database\Expression\QueryExpression|null $clause */ + $clause = $query->clause($name); + if (!$clause || !$clause->count()) { + return false; + } + + $alias = $config['hasOneAlias']; + $fields = $this->translatedFields(); + $mainTableAlias = $config['mainTableAlias']; + $mainTableFields = $this->mainFields(); + $joinRequired = false; + + $clause->traverse( + function ($expression) use ($fields, $alias, $mainTableAlias, $mainTableFields, &$joinRequired): void { + if (!($expression instanceof FieldInterface)) { + return; + } + $field = $expression->getField(); + if (!is_string($field) || str_contains($field, '.')) { + return; + } + + if (in_array($field, $fields, true)) { + $joinRequired = true; + $expression->setField("{$alias}.{$field}"); + + return; + } + + if (in_array($field, $mainTableFields, true)) { + $expression->setField("{$mainTableAlias}.{$field}"); + } + }, + ); + + return $joinRequired; + } + + /** + * Modifies the entity before it is saved so that translated fields are persisted + * in the database too. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event The beforeSave event that was fired. + * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved. + * @param \ArrayObject $options the options passed to the save method. + * @return void + */ + public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void + { + $locale = $entity->has('_locale') ? $entity->get('_locale') : $this->getLocale(); + $newOptions = [$this->translationTable->getAlias() => ['validate' => false]]; + $options['associated'] = $newOptions + $options['associated']; + + // Check early if empty translations are present in the entity. + // If this is the case, unset them to prevent persistence. + // This only applies if $this->_config['allowEmptyTranslations'] is false + if ($this->_config['allowEmptyTranslations'] === false) { + $this->unsetEmptyFields($entity); + } + + $this->bundleTranslatedFields($entity); + $bundled = $entity->has('_i18n') ? (array)$entity->get('_i18n') : []; + $noBundled = $bundled === []; + + // No additional translation records need to be saved, + // as the entity is in the default locale. + if ($noBundled && $locale === $this->getConfig('defaultLocale')) { + return; + } + + $values = $entity->extract($this->translatedFields(), true); + $fields = array_keys($values); + $noFields = $fields === []; + + // If there are no fields and no bundled translations, or both fields + // in the default locale and bundled translations we can + // skip the remaining logic as it is not necessary. + if ($noFields && $noBundled || ($fields && $bundled)) { + return; + } + + /** @var string $primaryKey */ + $primaryKey = current((array)$this->table->getPrimaryKey()); + $id = $entity->has($primaryKey) ? $entity->get($primaryKey) : null; + + // When we have no key and bundled translations, we + // need to mark the entity dirty so the root + // entity persists. + if ($noFields && $bundled && !$id) { + foreach ($this->translatedFields() as $field) { + $entity->setDirty($field, true); + } + + return; + } + + if ($noFields) { + return; + } + + $where = ['locale' => $locale]; + $translation = null; + if ($id) { + $where['id'] = $id; + + /** @var \Cake\Datasource\EntityInterface|null $translation */ + $translation = $this->translationTable->find() + ->select(array_merge(['id', 'locale'], $fields)) + ->where($where) + ->first(); + } + + if ($translation) { + if (method_exists($translation, 'patch')) { + $translation->patch($values); + } else { + $translation->set($values); + } + } else { + $translation = new ($this->translationTable->getEntityClass())( + $where + $values, + [ + 'useSetters' => false, + 'markNew' => true, + ], + ); + } + + $entity->set('_i18n', array_merge($bundled, [$translation])); + $entity->set('_locale', $locale, ['setter' => false]); + $entity->setDirty('_locale', false); + + foreach ($fields as $field) { + $entity->setDirty($field, false); + } + } + + /** + * @inheritDoc + */ + public function buildMarshalMap(Marshaller $marshaller, array $map, array $options): array + { + $this->translatedFields(); + + return $this->_buildMarshalMap($marshaller, $map, $options); + } + + /** + * Returns a fully aliased field name for translated fields. + * + * If the requested field is configured as a translation field, field with + * an alias of a corresponding association is returned. Table-aliased + * field name is returned for all other fields. + * + * @param string $field Field name to be aliased. + * @return string + */ + public function translationField(string $field): string + { + if ($this->getLocale() === $this->getConfig('defaultLocale')) { + return $this->table->aliasField($field); + } + + $translatedFields = $this->translatedFields(); + if (in_array($field, $translatedFields, true)) { + return $this->getConfig('hasOneAlias') . '.' . $field; + } + + return $this->table->aliasField($field); + } + + /** + * Modifies the results from a table find in order to merge the translated + * fields into each entity for a given locale. + * + * @param \Cake\Collection\CollectionInterface $results Results to map. + * @param string $locale Locale string + * @return \Cake\Collection\CollectionInterface + */ + protected function rowMapper(CollectionInterface $results, string $locale): CollectionInterface + { + $allowEmpty = $this->_config['allowEmptyTranslations']; + + return $results->map(function ($row) use ($allowEmpty, $locale) { + /** @var \Cake\Datasource\EntityInterface|array|null $row */ + if ($row === null) { + return $row; + } + + $hydrated = $row instanceof EntityInterface; + + if (empty($row['translation'])) { + $row['_locale'] = $locale; + unset($row['translation']); + + if ($hydrated) { + /** @var \Cake\Datasource\EntityInterface $row */ + $row->setDirty('_locale', false); + } + + return $row; + } + + $translation = $row['translation']; + assert($translation instanceof EntityInterface || is_array($translation)); + + if ($hydrated) { + /** @var \Cake\Datasource\EntityInterface $translation */ + $keys = $translation->getVisible(); + } else { + /** @var non-empty-array $translation */ + $keys = array_keys($translation); + } + + foreach ($keys as $field) { + if ($field === 'locale') { + $row['_locale'] = $translation[$field]; + continue; + } + + if ($translation[$field] !== null && ($allowEmpty || $translation[$field] !== '')) { + $row[$field] = $translation[$field]; + if ($hydrated) { + /** @var \Cake\Datasource\EntityInterface $row */ + $row->setDirty($field, false); + } + } + } + + unset($row['translation']); + + if ($hydrated) { + /** @var \Cake\Datasource\EntityInterface $row */ + $row->setDirty('_locale', false); + } + + return $row; + }); + } + + /** + * Modifies the results from a table find in order to merge full translation + * records into each entity under the `_translations` key. + * + * @param \Cake\Collection\CollectionInterface $results Results to modify. + * @return \Cake\Collection\CollectionInterface + */ + public function groupTranslations(CollectionInterface $results): CollectionInterface + { + return $results->map(function ($row) { + if (!$row instanceof EntityInterface) { + return $row; + } + + $translations = $row->has('_i18n') ? $row->get('_i18n') : []; + if ($translations === []) { + if ($row->has('_translations')) { + return $row; + } + + $row->set('_translations', []) + ->setDirty('_translations', false); + unset($row['_i18n']); + + return $row; + } + + $result = []; + foreach ($translations as $translation) { + unset($translation['id']); + $result[$translation['locale']] = $translation; + } + + $row->set('_translations', $result) + ->setDirty('_translations', false); + unset($row['_i18n']); + + return $row; + }); + } + + /** + * Helper method used to generated multiple translated field entities + * out of the data found in the `_translations` property in the passed + * entity. The result will be put into its `_i18n` property. + * + * @param \Cake\Datasource\EntityInterface $entity Entity. + * @return void + */ + protected function bundleTranslatedFields(EntityInterface $entity): void + { + /** @var array $translations */ + $translations = $entity->has('_translations') ? (array)$entity->get('_translations') : []; + + if (!$translations && !$entity->isDirty('_translations')) { + return; + } + + if ($entity->isNew()) { + $key = null; + } else { + $primaryKey = (array)$this->table->getPrimaryKey(); + $key = $entity->get((string)current($primaryKey)); + } + + foreach ($translations as $lang => $translation) { + if ($translation->isNew()) { + $update = [ + 'locale' => $lang, + ]; + if ($key !== null) { + $update['id'] = $key; + } + if (method_exists($translation, 'patch')) { + $translation->patch($update, ['guard' => false]); + } else { + $translation->set($update, ['guard' => false]); + } + } + } + + $entity->set('_i18n', $translations); + } + + /** + * Lazy define and return the main table fields. + * + * @return array + */ + protected function mainFields(): array + { + /** @var array $fields */ + $fields = $this->getConfig('mainTableFields'); + + if ($fields) { + return $fields; + } + + $fields = $this->table->getSchema()->columns(); + + $this->setConfig('mainTableFields', $fields); + + return $fields; + } + + /** + * Lazy define and return the translation table fields. + * + * @return array + */ + protected function translatedFields(): array + { + $fields = $this->getConfig('fields'); + + if ($fields) { + return $fields; + } + + $table = $this->translationTable; + $fields = $table->getSchema()->columns(); + $fields = array_values(array_diff($fields, ['id', 'locale'])); + + $this->setConfig('fields', $fields); + + return $fields; + } +} diff --git a/src/ORM/Behavior/Translate/TranslateStrategyInterface.php b/src/ORM/Behavior/Translate/TranslateStrategyInterface.php new file mode 100644 index 00000000000..aaf9aa4f516 --- /dev/null +++ b/src/ORM/Behavior/Translate/TranslateStrategyInterface.php @@ -0,0 +1,119 @@ + $results Results to modify. + * @return \Cake\Collection\CollectionInterface + */ + public function groupTranslations(ResultSetInterface $results): CollectionInterface; + + /** + * Callback method that listens to the `beforeFind` event in the bound + * table. It modifies the passed query by eager loading the translated fields + * and adding a formatter to copy the values into the main table records. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event The beforeFind event that was fired. + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query Query + * @param \ArrayObject $options The options for the query + * @return void + */ + public function beforeFind(EventInterface $event, SelectQuery $query, ArrayObject $options): void; + + /** + * Modifies the entity before it is saved so that translated fields are persisted + * in the database too. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event The beforeSave event that was fired + * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved + * @param \ArrayObject $options the options passed to the save method + * @return void + */ + public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void; + + /** + * Unsets the temporary `_i18n` property after the entity has been saved + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event The beforeSave event that was fired + * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved + * @return void + */ + public function afterSave(EventInterface $event, EntityInterface $entity): void; +} diff --git a/src/ORM/Behavior/Translate/TranslateStrategyTrait.php b/src/ORM/Behavior/Translate/TranslateStrategyTrait.php new file mode 100644 index 00000000000..5571f83f23d --- /dev/null +++ b/src/ORM/Behavior/Translate/TranslateStrategyTrait.php @@ -0,0 +1,205 @@ +translationTable; + } + + /** + * Sets the locale to be used. + * + * When fetching records, the content for the locale set via this method, + * and likewise when saving data, it will save the data in that locale. + * + * Note that in case an entity has a `_locale` property set, that locale + * will win over the locale set via this method (and over the globally + * configured one for that matter)! + * + * @param string|null $locale The locale to use for fetching and saving + * records. Pass `null` in order to unset the current locale, and to make + * the behavior falls back to using the globally configured locale. + * @return $this + */ + public function setLocale(?string $locale) + { + $this->locale = $locale; + + return $this; + } + + /** + * Returns the current locale. + * + * If no locale has been explicitly set via `setLocale()`, this method will return + * the currently configured global locale excluding any options set after @. + * + * @return string + * @see \Cake\I18n\I18n::getLocale() + * @see \Cake\ORM\Behavior\TranslateBehavior::setLocale() + */ + public function getLocale(): string + { + return $this->locale ?: explode('@', I18n::getLocale())[0]; + } + + /** + * Unset empty translations to avoid persistence. + * + * Should only be called if $this->_config['allowEmptyTranslations'] is false. + * + * @param \Cake\Datasource\EntityInterface $entity The entity to check for empty translations fields inside. + * @return void + */ + protected function unsetEmptyFields(EntityInterface $entity): void + { + if (!$entity->has('_translations')) { + return; + } + + /** @var array<\Cake\Datasource\EntityInterface> $translations */ + $translations = $entity->get('_translations'); + foreach ($translations as $locale => $translation) { + $fields = $translation->extract($this->_config['fields'], false); + foreach ($fields as $field => $value) { + if ($value === null || $value === '') { + $translation->unset($field); + } + } + + $translation = $translation->extract($this->_config['fields']); + + // If now, the current locale property is empty, + // unset it completely. + if (array_filter($translation) === []) { + unset($translations[$locale]); + } + } + + // If now, the whole $translations is empty, unset _translations property completely + if ($translations === []) { + $entity->unset('_translations'); + } else { + $entity->set('_translations', $translations); + } + } + + /** + * Build a set of properties that should be included in the marshaling process. + + * Add in `_translations` marshaling handlers. You can disable marshaling + * of translations by setting `'translations' => false` in the options + * provided to `Table::newEntity()` or `Table::patchEntity()`. + * + * @param \Cake\ORM\Marshaller<\Cake\Datasource\EntityInterface> $marshaller The marshaler of the table the behavior is attached to. + * @param array $map The property map being built. + * @param array $options The options array used in the marshaling call. + * @return array A map of `[property => callable]` of additional properties to marshal. + */ + public function buildMarshalMap(Marshaller $marshaller, array $map, array $options): array + { + if (isset($options['translations']) && !$options['translations']) { + return []; + } + + return [ + '_translations' => function ($value, EntityInterface $entity) use ($marshaller, $options) { + if (!is_array($value)) { + return null; + } + + /** @var array $translations */ + $translations = $entity->has('_translations') ? (array)$entity->get('_translations') : []; + + $options['validate'] = $this->_config['validator']; + $errors = []; + foreach ($value as $language => $fields) { + $translations[$language] ??= $this->table->newEmptyEntity(); + $marshaller->merge($translations[$language], $fields, $options); + + $translationErrors = $translations[$language]->getErrors(); + if ($translationErrors) { + $errors[$language] = $translationErrors; + } + } + + // Set errors into the root entity, so validation errors match the original form data position. + if ($errors) { + $entity->setErrors(['_translations' => $errors]); + } + + return $translations; + }, + ]; + } + + /** + * Unsets the temporary `_i18n` property after the entity has been saved + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event The beforeSave event that was fired + * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved + * @return void + */ + public function afterSave(EventInterface $event, EntityInterface $entity): void + { + $entity->unset('_i18n'); + } +} diff --git a/src/ORM/Behavior/Translate/TranslateTrait.php b/src/ORM/Behavior/Translate/TranslateTrait.php index 343740e2381..2e500295634 100644 --- a/src/ORM/Behavior/Translate/TranslateTrait.php +++ b/src/ORM/Behavior/Translate/TranslateTrait.php @@ -1,4 +1,6 @@ get('_locale')) { return $this; } - $i18n = $this->get('_translations'); + $i18n = $this->has('_translations') ? $this->get('_translations') : null; $created = false; - if (empty($i18n)) { + if (!$i18n) { $i18n = []; $created = true; } if ($created || empty($i18n[$language]) || !($i18n[$language] instanceof EntityInterface)) { - $className = get_class($this); + $className = static::class; $i18n[$language] = new $className(); $created = true; diff --git a/src/ORM/Behavior/TranslateBehavior.php b/src/ORM/Behavior/TranslateBehavior.php index 8ec2e3980c2..4d9c2f3d8b0 100644 --- a/src/ORM/Behavior/TranslateBehavior.php +++ b/src/ORM/Behavior/TranslateBehavior.php @@ -1,4 +1,6 @@ */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ 'implementedFinders' => ['translations' => 'findTranslations'], 'implementedMethods' => [ - 'locale' => 'locale', - 'translationField' => 'translationField' + 'setLocale' => 'setLocale', + 'getLocale' => 'getLocale', + 'translationField' => 'translationField', + 'getStrategy' => 'getStrategy', ], 'fields' => [], - 'translationTable' => 'I18n', - 'defaultLocale' => '', + 'defaultLocale' => null, 'referenceName' => '', 'allowEmptyTranslations' => true, 'onlyTranslated' => false, 'strategy' => 'subquery', 'tableLocator' => null, - 'validator' => false + 'validator' => false, + 'strategyClass' => null, ]; + /** + * Default strategy class name. + * + * @var class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> + */ + protected static string $defaultStrategyClass = ShadowTableStrategy::class; + + /** + * Translation strategy instance. + * + * @var \Cake\ORM\Behavior\Translate\TranslateStrategyInterface|null + */ + protected ?TranslateStrategyInterface $strategy = null; + /** * Constructor * + * ### Options + * + * - `fields`: List of fields which need to be translated. Providing this fields + * list is mandatory when using `EavStrategy`. If the fields list is empty when + * using `ShadowTableStrategy` then the list will be auto generated based on + * shadow table schema. + * - `defaultLocale`: The locale which is treated as default by the behavior. + * Fields values for default locale will be stored in the primary table itself + * and the rest in translation table. If not explicitly set the value of + * `I18n::getDefaultLocale()` will be used to get default locale. + * If you do not want any default locale and want translated fields + * for all locales to be stored in translation table then set this config + * to empty string `''`. + * - `allowEmptyTranslations`: By default if a record has been translated and + * stored as an empty string the translate behavior will take and use this + * value to overwrite the original field value. If you don't want this behavior + * then set this option to `false`. + * - `validator`: The validator that should be used when translation records + * are created/modified. Default `null`. + * * @param \Cake\ORM\Table $table The table this behavior is attached to. - * @param array $config The config for this behavior. + * @param array $config The config for this behavior. */ public function __construct(Table $table, array $config = []) { $config += [ 'defaultLocale' => I18n::getDefaultLocale(), - 'referenceName' => $this->_referenceName($table) + 'referenceName' => $this->referenceName($table), + 'tableLocator' => $table->associations()->getTableLocator(), ]; - if (isset($config['tableLocator'])) { - $this->_tableLocator = $config['tableLocator']; - } - parent::__construct($table, $config); } /** * Initialize hook * - * @param array $config The config for this behavior. + * @param array $config The config for this behavior. * @return void */ - public function initialize(array $config) + public function initialize(array $config): void { - $this->_translationTable = $this->getTableLocator()->get($this->_config['translationTable']); - - $this->setupFieldAssociations( - $this->_config['fields'], - $this->_config['translationTable'], - $this->_config['referenceName'], - $this->_config['strategy'] - ); + $this->getStrategy(); } /** - * Creates the associations between the bound table and every field passed to - * this method. - * - * Additionally it creates a `i18n` HasMany association that will be - * used for fetching all translations for each record in the bound table - * - * @param array $fields list of fields to create associations for - * @param string $table the table name to use for storing each field translation - * @param string $model the model field value - * @param string $strategy the strategy used in the _i18n association + * Set default strategy class name. * + * @param class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> $class Class name. * @return void + * @since 4.0.0 */ - public function setupFieldAssociations($fields, $table, $model, $strategy) + public static function setDefaultStrategyClass(string $class): void { - $targetAlias = $this->_translationTable->getAlias(); - $alias = $this->_table->getAlias(); - $filter = $this->_config['onlyTranslated']; - $tableLocator = $this->getTableLocator(); - - foreach ($fields as $field) { - $name = $alias . '_' . $field . '_translation'; - - if (!$tableLocator->exists($name)) { - $fieldTable = $tableLocator->get($name, [ - 'className' => $table, - 'alias' => $name, - 'table' => $this->_translationTable->getTable() - ]); - } else { - $fieldTable = $tableLocator->get($name); - } - - $conditions = [ - $name . '.model' => $model, - $name . '.field' => $field, - ]; - if (!$this->_config['allowEmptyTranslations']) { - $conditions[$name . '.content !='] = ''; - } - - $this->_table->hasOne($name, [ - 'targetTable' => $fieldTable, - 'foreignKey' => 'foreign_key', - 'joinType' => $filter ? QueryInterface::JOIN_TYPE_INNER : QueryInterface::JOIN_TYPE_LEFT, - 'conditions' => $conditions, - 'propertyName' => $field . '_translation' - ]); - } - - $conditions = ["$targetAlias.model" => $model]; - if (!$this->_config['allowEmptyTranslations']) { - $conditions["$targetAlias.content !="] = ''; - } - - $this->_table->hasMany($targetAlias, [ - 'className' => $table, - 'foreignKey' => 'foreign_key', - 'strategy' => $strategy, - 'conditions' => $conditions, - 'propertyName' => '_i18n', - 'dependent' => true - ]); + static::$defaultStrategyClass = $class; } /** - * Callback method that listens to the `beforeFind` event in the bound - * table. It modifies the passed query by eager loading the translated fields - * and adding a formatter to copy the values into the main table records. + * Get default strategy class name. * - * @param \Cake\Event\Event $event The beforeFind event that was fired. - * @param \Cake\ORM\Query $query Query - * @param \ArrayObject $options The options for the query - * @return void + * @return class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> + * @since 4.0.0 */ - public function beforeFind(Event $event, Query $query, $options) + public static function getDefaultStrategyClass(): string { - $locale = $this->locale(); - - if ($locale === $this->getConfig('defaultLocale')) { - return; - } - - $conditions = function ($field, $locale, $query, $select) { - return function ($q) use ($field, $locale, $query, $select) { - /* @var \Cake\Datasource\QueryInterface $q */ - $q->where([$q->repository()->aliasField('locale') => $locale]); - - /* @var \Cake\ORM\Query $query */ - if ($query->isAutoFieldsEnabled() || - in_array($field, $select, true) || - in_array($this->_table->aliasField($field), $select, true) - ) { - $q->select(['id', 'content']); - } - - return $q; - }; - }; + return static::$defaultStrategyClass; + } - $contain = []; - $fields = $this->_config['fields']; - $alias = $this->_table->getAlias(); - $select = $query->clause('select'); + /** + * Get strategy class instance. + * + * @return \Cake\ORM\Behavior\Translate\TranslateStrategyInterface + * @since 4.0.0 + */ + public function getStrategy(): TranslateStrategyInterface + { + return $this->strategy ??= $this->createStrategy(); + } - $changeFilter = isset($options['filterByCurrentLocale']) && - $options['filterByCurrentLocale'] !== $this->_config['onlyTranslated']; + /** + * Create strategy instance. + * + * @return \Cake\ORM\Behavior\Translate\TranslateStrategyInterface + * @since 4.0.0 + */ + protected function createStrategy(): TranslateStrategyInterface + { + $config = array_diff_key( + $this->_config, + ['implementedFinders', 'implementedMethods', 'strategyClass'], + ); + /** @var class-string<\Cake\ORM\Behavior\Translate\TranslateStrategyInterface> $className */ + $className = $this->getConfig('strategyClass', static::$defaultStrategyClass); - foreach ($fields as $field) { - $name = $alias . '_' . $field . '_translation'; + return new $className($this->_table, $config); + } - $contain[$name]['queryBuilder'] = $conditions( - $field, - $locale, - $query, - $select - ); + /** + * Set strategy class instance. + * + * @param \Cake\ORM\Behavior\Translate\TranslateStrategyInterface $strategy Strategy class instance. + * @return $this + * @since 4.0.0 + */ + public function setStrategy(TranslateStrategyInterface $strategy) + { + $this->strategy = $strategy; - if ($changeFilter) { - $filter = $options['filterByCurrentLocale'] ? QueryInterface::JOIN_TYPE_INNER : QueryInterface::JOIN_TYPE_LEFT; - $contain[$name]['joinType'] = $filter; - } - } + return $this; + } - $query->contain($contain); - $query->formatResults(function ($results) use ($locale) { - return $this->_rowMapper($results, $locale); - }, $query::PREPEND); + /** + * Gets the Model callbacks this behavior is interested in. + * + * @return array + */ + public function implementedEvents(): array + { + return [ + 'Model.beforeFind' => 'beforeFind', + 'Model.beforeMarshal' => 'beforeMarshal', + 'Model.beforeSave' => 'beforeSave', + 'Model.afterSave' => 'afterSave', + ]; } /** - * Modifies the entity before it is saved so that translated fields are persisted - * in the database too. + * Hoist fields for the default locale under `_translations` key to the root + * in the data. * - * @param \Cake\Event\Event $event The beforeSave event that was fired - * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved - * @param \ArrayObject $options the options passed to the save method + * This allows `_translations.{locale}.field_name` type naming even for the + * default locale in forms. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event The event that was fired. + * @param \ArrayObject $data The data being marshalled. + * @param \ArrayObject $options The options for marshalling. * @return void */ - public function beforeSave(Event $event, EntityInterface $entity, ArrayObject $options) + public function beforeMarshal(EventInterface $event, ArrayObject $data, ArrayObject $options): void { - $locale = $entity->get('_locale') ?: $this->locale(); - $newOptions = [$this->_translationTable->getAlias() => ['validate' => false]]; - $options['associated'] = $newOptions + $options['associated']; - - // Check early if empty translations are present in the entity. - // If this is the case, unset them to prevent persistence. - // This only applies if $this->_config['allowEmptyTranslations'] is false - if ($this->_config['allowEmptyTranslations'] === false) { - $this->_unsetEmptyFields($entity); - } - - $this->_bundleTranslatedFields($entity); - $bundled = $entity->get('_i18n') ?: []; - $noBundled = count($bundled) === 0; - - // No additional translation records need to be saved, - // as the entity is in the default locale. - if ($noBundled && $locale === $this->getConfig('defaultLocale')) { - return; - } - - $values = $entity->extract($this->_config['fields'], true); - $fields = array_keys($values); - $noFields = empty($fields); - - // If there are no fields and no bundled translations, or both fields - // in the default locale and bundled translations we can - // skip the remaining logic as its not necessary. - if ($noFields && $noBundled || ($fields && $bundled)) { - return; - } - - $primaryKey = (array)$this->_table->getPrimaryKey(); - $key = $entity->get(current($primaryKey)); - - // When we have no key and bundled translations, we - // need to mark the entity dirty so the root - // entity persists. - if ($noFields && $bundled && !$key) { - foreach ($this->_config['fields'] as $field) { - $entity->setDirty($field, true); - } - + if (isset($options['translations']) && !$options['translations']) { return; } - if ($noFields) { + $defaultLocale = $this->getConfig('defaultLocale'); + if (!isset($data['_translations'][$defaultLocale])) { return; } - $model = $this->_config['referenceName']; - $preexistent = $this->_translationTable->find() - ->select(['id', 'field']) - ->where([ - 'field IN' => $fields, - 'locale' => $locale, - 'foreign_key' => $key, - 'model' => $model - ]) - ->enableBufferedResults(false) - ->all() - ->indexBy('field'); - - $modified = []; - foreach ($preexistent as $field => $translation) { - $translation->set('content', $values[$field]); - $modified[$field] = $translation; - } - - $new = array_diff_key($values, $modified); - foreach ($new as $field => $content) { - $new[$field] = new Entity(compact('locale', 'field', 'content', 'model'), [ - 'useSetters' => false, - 'markNew' => true - ]); + foreach ($data['_translations'][$defaultLocale] as $field => $value) { + $data[$field] = $value; } - $entity->set('_i18n', array_merge($bundled, array_values($modified + $new))); - $entity->set('_locale', $locale, ['setter' => false]); - $entity->setDirty('_locale', false); - - foreach ($fields as $field) { - $entity->setDirty($field, false); - } + unset($data['_translations'][$defaultLocale]); } /** - * Unsets the temporary `_i18n` property after the entity has been saved + * {@inheritDoc} * - * @param \Cake\Event\Event $event The beforeSave event that was fired - * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved - * @return void + * Add in `_translations` marshaling handlers. You can disable marshaling + * of translations by setting `'translations' => false` in the options + * provided to `Table::newEntity()` or `Table::patchEntity()`. + * + * @param \Cake\ORM\Marshaller<\Cake\Datasource\EntityInterface> $marshaller The marshaler of the table the behavior is attached to. + * @param array $map The property map being built. + * @param array $options The options array used in the marshaling call. + * @return array A map of `[property => callable]` of additional properties to marshal. */ - public function afterSave(Event $event, EntityInterface $entity) + public function buildMarshalMap(Marshaller $marshaller, array $map, array $options): array { - $entity->unsetProperty('_i18n'); + return $this->getStrategy()->buildMarshalMap($marshaller, $map, $options); } /** - * Add in `_translations` marshalling handlers. You can disable marshalling - * of translations by setting `'translations' => false` in the options - * provided to `Table::newEntity()` or `Table::patchEntity()`. + * Sets the locale that should be used for all future find and save operations on + * the table where this behavior is attached to. * - * {@inheritDoc} + * When fetching records, the behavior will include the content for the locale set + * via this method, and likewise when saving data, it will save the data in that + * locale. + * + * Note that in case an entity has a `_locale` property set, that locale will win + * over the locale set via this method (and over the globally configured one for + * that matter)! + * + * @param string|null $locale The locale to use for fetching and saving records. Pass `null` + * in order to unset the current locale, and to make the behavior falls back to using the + * globally configured locale. + * @return $this + * @see \Cake\ORM\Behavior\TranslateBehavior::getLocale() + * @link https://book.cakephp.org/5/en/orm/behaviors/translate.html#retrieving-one-language-without-using-i18n-setlocale + * @link https://book.cakephp.org/5/en/orm/behaviors/translate.html#saving-in-another-language */ - public function buildMarshalMap($marshaller, $map, $options) + public function setLocale(?string $locale) { - if (isset($options['translations']) && !$options['translations']) { - return []; - } - - return [ - '_translations' => function ($value, $entity) use ($marshaller, $options) { - /* @var \Cake\Datasource\EntityInterface $entity */ - $translations = $entity->get('_translations'); - foreach ($this->_config['fields'] as $field) { - $options['validate'] = $this->_config['validator']; - $errors = []; - if (!is_array($value)) { - return null; - } - foreach ($value as $language => $fields) { - if (!isset($translations[$language])) { - $translations[$language] = $this->_table->newEntity(); - } - $marshaller->merge($translations[$language], $fields, $options); - if ((bool)$translations[$language]->getErrors()) { - $errors[$language] = $translations[$language]->getErrors(); - } - } - // Set errors into the root entity, so validation errors - // match the original form data position. - $entity->setErrors($errors); - } + $this->getStrategy()->setLocale($locale); - return $translations; - } - ]; + return $this; } /** - * Sets all future finds for the bound table to also fetch translated fields for - * the passed locale. If no value is passed, it returns the currently configured - * locale + * Returns the current locale. + * + * If no locale has been explicitly set via `setLocale()`, this method will return + * the currently configured global locale. * - * @param string|null $locale The locale to use for fetching translated records * @return string + * @see \Cake\I18n\I18n::getLocale() + * @see \Cake\ORM\Behavior\TranslateBehavior::setLocale() */ - public function locale($locale = null) + public function getLocale(): string { - if ($locale === null) { - return $this->_locale ?: I18n::getLocale(); - } - - return $this->_locale = (string)$locale; + return $this->getStrategy()->getLocale(); } /** @@ -438,19 +312,9 @@ public function locale($locale = null) * @param string $field Field name to be aliased. * @return string */ - public function translationField($field) + public function translationField(string $field): string { - $table = $this->_table; - if ($this->locale() === $this->getConfig('defaultLocale')) { - return $table->aliasField($field); - } - $associationName = $table->getAlias() . '_' . $field . '_translation'; - - if ($table->associations()->has($associationName)) { - return $associationName . '.content'; - } - - return $table->aliasField($field); + return $this->getStrategy()->translationField($field); } /** @@ -464,32 +328,42 @@ public function translationField($field) * ### Example: * * ``` - * $article = $articles->find('translations', ['locales' => ['eng', 'deu'])->first(); + * $article = $articles->find('translations', locales: ['eng', 'deu'])->first(); * $englishTranslatedFields = $article->get('_translations')['eng']; * ``` * * If the `locales` array is not passed, it will bring all translations found * for each record. * - * @param \Cake\ORM\Query $query The original query to modify - * @param array $options Options - * @return \Cake\ORM\Query + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query The original query to modify + * @param array $locales A list of locales or options with the `locales` key defined + * @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> */ - public function findTranslations(Query $query, array $options) + public function findTranslations(SelectQuery $query, array $locales = []): SelectQuery { - $locales = isset($options['locales']) ? $options['locales'] : []; - $targetAlias = $this->_translationTable->getAlias(); + $targetAlias = $this->getStrategy()->getTranslationTable()->getAlias(); return $query - ->contain([$targetAlias => function ($query) use ($locales, $targetAlias) { + ->contain([$targetAlias => function (QueryInterface $query) use ($locales, $targetAlias) { if ($locales) { - /* @var \Cake\Datasource\QueryInterface $query */ - $query->where(["$targetAlias.locale IN" => $locales]); + $query->where(["{$targetAlias}.locale IN" => $locales]); } return $query; }]) - ->formatResults([$this, 'groupTranslations'], $query::PREPEND); + ->formatResults($this->getStrategy()->groupTranslations(...), SelectQuery::PREPEND); + } + + /** + * Proxy method calls to strategy class instance. + * + * @param string $method Method name. + * @param array $args Method arguments. + * @return mixed + */ + public function __call(string $method, array $args): mixed + { + return $this->getStrategy()->{$method}(...$args); } /** @@ -503,214 +377,15 @@ public function findTranslations(Query $query, array $options) * @param \Cake\ORM\Table $table The table class to get a reference name for. * @return string */ - protected function _referenceName(Table $table) + protected function referenceName(Table $table): string { - $name = namespaceSplit(get_class($table)); + $name = namespaceSplit($table::class); $name = substr(end($name), 0, -5); - if (empty($name)) { + if (!$name) { $name = $table->getTable() ?: $table->getAlias(); $name = Inflector::camelize($name); } return $name; } - - /** - * Modifies the results from a table find in order to merge the translated fields - * into each entity for a given locale. - * - * @param \Cake\Datasource\ResultSetInterface $results Results to map. - * @param string $locale Locale string - * @return \Cake\Collection\CollectionInterface - */ - protected function _rowMapper($results, $locale) - { - return $results->map(function ($row) use ($locale) { - if ($row === null) { - return $row; - } - $hydrated = !is_array($row); - - foreach ($this->_config['fields'] as $field) { - $name = $field . '_translation'; - $translation = isset($row[$name]) ? $row[$name] : null; - - if ($translation === null || $translation === false) { - unset($row[$name]); - continue; - } - - $content = isset($translation['content']) ? $translation['content'] : null; - if ($content !== null) { - $row[$field] = $content; - } - - unset($row[$name]); - } - - $row['_locale'] = $locale; - if ($hydrated) { - /* @var \Cake\Datasource\EntityInterface $row */ - $row->clean(); - } - - return $row; - }); - } - - /** - * Modifies the results from a table find in order to merge full translation records - * into each entity under the `_translations` key - * - * @param \Cake\Datasource\ResultSetInterface $results Results to modify. - * @return \Cake\Collection\CollectionInterface - */ - public function groupTranslations($results) - { - return $results->map(function ($row) { - if (!$row instanceof EntityInterface) { - return $row; - } - $translations = (array)$row->get('_i18n'); - if (empty($translations) && $row->get('_translations')) { - return $row; - } - $grouped = new Collection($translations); - - $result = []; - foreach ($grouped->combine('field', 'content', 'locale') as $locale => $keys) { - $entityClass = $this->_table->getEntityClass(); - $translation = new $entityClass($keys + ['locale' => $locale], [ - 'markNew' => false, - 'useSetters' => false, - 'markClean' => true - ]); - $result[$locale] = $translation; - } - - $options = ['setter' => false, 'guard' => false]; - $row->set('_translations', $result, $options); - unset($row['_i18n']); - $row->clean(); - - return $row; - }); - } - - /** - * Helper method used to generated multiple translated field entities - * out of the data found in the `_translations` property in the passed - * entity. The result will be put into its `_i18n` property - * - * @param \Cake\Datasource\EntityInterface $entity Entity - * @return void - */ - protected function _bundleTranslatedFields($entity) - { - $translations = (array)$entity->get('_translations'); - - if (empty($translations) && !$entity->isDirty('_translations')) { - return; - } - - $fields = $this->_config['fields']; - $primaryKey = (array)$this->_table->getPrimaryKey(); - $key = $entity->get(current($primaryKey)); - $find = []; - $contents = []; - - foreach ($translations as $lang => $translation) { - foreach ($fields as $field) { - if (!$translation->isDirty($field)) { - continue; - } - $find[] = ['locale' => $lang, 'field' => $field, 'foreign_key' => $key]; - $contents[] = new Entity(['content' => $translation->get($field)], [ - 'useSetters' => false - ]); - } - } - - if (empty($find)) { - return; - } - - $results = $this->_findExistingTranslations($find); - - foreach ($find as $i => $translation) { - if (!empty($results[$i])) { - $contents[$i]->set('id', $results[$i], ['setter' => false]); - $contents[$i]->isNew(false); - } else { - $translation['model'] = $this->_config['referenceName']; - $contents[$i]->set($translation, ['setter' => false, 'guard' => false]); - $contents[$i]->isNew(true); - } - } - - $entity->set('_i18n', $contents); - } - - /** - * Unset empty translations to avoid persistence. - * - * Should only be called if $this->_config['allowEmptyTranslations'] is false. - * - * @param \Cake\Datasource\EntityInterface $entity The entity to check for empty translations fields inside. - * @return void - */ - protected function _unsetEmptyFields(EntityInterface $entity) - { - $translations = (array)$entity->get('_translations'); - foreach ($translations as $locale => $translation) { - $fields = $translation->extract($this->_config['fields'], false); - foreach ($fields as $field => $value) { - if (strlen($value) === 0) { - $translation->unsetProperty($field); - } - } - - $translation = $translation->extract($this->_config['fields']); - - // If now, the current locale property is empty, - // unset it completely. - if (empty(array_filter($translation))) { - unset($entity->get('_translations')[$locale]); - } - } - - // If now, the whole _translations property is empty, - // unset it completely and return - if (empty($entity->get('_translations'))) { - $entity->unsetProperty('_translations'); - } - } - - /** - * Returns the ids found for each of the condition arrays passed for the translations - * table. Each records is indexed by the corresponding position to the conditions array - * - * @param array $ruleSet an array of arary of conditions to be used for finding each - * @return array - */ - protected function _findExistingTranslations($ruleSet) - { - $association = $this->_table->association($this->_translationTable->getAlias()); - - $query = $association->find() - ->select(['id', 'num' => 0]) - ->where(current($ruleSet)) - ->enableHydration(false) - ->enableBufferedResults(false); - - unset($ruleSet[0]); - foreach ($ruleSet as $i => $conditions) { - $q = $association->find() - ->select(['id', 'num' => $i]) - ->where($conditions); - $query->unionAll($q); - } - - return $query->all()->combine('num', 'id')->toArray(); - } } diff --git a/src/ORM/Behavior/TreeBehavior.php b/src/ORM/Behavior/TreeBehavior.php index ee93abd91a9..f86cfee3366 100644 --- a/src/ORM/Behavior/TreeBehavior.php +++ b/src/ORM/Behavior/TreeBehavior.php @@ -1,4 +1,6 @@ */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ 'implementedFinders' => [ 'path' => 'findPath', 'children' => 'findChildren', @@ -73,12 +79,13 @@ class TreeBehavior extends Behavior 'scope' => null, 'level' => null, 'recoverOrder' => null, + 'cascadeCallbacks' => false, ]; /** - * {@inheritDoc} + * @inheritDoc */ - public function initialize(array $config) + public function initialize(array $config): void { $this->_config['leftField'] = new IdentifierExpression($this->_config['left']); $this->_config['rightField'] = new IdentifierExpression($this->_config['right']); @@ -89,12 +96,12 @@ public function initialize(array $config) * Transparently manages setting the lft and rght fields if the parent field is * included in the parameters to be saved. * - * @param \Cake\Event\Event $event The beforeSave event that was fired + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event The beforeSave event that was fired * @param \Cake\Datasource\EntityInterface $entity the entity that is going to be saved * @return void - * @throws \RuntimeException if the parent to set for the node is invalid + * @throws \Cake\Database\Exception\DatabaseException if the parent to set for the node is invalid */ - public function beforeSave(Event $event, EntityInterface $entity) + public function beforeSave(EventInterface $event, EntityInterface $entity): void { $isNew = $entity->isNew(); $config = $this->getConfig(); @@ -103,25 +110,25 @@ public function beforeSave(Event $event, EntityInterface $entity) $dirty = $entity->isDirty($config['parent']); $level = $config['level']; - if ($parent && $entity->get($primaryKey) == $parent) { - throw new RuntimeException("Cannot set a node's parent as itself"); + if ($parent && $entity->get($primaryKey) === $parent) { + throw new DatabaseException("Cannot set a node's parent as itself."); } - if ($isNew && $parent) { - $parentNode = $this->_getNode($parent); - $edge = $parentNode->get($config['right']); - $entity->set($config['left'], $edge); - $entity->set($config['right'], $edge + 1); - $this->_sync(2, '+', ">= {$edge}"); + if ($isNew) { + if ($parent) { + $parentNode = $this->_getNode($parent); + $edge = $parentNode->get($config['right']); + $entity->set($config['left'], $edge); + $entity->set($config['right'], $edge + 1); + $this->_sync(2, '+', ">= {$edge}"); - if ($level) { - $entity->set($level, $parentNode[$level] + 1); - } + if ($level) { + $entity->set($level, $parentNode[$level] + 1); + } - return; - } + return; + } - if ($isNew && !$parent) { $edge = $this->_getMax(); $entity->set($config['left'], $edge + 1); $entity->set($config['right'], $edge + 2); @@ -133,18 +140,18 @@ public function beforeSave(Event $event, EntityInterface $entity) return; } - if (!$isNew && $dirty && $parent) { - $this->_setParent($entity, $parent); + if ($dirty) { + if ($parent) { + $this->_setParent($entity, $parent); - if ($level) { - $parentNode = $this->_getNode($parent); - $entity->set($level, $parentNode[$level] + 1); - } + if ($level) { + $parentNode = $this->_getNode($parent); + $entity->set($level, $parentNode[$level] + 1); + } - return; - } + return; + } - if (!$isNew && $dirty && !$parent) { $this->_setAsRoot($entity); if ($level) { @@ -158,11 +165,11 @@ public function beforeSave(Event $event, EntityInterface $entity) * * Manages updating level of descendants of currently saved entity. * - * @param \Cake\Event\Event $event The afterSave event that was fired + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event The afterSave event that was fired * @param \Cake\Datasource\EntityInterface $entity the entity that is going to be saved * @return void */ - public function afterSave(Event $event, EntityInterface $entity) + public function afterSave(EventInterface $event, EntityInterface $entity): void { if (!$this->_config['level'] || $entity->isNew()) { return; @@ -177,7 +184,7 @@ public function afterSave(Event $event, EntityInterface $entity) * @param \Cake\Datasource\EntityInterface $entity The entity whose descendants need to be updated. * @return void */ - protected function _setChildrenLevel($entity) + protected function _setChildrenLevel(EntityInterface $entity): void { $config = $this->getConfig(); @@ -189,13 +196,15 @@ protected function _setChildrenLevel($entity) $primaryKeyValue = $entity->get($primaryKey); $depths = [$primaryKeyValue => $entity->get($config['level'])]; - $children = $this->_table->find('children', [ - 'for' => $primaryKeyValue, - 'fields' => [$this->_getPrimaryKey(), $config['parent'], $config['level']], - 'order' => $config['left'], - ]); + /** @var \Traversable<\Cake\Datasource\EntityInterface> $children */ + $children = $this->_table->find( + 'children', + for: $primaryKeyValue, + fields: [$this->_getPrimaryKey(), $config['parent'], $config['level']], + order: $config['left'], + ) + ->all(); - /* @var \Cake\Datasource\EntityInterface $node */ foreach ($children as $node) { $parentIdValue = $node->get($config['parent']); $depth = $depths[$parentIdValue] + 1; @@ -203,7 +212,7 @@ protected function _setChildrenLevel($entity) $this->_table->updateAll( [$config['level'] => $depth], - [$primaryKey => $node->get($primaryKey)] + [$primaryKey => $node->get($primaryKey)], ); } } @@ -211,29 +220,41 @@ protected function _setChildrenLevel($entity) /** * Also deletes the nodes in the subtree of the entity to be delete * - * @param \Cake\Event\Event $event The beforeDelete event that was fired + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event The beforeDelete event that was fired * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved * @return void */ - public function beforeDelete(Event $event, EntityInterface $entity) + public function beforeDelete(EventInterface $event, EntityInterface $entity): void { $config = $this->getConfig(); $this->_ensureFields($entity); $left = $entity->get($config['left']); $right = $entity->get($config['right']); - $diff = $right - $left + 1; + $diff = (int)($right - $left + 1); if ($diff > 2) { - $query = $this->_scope($this->_table->query()) - ->delete() - ->where(function ($exp) use ($config, $left, $right) { - /* @var \Cake\Database\Expression\QueryExpression $exp */ - return $exp - ->gte($config['leftField'], $left + 1) - ->lte($config['leftField'], $right - 1); - }); - $statement = $query->execute(); - $statement->closeCursor(); + if ($this->getConfig('cascadeCallbacks')) { + /** @var \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface> $query */ + $query = $this->_scope($this->_table->query()) + ->where( + fn(QueryExpression $exp) => $exp + ->gte($config['leftField'], $left + 1) + ->lte($config['leftField'], $right - 1), + ); + + $entities = $query->toArray(); + foreach ($entities as $entityToDelete) { + $this->_table->delete($entityToDelete, ['atomic' => false]); + } + } else { + $this->_scope($this->_table->deleteQuery()) + ->where( + fn(QueryExpression $exp) => $exp + ->gte($config['leftField'], $left + 1) + ->lte($config['leftField'], $right - 1), + ) + ->execute(); + } } $this->_sync($diff, '-', "> {$right}"); @@ -247,9 +268,9 @@ public function beforeDelete(Event $event, EntityInterface $entity) * @param \Cake\Datasource\EntityInterface $entity The entity to re-parent * @param mixed $parent the id of the parent to set * @return void - * @throws \RuntimeException if the parent to set to the entity is not valid + * @throws \Cake\Database\Exception\DatabaseException if the parent to set to the entity is not valid */ - protected function _setParent($entity, $parent) + protected function _setParent(EntityInterface $entity, mixed $parent): void { $config = $this->getConfig(); $parentNode = $this->_getNode($parent); @@ -260,10 +281,10 @@ protected function _setParent($entity, $parent) $left = $entity->get($config['left']); if ($parentLeft > $left && $parentLeft < $right) { - throw new RuntimeException(sprintf( - 'Cannot use node "%s" as parent for entity "%s"', + throw new DatabaseException(sprintf( + 'Cannot use node `%s` as parent for entity `%s`.', $parent, - $entity->get($this->_getPrimaryKey()) + $entity->get($this->_getPrimaryKey()), )); } @@ -309,7 +330,7 @@ protected function _setParent($entity, $parent) * @param \Cake\Datasource\EntityInterface $entity The entity to set as a new root * @return void */ - protected function _setAsRoot($entity) + protected function _setAsRoot(EntityInterface $entity): void { $config = $this->getConfig(); $edge = $this->_getMax(); @@ -342,24 +363,20 @@ protected function _setAsRoot($entity) * * @return void */ - protected function _unmarkInternalTree() + protected function _unmarkInternalTree(): void { $config = $this->getConfig(); $this->_table->updateAll( - function ($exp) use ($config) { - /* @var \Cake\Database\Expression\QueryExpression $exp */ + function (QueryExpression $exp) use ($config) { $leftInverse = clone $exp; - $leftInverse->type('*')->add('-1'); + $leftInverse->setConjunction('*')->add('-1'); $rightInverse = clone $leftInverse; return $exp ->eq($config['leftField'], $leftInverse->add($config['leftField'])) ->eq($config['rightField'], $rightInverse->add($config['rightField'])); }, - function ($exp) use ($config) { - /* @var \Cake\Database\Expression\QueryExpression $exp */ - return $exp->lt($config['leftField'], 0); - } + fn(QueryExpression $exp) => $exp->lt($config['leftField'], 0), ); } @@ -368,33 +385,27 @@ function ($exp) use ($config) { * to a specific node in the tree. This custom finder requires that the key 'for' * is passed in the options containing the id of the node to get its path for. * - * @param \Cake\ORM\Query $query The constructed query to modify - * @param array $options the list of options for the query - * @return \Cake\ORM\Query + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query The constructed query to modify + * @param string|int $for The path to find or an array of options with `for`. + * @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> * @throws \InvalidArgumentException If the 'for' key is missing in options */ - public function findPath(Query $query, array $options) + public function findPath(SelectQuery $query, string|int $for): SelectQuery { - if (empty($options['for'])) { - throw new InvalidArgumentException("The 'for' key is required for find('path')"); - } - $config = $this->getConfig(); - list($left, $right) = array_map( - function ($field) { - return $this->_table->aliasField($field); - }, - [$config['left'], $config['right']] + [$left, $right] = array_map( + $this->_table->aliasField(...), + [$config['left'], $config['right']], ); - $node = $this->_table->get($options['for'], ['fields' => [$left, $right]]); + $node = $this->_table->get($for, select: [$left, $right]); return $this->_scope($query) ->where([ - "$left <=" => $node->get($config['left']), - "$right >=" => $node->get($config['right']), + "{$left} <=" => $node->get($config['left']), + "{$right} >=" => $node->get($config['right']), ]) - ->order([$left => 'ASC']); + ->orderBy([$left => 'ASC']); } /** @@ -405,7 +416,7 @@ function ($field) { * direct children * @return int Number of children nodes. */ - public function childCount(EntityInterface $node, $direct = false) + public function childCount(EntityInterface $node, bool $direct = false): int { $config = $this->getConfig(); $parent = $this->_table->aliasField($config['parent']); @@ -422,40 +433,27 @@ public function childCount(EntityInterface $node, $direct = false) } /** - * Get the children nodes of the current model - * - * Available options are: - * - * - for: The id of the record to read. - * - direct: Boolean, whether to return only the direct (true), or all (false) children, - * defaults to false (all children). + * Get the children nodes of the current model. * - * If the direct option is set to true, only the direct children are returned (based upon the parent_id field) + * If the direct option is set to true, only the direct children are returned + * (based upon the parent_id field). * - * @param \Cake\ORM\Query $query Query. - * @param array $options Array of options as described above - * @return \Cake\ORM\Query + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query Query. + * @param string|int $for The id of the record to read. Can also be an array of options. + * @param bool $direct Whether to return only the direct (true) or all children (false). + * @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> * @throws \InvalidArgumentException When the 'for' key is not passed in $options */ - public function findChildren(Query $query, array $options) + public function findChildren(SelectQuery $query, int|string $for, bool $direct = false): SelectQuery { $config = $this->getConfig(); - $options += ['for' => null, 'direct' => false]; - list($parent, $left, $right) = array_map( - function ($field) { - return $this->_table->aliasField($field); - }, - [$config['parent'], $config['left'], $config['right']] + [$parent, $left, $right] = array_map( + $this->_table->aliasField(...), + [$config['parent'], $config['left'], $config['right']], ); - list($for, $direct) = [$options['for'], $options['direct']]; - - if (empty($for)) { - throw new InvalidArgumentException("The 'for' key is required for find('children')"); - } - if ($query->clause('order') === null) { - $query->order([$left => 'ASC']); + $query->orderBy([$left => 'ASC']); } if ($direct) { @@ -476,29 +474,26 @@ function ($field) { * the primary key for the table and the values are the display field for the table. * Values are prefixed to visually indicate relative depth in the tree. * - * ### Options - * - * - keyPath: A dot separated path to fetch the field to use for the array key, or a closure to + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query Query. + * @param \Closure|string|null $keyPath A dot separated path to fetch the field to use for the array key, or a closure to * return the key out of the provided row. - * - valuePath: A dot separated path to fetch the field to use for the array value, or a closure to + * @param \Closure|string|null $valuePath A dot separated path to fetch the field to use for the array value, or a closure to * return the value out of the provided row. - * - spacer: A string to be used as prefix for denoting the depth in the tree for each item - * - * @param \Cake\ORM\Query $query Query. - * @param array $options Array of options as described above. - * @return \Cake\ORM\Query + * @param string|null $spacer A string to be used as prefix for denoting the depth in the tree for each item. + * @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> */ - public function findTreeList(Query $query, array $options) - { + public function findTreeList( + SelectQuery $query, + Closure|string|null $keyPath = null, + Closure|string|null $valuePath = null, + ?string $spacer = null, + ): SelectQuery { $left = $this->_table->aliasField($this->getConfig('left')); $results = $this->_scope($query) - ->find('threaded', [ - 'parentField' => $this->getConfig('parent'), - 'order' => [$left => 'ASC'], - ]); + ->find('threaded', parentField: $this->getConfig('parent'), order: [$left => 'ASC']); - return $this->formatTreeList($results, $options); + return $this->formatTreeList($results, $keyPath, $valuePath, $spacer); } /** @@ -506,32 +501,33 @@ public function findTreeList(Query $query, array $options) * and the values are the display field for the table. Values are prefixed to visually * indicate relative depth in the tree. * - * ### Options - * - * - keyPath: A dot separated path to the field that will be the result array key, or a closure to + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query The query object to format. + * @param \Closure|string|null $keyPath A dot separated path to the field that will be the result array key, or a closure to * return the key from the provided row. - * - valuePath: A dot separated path to the field that is the array's value, or a closure to + * @param \Closure|string|null $valuePath A dot separated path to the field that is the array's value, or a closure to * return the value from the provided row. - * - spacer: A string to be used as prefix for denoting the depth in the tree for each item. - * - * @param \Cake\ORM\Query $query The query object to format. - * @param array $options Array of options as described above. - * @return \Cake\ORM\Query Augmented query. + * @param string|null $spacer A string to be used as prefix for denoting the depth in the tree for each item. + * @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> Augmented query. */ - public function formatTreeList(Query $query, array $options = []) - { - return $query->formatResults(function ($results) use ($options) { - /* @var \Cake\Collection\CollectionTrait $results */ - $options += [ - 'keyPath' => $this->_getPrimaryKey(), - 'valuePath' => $this->_table->getDisplayField(), - 'spacer' => '_', - ]; - - return $results - ->listNested() - ->printer($options['valuePath'], $options['keyPath'], $options['spacer']); - }); + public function formatTreeList( + SelectQuery $query, + Closure|string|null $keyPath = null, + Closure|string|null $valuePath = null, + ?string $spacer = null, + ): SelectQuery { + return $query->formatResults( + function (CollectionInterface $results) use ($keyPath, $valuePath, $spacer) { + $keyPath ??= $this->_getPrimaryKey(); + $valuePath ??= $this->_table->getDisplayField(); + $spacer ??= '_'; + + $nested = $results->listNested(); + assert($nested instanceof TreeIterator); + assert(is_callable($valuePath) || is_string($valuePath)); + + return $nested->printer($valuePath, $keyPath, $spacer); + }, + ); } /** @@ -545,7 +541,7 @@ public function formatTreeList(Query $query, array $options = []) * @return \Cake\Datasource\EntityInterface|false the node after being removed from the tree or * false on error */ - public function removeFromTree(EntityInterface $node) + public function removeFromTree(EntityInterface $node): EntityInterface|false { return $this->_table->getConnection()->transactional(function () use ($node) { $this->_ensureFields($node); @@ -561,23 +557,23 @@ public function removeFromTree(EntityInterface $node) * @return \Cake\Datasource\EntityInterface|false the node after being removed from the tree or * false on error */ - protected function _removeFromTree($node) + protected function _removeFromTree(EntityInterface $node): EntityInterface|false { $config = $this->getConfig(); $left = $node->get($config['left']); $right = $node->get($config['right']); $parent = $node->get($config['parent']); - $node->set($config['parent'], null); + $node->set($config['parent']); - if ($right - $left == 1) { + if ($right - $left === 1) { return $this->_table->save($node); } $primary = $this->_getPrimaryKey(); $this->_table->updateAll( [$config['parent'] => $parent], - [$config['parent'] => $node->get($primary)] + [$config['parent'] => $node->get($primary)], ); $this->_sync(1, '-', 'BETWEEN ' . ($left + 1) . ' AND ' . ($right - 1)); $this->_sync(2, '-', "> {$right}"); @@ -599,14 +595,14 @@ protected function _removeFromTree($node) * Reorders the node without changing its parent. * * If the node is the first child, or is a top level node with no previous node - * this method will return false + * this method will return the same node without any changes * * @param \Cake\Datasource\EntityInterface $node The node to move - * @param int|bool $number How many places to move the node, or true to move to first position + * @param int|true $number How many places to move the node, or true to move to first position * @throws \Cake\Datasource\Exception\RecordNotFoundException When node was not found - * @return \Cake\Datasource\EntityInterface|bool $node The node after being moved or false on failure + * @return \Cake\Datasource\EntityInterface|false $node The node after being moved or false if `$number` is < 1 */ - public function moveUp(EntityInterface $node, $number = 1) + public function moveUp(EntityInterface $node, int|true $number = 1): EntityInterface|false { if ($number < 1) { return false; @@ -623,39 +619,35 @@ public function moveUp(EntityInterface $node, $number = 1) * Helper function used with the actual code for moveUp * * @param \Cake\Datasource\EntityInterface $node The node to move - * @param int|bool $number How many places to move the node, or true to move to first position + * @param int|true $number How many places to move the node, or true to move to first position + * @return \Cake\Datasource\EntityInterface $node The node after being moved * @throws \Cake\Datasource\Exception\RecordNotFoundException When node was not found - * @return \Cake\Datasource\EntityInterface|bool $node The node after being moved or false on failure */ - protected function _moveUp($node, $number) + protected function _moveUp(EntityInterface $node, int|true $number): EntityInterface { $config = $this->getConfig(); - list($parent, $left, $right) = [$config['parent'], $config['left'], $config['right']]; - list($nodeParent, $nodeLeft, $nodeRight) = array_values($node->extract([$parent, $left, $right])); + [$parent, $left, $right] = [$config['parent'], $config['left'], $config['right']]; + [$nodeParent, $nodeLeft, $nodeRight] = array_values($node->extract([$parent, $left, $right])); $targetNode = null; if ($number !== true) { + /** @var \Cake\Datasource\EntityInterface|null $targetNode */ $targetNode = $this->_scope($this->_table->find()) ->select([$left, $right]) - ->where(["$parent IS" => $nodeParent]) - ->where(function ($exp) use ($config, $nodeLeft) { - /* @var \Cake\Database\Expression\QueryExpression $exp */ - return $exp->lt($config['rightField'], $nodeLeft); - }) - ->orderDesc($config['leftField']) + ->where(["{$parent} IS" => $nodeParent]) + ->where(fn(QueryExpression $exp) => $exp->lt($config['rightField'], $nodeLeft)) + ->orderByDesc($config['leftField']) ->offset($number - 1) ->limit(1) ->first(); } if (!$targetNode) { + /** @var \Cake\Datasource\EntityInterface|null $targetNode */ $targetNode = $this->_scope($this->_table->find()) ->select([$left, $right]) - ->where(["$parent IS" => $nodeParent]) - ->where(function ($exp) use ($config, $nodeLeft) { - /* @var \Cake\Database\Expression\QueryExpression $exp */ - return $exp->lt($config['rightField'], $nodeLeft); - }) - ->orderAsc($config['leftField']) + ->where(["{$parent} IS" => $nodeParent]) + ->where(fn(QueryExpression $exp) => $exp->lt($config['rightField'], $nodeLeft)) + ->orderByAsc($config['leftField']) ->limit(1) ->first(); @@ -664,7 +656,7 @@ protected function _moveUp($node, $number) } } - list($targetLeft) = array_values($targetNode->extract([$left, $right])); + [$targetLeft] = array_values($targetNode->extract([$left, $right])); $edge = $this->_getMax(); $leftBoundary = $targetLeft; $rightBoundary = $nodeLeft - 1; @@ -676,8 +668,10 @@ protected function _moveUp($node, $number) $this->_sync($shift, '+', "BETWEEN {$leftBoundary} AND {$rightBoundary}"); $this->_sync($nodeToHole, '-', "> {$edge}"); + /** @var string $left */ $node->set($left, $targetLeft); - $node->set($right, $targetLeft + ($nodeRight - $nodeLeft)); + /** @var string $right */ + $node->set($right, $targetLeft + $nodeRight - $nodeLeft); $node->setDirty($left, false); $node->setDirty($right, false); @@ -689,14 +683,14 @@ protected function _moveUp($node, $number) * Reorders the node without changing the parent. * * If the node is the last child, or is a top level node with no subsequent node - * this method will return false + * this method will return the same node without any changes * * @param \Cake\Datasource\EntityInterface $node The node to move - * @param int|bool $number How many places to move the node or true to move to last position + * @param int|true $number How many places to move the node or true to move to last position * @throws \Cake\Datasource\Exception\RecordNotFoundException When node was not found - * @return \Cake\Datasource\EntityInterface|bool the entity after being moved or false on failure + * @return \Cake\Datasource\EntityInterface|false the entity after being moved or false if `$number` is < 1 */ - public function moveDown(EntityInterface $node, $number = 1) + public function moveDown(EntityInterface $node, int|true $number = 1): EntityInterface|false { if ($number < 1) { return false; @@ -713,39 +707,36 @@ public function moveDown(EntityInterface $node, $number = 1) * Helper function used with the actual code for moveDown * * @param \Cake\Datasource\EntityInterface $node The node to move - * @param int|bool $number How many places to move the node, or true to move to last position + * @param int|true $number How many places to move the node, or true to move to last position + * @return \Cake\Datasource\EntityInterface $node The node after being moved * @throws \Cake\Datasource\Exception\RecordNotFoundException When node was not found - * @return \Cake\Datasource\EntityInterface|bool $node The node after being moved or false on failure */ - protected function _moveDown($node, $number) + protected function _moveDown(EntityInterface $node, int|true $number): EntityInterface { $config = $this->getConfig(); - list($parent, $left, $right) = [$config['parent'], $config['left'], $config['right']]; - list($nodeParent, $nodeLeft, $nodeRight) = array_values($node->extract([$parent, $left, $right])); + [$parent, $left, $right] = [$config['parent'], $config['left'], $config['right']]; + assert(is_string($parent) && is_string($left) && is_string($right)); + [$nodeParent, $nodeLeft, $nodeRight] = array_values($node->extract([$parent, $left, $right])); $targetNode = null; if ($number !== true) { + /** @var \Cake\Datasource\EntityInterface|null $targetNode */ $targetNode = $this->_scope($this->_table->find()) ->select([$left, $right]) - ->where(["$parent IS" => $nodeParent]) - ->where(function ($exp) use ($config, $nodeRight) { - /* @var \Cake\Database\Expression\QueryExpression $exp */ - return $exp->gt($config['leftField'], $nodeRight); - }) - ->orderAsc($config['leftField']) + ->where(["{$parent} IS" => $nodeParent]) + ->where(fn(QueryExpression $exp) => $exp->gt($config['leftField'], $nodeRight)) + ->orderByAsc($config['leftField']) ->offset($number - 1) ->limit(1) ->first(); } if (!$targetNode) { + /** @var \Cake\Datasource\EntityInterface|null $targetNode */ $targetNode = $this->_scope($this->_table->find()) ->select([$left, $right]) - ->where(["$parent IS" => $nodeParent]) - ->where(function ($exp) use ($config, $nodeRight) { - /* @var \Cake\Database\Expression\QueryExpression $exp */ - return $exp->gt($config['leftField'], $nodeRight); - }) - ->orderDesc($config['leftField']) + ->where(["{$parent} IS" => $nodeParent]) + ->where(fn(QueryExpression $exp) => $exp->gt($config['leftField'], $nodeRight)) + ->orderByDesc($config['leftField']) ->limit(1) ->first(); @@ -754,7 +745,7 @@ protected function _moveDown($node, $number) } } - list(, $targetRight) = array_values($targetNode->extract([$left, $right])); + [, $targetRight] = array_values($targetNode->extract([$left, $right])); $edge = $this->_getMax(); $leftBoundary = $nodeRight + 1; $rightBoundary = $targetRight; @@ -782,10 +773,10 @@ protected function _moveDown($node, $number) * @return \Cake\Datasource\EntityInterface * @throws \Cake\Datasource\Exception\RecordNotFoundException When node was not found */ - protected function _getNode($id) + protected function _getNode(mixed $id): EntityInterface { $config = $this->getConfig(); - list($parent, $left, $right) = [$config['parent'], $config['left'], $config['right']]; + [$parent, $left, $right] = [$config['parent'], $config['left'], $config['right']]; $primaryKey = $this->_getPrimaryKey(); $fields = [$parent, $left, $right]; if ($config['level']) { @@ -797,8 +788,8 @@ protected function _getNode($id) ->where([$this->_table->aliasField($primaryKey) => $id]) ->first(); - if (!$node) { - throw new RecordNotFoundException("Node \"{$id}\" was not found in the tree."); + if (!$node instanceof EntityInterface) { + throw new RecordNotFoundException(sprintf('Node `%s` was not found in the tree.', $id)); } return $node; @@ -810,9 +801,9 @@ protected function _getNode($id) * * @return void */ - public function recover() + public function recover(): void { - $this->_table->getConnection()->transactional(function () { + $this->_table->getConnection()->transactional(function (): void { $this->_recoverTree(); }); } @@ -820,47 +811,41 @@ public function recover() /** * Recursive method used to recover a single level of the tree * - * @param int $counter The Last left column value that was assigned + * @param int $lftRght The starting lft/rght value * @param mixed $parentId the parent id of the level to be recovered * @param int $level Node level - * @return int The next value to use for the left column + * @return int The next lftRght value */ - protected function _recoverTree($counter = 0, $parentId = null, $level = -1) + protected function _recoverTree(int $lftRght = 1, mixed $parentId = null, int $level = 0): int { $config = $this->getConfig(); - list($parent, $left, $right) = [$config['parent'], $config['left'], $config['right']]; + [$parent, $left, $right] = [$config['parent'], $config['left'], $config['right']]; $primaryKey = $this->_getPrimaryKey(); - $aliasedPrimaryKey = $this->_table->aliasField($primaryKey); - $order = $config['recoverOrder'] ?: $aliasedPrimaryKey; - - $query = $this->_scope($this->_table->query()) - ->select([$aliasedPrimaryKey]) - ->where([$this->_table->aliasField($parent) . ' IS' => $parentId]) - ->order($order) - ->enableHydration(false); - - $leftCounter = $counter; - $nextLevel = $level + 1; - foreach ($query as $row) { - $counter++; - $counter = $this->_recoverTree($counter, $row[$primaryKey], $nextLevel); - } - - if ($parentId === null) { - return $counter; - } + $order = $config['recoverOrder'] ?: $primaryKey; + + $nodes = $this->_scope($this->_table->selectQuery()) + ->select($primaryKey) + ->where([$parent . ' IS' => $parentId]) + ->orderBy($order) + ->disableHydration() + ->all(); + + foreach ($nodes as $node) { + $nodeLft = $lftRght++; + $lftRght = $this->_recoverTree($lftRght, $node[$primaryKey], $level + 1); + + $fields = [$left => $nodeLft, $right => $lftRght++]; + if ($config['level']) { + $fields[$config['level']] = $level; + } - $fields = [$left => $leftCounter, $right => $counter + 1]; - if ($config['level']) { - $fields[$config['level']] = $level; + $this->_table->updateAll( + $fields, + [$primaryKey => $node[$primaryKey]], + ); } - $this->_table->updateAll( - $fields, - [$primaryKey => $parentId] - ); - - return $counter + 1; + return $lftRght; } /** @@ -868,20 +853,20 @@ protected function _recoverTree($counter = 0, $parentId = null, $level = -1) * * @return int */ - protected function _getMax() + protected function _getMax(): int { $field = $this->_config['right']; $rightField = $this->_config['rightField']; $edge = $this->_scope($this->_table->find()) ->select([$field]) - ->orderDesc($rightField) + ->orderByDesc($rightField) ->first(); - if (empty($edge->{$field})) { + if ($edge === null || empty($edge[$field])) { return 0; } - return $edge->{$field}; + return $edge[$field]; } /** @@ -896,16 +881,17 @@ protected function _getMax() * modified by future calls to this function. * @return void */ - protected function _sync($shift, $dir, $conditions, $mark = false) + protected function _sync(int $shift, string $dir, string $conditions, bool $mark = false): void { $config = $this->_config; + /** @var \Cake\Database\Expression\IdentifierExpression $field */ foreach ([$config['leftField'], $config['rightField']] as $field) { - $query = $this->_scope($this->_table->query()); - $exp = $query->newExpr(); + $query = $this->_scope($this->_table->updateQuery()); + $exp = $query->expr(); $movement = clone $exp; - $movement->add($field)->add("$shift")->setConjunction($dir); + $movement->add($field)->add((string)$shift)->setConjunction($dir); $inverse = clone $exp; $movement = $mark ? @@ -915,11 +901,10 @@ protected function _sync($shift, $dir, $conditions, $mark = false) $where = clone $exp; $where->add($field)->add($conditions)->setConjunction(''); - $query->update() + $query ->set($exp->eq($field, $movement)) - ->where($where); - - $query->execute()->closeCursor(); + ->where($where) + ->execute(); } } @@ -927,21 +912,19 @@ protected function _sync($shift, $dir, $conditions, $mark = false) * Alters the passed query so that it only returns scoped records as defined * in the tree configuration. * - * @param \Cake\ORM\Query $query the Query to modify - * @return \Cake\ORM\Query + * @template TQuery of \Cake\ORM\Query\SelectQuery|\Cake\ORM\Query\UpdateQuery|\Cake\ORM\Query\DeleteQuery + * @param TQuery $query the Query to modify + * @return TQuery */ - protected function _scope($query) + protected function _scope(SelectQuery|UpdateQuery|DeleteQuery $query): SelectQuery|UpdateQuery|DeleteQuery { $scope = $this->getConfig('scope'); - if (is_array($scope)) { - return $query->where($scope); - } - if (is_callable($scope)) { - return $scope($query); + if ($scope === null) { + return $query; } - return $query; + return $query->where($scope); } /** @@ -951,7 +934,7 @@ protected function _scope($query) * @param \Cake\Datasource\EntityInterface $entity The entity to ensure fields for * @return void */ - protected function _ensureFields($entity) + protected function _ensureFields(EntityInterface $entity): void { $config = $this->getConfig(); $fields = [$config['left'], $config['right']]; @@ -960,8 +943,12 @@ protected function _ensureFields($entity) return; } - $fresh = $this->_table->get($entity->get($this->_getPrimaryKey()), $fields); - $entity->set($fresh->extract($fields), ['guard' => false]); + $fresh = $this->_table->get($entity->get($this->_getPrimaryKey())); + if (method_exists($entity, 'patch')) { + $entity->patch($fresh->extract($fields), ['guard' => false]); + } else { + $entity->set($fresh->extract($fields), ['guard' => false]); + } foreach ($fields as $field) { $entity->setDirty($field, false); @@ -973,7 +960,7 @@ protected function _ensureFields($entity) * * @return string */ - protected function _getPrimaryKey() + protected function _getPrimaryKey(): string { if (!$this->_primaryKey) { $primaryKey = (array)$this->_table->getPrimaryKey(); @@ -986,10 +973,10 @@ protected function _getPrimaryKey() /** * Returns the depth level of a node in the tree. * - * @param int|string|\Cake\Datasource\EntityInterface $entity The entity or primary key get the level of. - * @return int|bool Integer of the level or false if the node does not exist. + * @param \Cake\Datasource\EntityInterface|string|int $entity The entity or primary key get the level of. + * @return int|false Integer of the level or false if the node does not exist. */ - public function getLevel($entity) + public function getLevel(EntityInterface|string|int $entity): int|false { $primaryKey = $this->_getPrimaryKey(); $id = $entity; diff --git a/src/ORM/BehaviorRegistry.php b/src/ORM/BehaviorRegistry.php index a72389e6e33..f43669cc95a 100644 --- a/src/ORM/BehaviorRegistry.php +++ b/src/ORM/BehaviorRegistry.php @@ -1,4 +1,6 @@ */ class BehaviorRegistry extends ObjectRegistry implements EventDispatcherInterface { - use EventDispatcherTrait; /** @@ -38,28 +43,28 @@ class BehaviorRegistry extends ObjectRegistry implements EventDispatcherInterfac * * @var \Cake\ORM\Table */ - protected $_table; + protected Table $_table; /** * Method mappings. * - * @var array + * @var array */ - protected $_methodMap = []; + protected array $_methodMap = []; /** * Finder method mappings. * - * @var array + * @var array */ - protected $_finderMap = []; + protected array $_finderMap = []; /** * Constructor * * @param \Cake\ORM\Table|null $table The table this registry is attached to. */ - public function __construct($table = null) + public function __construct(?Table $table = null) { if ($table !== null) { $this->setTable($table); @@ -72,30 +77,22 @@ public function __construct($table = null) * @param \Cake\ORM\Table $table The table this registry is attached to. * @return void */ - public function setTable(Table $table) + public function setTable(Table $table): void { $this->_table = $table; - $eventManager = $table->getEventManager(); - if ($eventManager !== null) { - $this->setEventManager($eventManager); - } + $this->setEventManager($table->getEventManager()); } /** * Resolve a behavior classname. * * @param string $class Partial classname to resolve. - * @return string|null Either the correct classname or null. - * @since 3.5.7 + * @return class-string|null Either the correct classname or null. */ - public static function className($class) + public static function className(string $class): ?string { - $result = App::className($class, 'Model/Behavior', 'Behavior'); - if (!$result) { - $result = App::className($class, 'ORM/Behavior', 'Behavior'); - } - - return $result ?: null; + return App::className($class, 'Model/Behavior', 'Behavior') + ?: App::className($class, 'ORM/Behavior', 'Behavior'); } /** @@ -104,11 +101,12 @@ public static function className($class) * Part of the template method for Cake\Core\ObjectRegistry::load() * * @param string $class Partial classname to resolve. - * @return string|false Either the correct classname or false. + * @return class-string<\Cake\ORM\Behavior>|null Either the correct class name or null. */ - protected function _resolveClassName($class) + protected function _resolveClassName(string $class): ?string { - return static::className($class) ?: false; + /** @var class-string<\Cake\ORM\Behavior>|null */ + return static::className($class); } /** @@ -118,15 +116,15 @@ protected function _resolveClassName($class) * and Cake\Core\ObjectRegistry::unload() * * @param string $class The classname that is missing. - * @param string $plugin The plugin the behavior is missing in. + * @param string|null $plugin The plugin the behavior is missing in. * @return void * @throws \Cake\ORM\Exception\MissingBehaviorException */ - protected function _throwMissingClassError($class, $plugin) + protected function _throwMissingClassError(string $class, ?string $plugin): void { throw new MissingBehaviorException([ 'class' => $class . 'Behavior', - 'plugin' => $plugin + 'plugin' => $plugin, ]); } @@ -136,15 +134,20 @@ protected function _throwMissingClassError($class, $plugin) * Part of the template method for Cake\Core\ObjectRegistry::load() * Enabled behaviors will be registered with the event manager. * - * @param string $class The classname that is missing. + * @param \Cake\ORM\Behavior|class-string<\Cake\ORM\Behavior> $class The classname that is missing. * @param string $alias The alias of the object. - * @param array $config An array of config to use for the behavior. + * @param array $config An array of config to use for the behavior. * @return \Cake\ORM\Behavior The constructed behavior class. */ - protected function _create($class, $alias, $config) + protected function _create(object|string $class, string $alias, array $config): Behavior { + if (is_object($class)) { + return $class; + } + $instance = new $class($this->_table, $config); - $enable = isset($config['enabled']) ? $config['enabled'] : true; + + $enable = $config['enabled'] ?? true; if ($enable) { $this->getEventManager()->on($instance); } @@ -168,7 +171,7 @@ protected function _create($class, $alias, $config) * @return array A list of implemented finders and methods. * @throws \LogicException when duplicate methods are connected. */ - protected function _getMethods(Behavior $instance, $class, $alias) + protected function _getMethods(Behavior $instance, string $class, string $alias): array { $finders = array_change_key_case($instance->implementedFinders()); $methods = array_change_key_case($instance->implementedMethods()); @@ -177,10 +180,10 @@ protected function _getMethods(Behavior $instance, $class, $alias) if (isset($this->_finderMap[$finder]) && $this->has($this->_finderMap[$finder][0])) { $duplicate = $this->_finderMap[$finder]; $error = sprintf( - '%s contains duplicate finder "%s" which is already provided by "%s"', + '`%s` contains duplicate finder `%s` which is already provided by `%s`.', $class, $finder, - $duplicate[0] + $duplicate[0], ); throw new LogicException($error); } @@ -191,10 +194,10 @@ protected function _getMethods(Behavior $instance, $class, $alias) if (isset($this->_methodMap[$method]) && $this->has($this->_methodMap[$method][0])) { $duplicate = $this->_methodMap[$method]; $error = sprintf( - '%s contains duplicate method "%s" which is already provided by "%s"', + '`%s` contains duplicate method `%s` which is already provided by `%s`.', $class, $method, - $duplicate[0] + $duplicate[0], ); throw new LogicException($error); } @@ -204,6 +207,49 @@ protected function _getMethods(Behavior $instance, $class, $alias) return compact('methods', 'finders'); } + /** + * Set an object directly into the registry by name. + * + * @param string $name The name of the object to set in the registry. + * @param \Cake\ORM\Behavior $object instance to store in the registry + * @return $this + */ + public function set(string $name, object $object) + { + parent::set($name, $object); + + $methods = $this->_getMethods($object, $object::class, $name); + $this->_methodMap += $methods['methods']; + $this->_finderMap += $methods['finders']; + + return $this; + } + + /** + * Remove an object from the registry. + * + * If this registry has an event manager, the object will be detached from any events as well. + * + * @param string $name The name of the object to remove from the registry. + * @return $this + */ + public function unload(string $name) + { + $instance = $this->get($name); + $result = parent::unload($name); + + $methods = array_map('strtolower', array_keys($instance->implementedMethods())); + foreach ($methods as $method) { + unset($this->_methodMap[$method]); + } + $finders = array_map('strtolower', array_keys($instance->implementedFinders())); + foreach ($finders as $finder) { + unset($this->_finderMap[$finder]); + } + + return $result; + } + /** * Check if any loaded behavior implements a method. * @@ -212,8 +258,9 @@ protected function _getMethods(Behavior $instance, $class, $alias) * * @param string $method The method to check for. * @return bool + * @deprecated 5.3.0 Calling behavior methods on the table instance is deprecated. */ - public function hasMethod($method) + public function hasMethod(string $method): bool { $method = strtolower($method); @@ -229,7 +276,7 @@ public function hasMethod($method) * @param string $method The method to check for. * @return bool */ - public function hasFinder($method) + public function hasFinder(string $method): bool { $method = strtolower($method); @@ -243,41 +290,56 @@ public function hasFinder($method) * @param array $args The arguments you want to invoke the method with. * @return mixed The return value depends on the underlying behavior method. * @throws \BadMethodCallException When the method is unknown. + * @deprecated 5.3.0 Calling behavior methods on the table instance is deprecated. */ - public function call($method, array $args = []) + public function call(string $method, array $args = []): mixed { + deprecationWarning( + '5.3.0', + sprintf( + 'Calling behavior methods on the table instance is deprecated.' + . ' Use `$table->getBehavior(\'YourBehavior\')->%s()` instead.', + $method, + ), + ); + $method = strtolower($method); if ($this->hasMethod($method) && $this->has($this->_methodMap[$method][0])) { - list($behavior, $callMethod) = $this->_methodMap[$method]; + [$behavior, $callMethod] = $this->_methodMap[$method]; - return call_user_func_array([$this->_loaded[$behavior], $callMethod], $args); + return $this->_loaded[$behavior]->{$callMethod}(...$args); } throw new BadMethodCallException( - sprintf('Cannot call "%s" it does not belong to any attached behavior.', $method) + sprintf('Cannot call `%s`, it does not belong to any attached behavior.', $method), ); } /** * Invoke a finder on a behavior. * + * @internal + * @template TSubject of \Cake\Datasource\EntityInterface|array * @param string $type The finder type to invoke. - * @param array $args The arguments you want to invoke the method with. - * @return mixed The return value depends on the underlying behavior method. + * @param \Cake\ORM\Query\SelectQuery $query The query object to apply the finder options to. + * @param mixed ...$args Arguments that match up to finder-specific parameters + * @return \Cake\ORM\Query\SelectQuery The return value depends on the underlying behavior method. * @throws \BadMethodCallException When the method is unknown. */ - public function callFinder($type, array $args = []) + public function callFinder(string $type, SelectQuery $query, mixed ...$args): SelectQuery { $type = strtolower($type); - if ($this->hasFinder($type) && $this->has($this->_finderMap[$type][0])) { - list($behavior, $callMethod) = $this->_finderMap[$type]; + if ($this->hasFinder($type)) { + [$behavior, $callMethod] = $this->_finderMap[$type]; + /** @var \Closure $callable */ + $callable = $this->_loaded[$behavior]->$callMethod(...); - return call_user_func_array([$this->_loaded[$behavior], $callMethod], $args); + return $this->_table->invokeFinder($callable, $query, $args); } throw new BadMethodCallException( - sprintf('Cannot call finder "%s" it does not belong to any attached behavior.', $type) + sprintf('Cannot call finder `%s`, it does not belong to any attached behavior.', $type), ); } } diff --git a/src/ORM/DtoMapper.php b/src/ORM/DtoMapper.php new file mode 100644 index 00000000000..e3f18ba324e --- /dev/null +++ b/src/ORM/DtoMapper.php @@ -0,0 +1,192 @@ +Users->find() + * ->contain(['Roles', 'Comments']) + * ->projectAs(UserDto::class) + * ->all(); + * ``` + */ +class DtoMapper +{ + /** + * Cached reflection info per class. + * + * @var array}> + */ + protected static array $cache = []; + + /** + * Map array data to a DTO instance. + * + * @template T of object + * @param array $data The source data (typically from ORM) + * @param class-string $dtoClass The target DTO class + * @return T + */ + public function map(array $data, string $dtoClass): object + { + $info = $this->getClassInfo($dtoClass); + + $args = []; + foreach ($info['params'] as $name => $paramInfo) { + // isset() is faster than array_key_exists(), check for null separately + if (isset($data[$name])) { + $value = $data[$name]; + + // Handle nested DTO (type hint is a class) - only map arrays, pass objects through + if ($paramInfo['dtoClass'] !== null && is_array($value)) { + $value = $this->map($value, $paramInfo['dtoClass']); + } elseif ($paramInfo['collectionOf'] !== null) { + // Handle collection - inline loop avoids closure creation overhead + $collectionClass = $paramInfo['collectionOf']; + $mapped = []; + foreach ($value as $item) { + $mapped[] = is_array($item) ? $this->map($item, $collectionClass) : $item; + } + $value = $mapped; + } + + $args[$name] = $value; + } elseif (array_key_exists($name, $data)) { + // Value is explicitly null in data + $args[$name] = null; + } elseif ($paramInfo['hasDefault']) { + $args[$name] = $paramInfo['default']; + } elseif ($paramInfo['nullable']) { + $args[$name] = null; + } + // If required and not provided, let PHP throw the error + } + + return new $dtoClass(...$args); + } + + /** + * Get cached class info via reflection. + * + * @param class-string $class The class to analyze + * @return array{params: array} + */ + protected function getClassInfo(string $class): array + { + if (isset(static::$cache[$class])) { + return static::$cache[$class]; + } + + $reflection = new ReflectionClass($class); + $constructor = $reflection->getConstructor(); + + $params = []; + if ($constructor !== null) { + foreach ($constructor->getParameters() as $param) { + $params[$param->getName()] = $this->analyzeParameter($param); + } + } + + static::$cache[$class] = ['params' => $params]; + + return static::$cache[$class]; + } + + /** + * Analyze a constructor parameter for DTO mapping info. + * + * @param \ReflectionParameter $param The parameter to analyze + * @return array{name: string, nullable: bool, hasDefault: bool, default: mixed, dtoClass: class-string|null, collectionOf: class-string|null} + */ + protected function analyzeParameter(ReflectionParameter $param): array + { + $type = $param->getType(); + + $info = [ + 'name' => $param->getName(), + 'nullable' => $param->allowsNull(), + 'hasDefault' => $param->isDefaultValueAvailable(), + 'default' => $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null, + 'dtoClass' => null, + 'collectionOf' => null, + ]; + + // Check if type is a class (potential nested DTO) + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $typeName = $type->getName(); + // Exclude common non-DTO classes + if ( + !in_array($typeName, ['DateTime', 'DateTimeImmutable', 'DateTimeInterface', 'stdClass'], true) + && class_exists($typeName) + ) { + $info['dtoClass'] = $typeName; + } + } + + // Check for #[CollectionOf(SomeDto::class)] attribute + foreach ($param->getAttributes(CollectionOf::class) as $attr) { + /** @var class-string $collectionClass */ + $collectionClass = $attr->getArguments()[0]; + $info['collectionOf'] = $collectionClass; + $info['dtoClass'] = null; // Collection takes precedence + } + + return $info; + } + + /** + * Clear the reflection cache. + * + * Useful for testing or when classes are reloaded. + * + * @return void + */ + public static function clearCache(): void + { + static::$cache = []; + } +} diff --git a/src/ORM/EagerLoadable.php b/src/ORM/EagerLoadable.php index 1e7a1d483b5..65e4551284c 100644 --- a/src/ORM/EagerLoadable.php +++ b/src/ORM/EagerLoadable.php @@ -1,4 +1,6 @@ */ - protected $_associations = []; + protected array $_associations = []; /** * The Association class instance to use for loading the records. * - * @var \Cake\ORM\Association + * @var \Cake\ORM\Association|null */ - protected $_instance; + protected ?Association $_instance = null; /** * A list of options to pass to the association object for loading * the records. * - * @var array + * @var array */ - protected $_config = []; + protected array $_config = []; /** * A dotted separated string representing the path of associations @@ -60,7 +63,7 @@ class EagerLoadable * * @var string */ - protected $_aliasPath; + protected string $_aliasPath; /** * A dotted separated string representing the path of entity properties @@ -74,24 +77,24 @@ class EagerLoadable * * The property path of `country` will be `author.company` * - * @var string + * @var string|null */ - protected $_propertyPath; + protected ?string $_propertyPath = null; /** - * Whether or not this level can be fetched using a join. + * Whether this level can be fetched using a join. * * @var bool */ - protected $_canBeJoined = false; + protected bool $_canBeJoined = false; /** - * Whether or not this level was meant for a "matching" fetch + * Whether this level was meant for a "matching" fetch * operation * - * @var bool + * @var bool|null */ - protected $_forMatching; + protected ?bool $_forMatching = null; /** * The property name where the association result should be nested @@ -105,9 +108,9 @@ class EagerLoadable * * The target property of `country` will be just `country` * - * @var string + * @var string|null */ - protected $_targetProperty; + protected ?string $_targetProperty = null; /** * Constructor. The $config parameter accepts the following array @@ -125,14 +128,14 @@ class EagerLoadable * The keys maps to the settable properties in this class. * * @param string $name The Association name. - * @param array $config The list of properties to set. + * @param array $config The list of properties to set. */ - public function __construct($name, array $config = []) + public function __construct(string $name, array $config = []) { $this->_name = $name; $allowed = [ 'associations', 'instance', 'config', 'canBeJoined', - 'aliasPath', 'propertyPath', 'forMatching', 'targetProperty' + 'aliasPath', 'propertyPath', 'forMatching', 'targetProperty', ]; foreach ($allowed as $property) { if (isset($config[$property])) { @@ -148,7 +151,7 @@ public function __construct($name, array $config = []) * @param \Cake\ORM\EagerLoadable $association The association to load. * @return void */ - public function addAssociation($name, EagerLoadable $association) + public function addAssociation(string $name, EagerLoadable $association): void { $this->_associations[$name] = $association; } @@ -156,9 +159,9 @@ public function addAssociation($name, EagerLoadable $association) /** * Returns the Association class instance to use for loading the records. * - * @return array + * @return array */ - public function associations() + public function associations(): array { return $this->_associations; } @@ -166,10 +169,15 @@ public function associations() /** * Gets the Association class instance to use for loading the records. * - * @return \Cake\ORM\Association|null + * @return \Cake\ORM\Association + * @throws \Cake\Database\Exception\DatabaseException */ - public function instance() + public function instance(): Association { + if ($this->_instance === null) { + throw new DatabaseException('No instance set.'); + } + return $this->_instance; } @@ -177,9 +185,9 @@ public function instance() * Gets a dot separated string representing the path of associations * that should be followed to fetch this level. * - * @return string|null + * @return string */ - public function aliasPath() + public function aliasPath(): string { return $this->_aliasPath; } @@ -198,39 +206,31 @@ public function aliasPath() * * @return string|null */ - public function propertyPath() + public function propertyPath(): ?string { return $this->_propertyPath; } /** - * Sets whether or not this level can be fetched using a join. + * Sets whether this level can be fetched using a join. * * @param bool $possible The value to set. * @return $this */ - public function setCanBeJoined($possible) + public function setCanBeJoined(bool $possible) { - $this->_canBeJoined = (bool)$possible; + $this->_canBeJoined = $possible; return $this; } /** - * Gets whether or not this level can be fetched using a join. - * - * If called with arguments it sets the value. - * As of 3.4.0 the setter part is deprecated, use setCanBeJoined() instead. + * Gets whether this level can be fetched using a join. * - * @param bool|null $possible The value to set. * @return bool */ - public function canBeJoined($possible = null) + public function canBeJoined(): bool { - if ($possible !== null) { - $this->setCanBeJoined($possible); - } - return $this->_canBeJoined; } @@ -238,7 +238,7 @@ public function canBeJoined($possible = null) * Sets the list of options to pass to the association object for loading * the records. * - * @param array $config The value to set. + * @param array $config The value to set. * @return $this */ public function setConfig(array $config) @@ -252,40 +252,20 @@ public function setConfig(array $config) * Gets the list of options to pass to the association object for loading * the records. * - * @return array + * @return array */ - public function getConfig() + public function getConfig(): array { return $this->_config; } /** - * Sets the list of options to pass to the association object for loading - * the records. - * - * If called with no arguments it returns the current - * value. - * - * @deprecated 3.4.0 Use setConfig()/getConfig() instead. - * @param array|null $config The value to set. - * @return array - */ - public function config(array $config = null) - { - if ($config !== null) { - $this->setConfig($config); - } - - return $this->getConfig(); - } - - /** - * Gets whether or not this level was meant for a + * Gets whether this level was meant for a * "matching" fetch operation. * * @return bool|null */ - public function forMatching() + public function forMatching(): ?bool { return $this->_forMatching; } @@ -304,7 +284,7 @@ public function forMatching() * * @return string|null */ - public function targetProperty() + public function targetProperty(): ?string { return $this->_targetProperty; } @@ -313,9 +293,9 @@ public function targetProperty() * Returns a representation of this object that can be passed to * Cake\ORM\EagerLoader::contain() * - * @return array + * @return array */ - public function asContainArray() + public function asContainArray(): array { $associations = []; foreach ($this->_associations as $assoc) { @@ -329,8 +309,18 @@ public function asContainArray() return [ $this->_name => [ 'associations' => $associations, - 'config' => $config - ] + 'config' => $config, + ], ]; } + + /** + * Handles cloning eager loadables. + */ + public function __clone() + { + foreach ($this->_associations as $i => $association) { + $this->_associations[$i] = clone $association; + } + } } diff --git a/src/ORM/EagerLoader.php b/src/ORM/EagerLoader.php index 77f80736473..b035d6f9788 100644 --- a/src/ORM/EagerLoader.php +++ b/src/ORM/EagerLoader.php @@ -1,4 +1,6 @@ */ - protected $_containments = []; + protected array $_containments = []; /** * Contains a nested array with the compiled containments tree * This is a normalized version of the user provided containments array. * - * @var \Cake\ORM\EagerLoadable[]|\Cake\ORM\EagerLoadable|null + * @var array|null */ - protected $_normalized; + protected ?array $_normalized = null; /** * List of options accepted by associations in contain() - * index by key for faster access + * index by key for faster access. * - * @var array + * @var array */ - protected $_containOptions = [ + protected array $_containOptions = [ 'associations' => 1, 'foreignKey' => 1, 'conditions' => 1, @@ -62,46 +61,46 @@ class EagerLoader 'finder' => 1, 'joinType' => 1, 'strategy' => 1, - 'negateMatch' => 1 + 'negateMatch' => 1, + 'includeFields' => 1, ]; /** - * A list of associations that should be loaded with a separate query + * A list of associations that should be loaded with a separate query. * - * @var \Cake\ORM\EagerLoadable[] + * @var array */ - protected $_loadExternal = []; + protected array $_loadExternal = []; /** - * Contains a list of the association names that are to be eagerly loaded + * Contains a list of the association names that are to be eagerly loaded. * - * @var array + * @var array>> */ - protected $_aliasList = []; + protected array $_aliasList = []; /** * Another EagerLoader instance that will be used for 'matching' associations. * - * @var \Cake\ORM\EagerLoader + * @var \Cake\ORM\EagerLoader|null */ - protected $_matching; + protected ?EagerLoader $_matching = null; /** * A map of table aliases pointing to the association objects they represent * for the query. * - * @var array + * @var array */ - protected $_joinsMap = []; + protected array $_joinsMap = []; /** - * Controls whether or not fields from associated tables - * will be eagerly loaded. When set to false, no fields will - * be loaded from associations. + * Controls whether fields from associated tables will be eagerly loaded. + * When set to false, no fields will be loaded from associations. * * @var bool */ - protected $_autoFields = true; + protected bool $_autoFields = true; /** * Sets the list of associations that should be eagerly loaded along for a @@ -115,39 +114,35 @@ class EagerLoader * * Accepted options per passed association: * - * - foreignKey: Used to set a different field to match both tables, if set to false + * - `foreignKey`: Used to set a different field to match both tables, if set to false * no join conditions will be generated automatically - * - fields: An array with the fields that should be fetched from the association - * - queryBuilder: Equivalent to passing a callable instead of an options array - * - matching: Whether to inform the association class that it should filter the + * - `fields`: An array with the fields that should be fetched from the association + * - `queryBuilder`: Equivalent to passing a callback instead of an options array + * - `matching`: Whether to inform the association class that it should filter the * main query by the results fetched by that class. - * - joinType: For joinable associations, the SQL join type to use. - * - strategy: The loading strategy to use (join, select, subquery) + * - `joinType`: For joinable associations, the SQL join type to use. + * - `strategy`: The loading strategy to use (join, select, subquery) * - * @param array|string $associations list of table aliases to be queried. + * @param array|string $associations List of table aliases to be queried. * When this method is called multiple times it will merge previous list with * the new one. - * @param callable|null $queryBuilder The query builder callable + * @param \Closure|null $queryBuilder The query builder callback. * @return array Containments. * @throws \InvalidArgumentException When using $queryBuilder with an array of $associations */ - public function contain($associations = [], callable $queryBuilder = null) + public function contain(array|string $associations, ?Closure $queryBuilder = null): array { - if (empty($associations)) { - return $this->_containments; - } - if ($queryBuilder) { if (!is_string($associations)) { throw new InvalidArgumentException( - sprintf('Cannot set containments. To use $queryBuilder, $associations must be a string') + 'Cannot set containments. To use $queryBuilder, $associations must be a string', ); } $associations = [ $associations => [ - 'queryBuilder' => $queryBuilder - ] + 'queryBuilder' => $queryBuilder, + ], ]; } @@ -160,6 +155,19 @@ public function contain($associations = [], callable $queryBuilder = null) return $this->_containments = $associations; } + /** + * Gets the list of associations that should be eagerly loaded along for a + * specific table using when a query is provided. The list of associated tables + * passed to this method must have been previously set as associations using the + * Table API. + * + * @return array Containments. + */ + public function getContain(): array + { + return $this->_containments; + } + /** * Remove any existing non-matching based containments. * @@ -168,7 +176,7 @@ public function contain($associations = [], callable $queryBuilder = null) * * @return void */ - public function clearContain() + public function clearContain(): void { $this->_containments = []; $this->_normalized = null; @@ -177,42 +185,38 @@ public function clearContain() } /** - * Sets whether or not contained associations will load fields automatically. + * Sets whether contained associations will load fields automatically. * * @param bool $enable The value to set. * @return $this */ - public function enableAutoFields($enable = true) + public function enableAutoFields(bool $enable = true) { - $this->_autoFields = (bool)$enable; + $this->_autoFields = $enable; return $this; } /** - * Gets whether or not contained associations will load fields automatically. + * Disable auto loading fields of contained associations. * - * @return bool The current value. + * @return $this */ - public function isAutoFieldsEnabled() + public function disableAutoFields() { - return $this->_autoFields; + $this->_autoFields = false; + + return $this; } /** - * Sets/Gets whether or not contained associations will load fields automatically. + * Gets whether contained associations will load fields automatically. * - * @deprecated 3.4.0 Use enableAutoFields()/isAutoFieldsEnabled() instead. - * @param bool|null $enable The value to set. * @return bool The current value. */ - public function autoFields($enable = null) + public function isAutoFieldsEnabled(): bool { - if ($enable !== null) { - $this->enableAutoFields($enable); - } - - return $this->isAutoFieldsEnabled(); + return $this->_autoFields; } /** @@ -223,40 +227,36 @@ public function autoFields($enable = null) * `matching` option. * * ### Options - * - 'joinType': INNER, OUTER, ... - * - 'fields': Fields to contain * - * @param string $assoc A single association or a dot separated path of associations. - * @param callable|null $builder the callback function to be used for setting extra - * options to the filtering query - * @param array $options Extra options for the association matching. + * - `joinType`: INNER, OUTER, ... + * - `fields`: Fields to contain + * - `negateMatch`: Whether to add conditions negate match on target association + * + * @param string $associationPath Dot separated association path, 'Name1.Name2.Name3'. + * @param \Closure|null $builder the callback function to be used for setting extra + * options to the filtering query. + * @param array $options Extra options for the association matching. * @return $this */ - public function setMatching($assoc, callable $builder = null, $options = []) + public function setMatching(string $associationPath, ?Closure $builder = null, array $options = []) { - if ($this->_matching === null) { - $this->_matching = new static(); + $this->_matching ??= new static(); + + $options += ['joinType' => SelectQuery::JOIN_TYPE_INNER]; + $sharedOptions = ['negateMatch' => false, 'matching' => true] + $options; + + $contains = []; + $nested = &$contains; + foreach (explode('.', $associationPath) as $association) { + // Add contain to parent contain using association name as key + $nested[$association] = $sharedOptions; + // Set to next nested level + $nested = &$nested[$association]; } - if (!isset($options['joinType'])) { - $options['joinType'] = QueryInterface::JOIN_TYPE_INNER; - } - - $assocs = explode('.', $assoc); - $last = array_pop($assocs); - $containments = []; - $pointer =& $containments; - $opts = ['matching' => true] + $options; - unset($opts['negateMatch']); - - foreach ($assocs as $name) { - $pointer[$name] = $opts; - $pointer =& $pointer[$name]; - } - - $pointer[$last] = ['queryBuilder' => $builder, 'matching' => true] + $options; - - $this->_matching->contain($containments); + // Add all options to target association contain which is the last in nested chain + $nested = ['matching' => true, 'queryBuilder' => $builder ?? fn($q) => $q] + $options; + $this->_matching->contain($contains); return $this; } @@ -264,42 +264,13 @@ public function setMatching($assoc, callable $builder = null, $options = []) /** * Returns the current tree of associations to be matched. * - * @return array The resulting containments array - */ - public function getMatching() - { - if ($this->_matching === null) { - $this->_matching = new static(); - } - - return $this->_matching->contain(); - } - - /** - * Adds a new association to the list that will be used to filter the results of - * any given query based on the results of finding records for that association. - * You can pass a dot separated path of associations to this method as its first - * parameter, this will translate in setting all those associations with the - * `matching` option. - * - * If called with no arguments it will return the current tree of associations to - * be matched. - * - * @deprecated 3.4.0 Use setMatching()/getMatching() instead. - * @param string|null $assoc A single association or a dot separated path of associations. - * @param callable|null $builder the callback function to be used for setting extra - * options to the filtering query - * @param array $options Extra options for the association matching, such as 'joinType' - * and 'fields' - * @return array The resulting containments array + * @return array The resulting containments array. */ - public function matching($assoc = null, callable $builder = null, $options = []) + public function getMatching(): array { - if ($assoc !== null) { - $this->setMatching($assoc, $builder, $options); - } + $this->_matching ??= new static(); - return $this->getMatching(); + return $this->_matching->getContain(); } /** @@ -307,34 +278,30 @@ public function matching($assoc = null, callable $builder = null, $options = []) * loaded for a table. The normalized array will restructure the original array * by sorting all associations under one key and special options under another. * - * Each of the levels of the associations tree will converted to a Cake\ORM\EagerLoadable + * Each of the levels of the associations tree will be converted to a {@link \Cake\ORM\EagerLoadable} * object, that contains all the information required for the association objects * to load the information from the database. * - * Additionally it will set an 'instance' key per association containing the + * Additionally, it will set an 'instance' key per association containing the * association instance from the corresponding source table * * @param \Cake\ORM\Table $repository The table containing the association that - * will be normalized - * @return array + * will be normalized. + * @return array */ - public function normalized(Table $repository) + public function normalized(Table $repository): array { - if ($this->_normalized !== null || empty($this->_containments)) { - return (array)$this->_normalized; + if ($this->_normalized !== null) { + return $this->_normalized; } $contain = []; foreach ($this->_containments as $alias => $options) { - if (!empty($options['instance'])) { - $contain = (array)$this->_containments; - break; - } $contain[$alias] = $this->_normalizeContain( $repository, $alias, $options, - ['root' => null] + ['root' => ''], ); } @@ -344,19 +311,19 @@ public function normalized(Table $repository) /** * Formats the containments array so that associations are always set as keys * in the array. This function merges the original associations array with - * the new associations provided + * the new associations provided. * - * @param array $associations user provided containments array + * @param array $associations User provided containments array. * @param array $original The original containments array to merge - * with the new one - * @return array + * with the new one. + * @return array */ - protected function _reformatContain($associations, $original) + protected function _reformatContain(array $associations, array $original): array { $result = $original; - foreach ((array)$associations as $table => $options) { - $pointer =& $result; + foreach ($associations as $table => $options) { + $pointer = &$result; if (is_int($table)) { $table = $options; $options = []; @@ -373,23 +340,32 @@ protected function _reformatContain($associations, $original) continue; } - if (strpos($table, '.')) { + if (str_contains($table, '.')) { $path = explode('.', $table); $table = array_pop($path); foreach ($path as $t) { $pointer += [$t => []]; - $pointer =& $pointer[$t]; + $pointer = &$pointer[$t]; } } if (is_array($options)) { - $options = isset($options['config']) ? - $options['config'] + $options['associations'] : - $options; - $options = $this->_reformatContain( - $options, - isset($pointer[$table]) ? $pointer[$table] : [] - ); + // When options come from asContainArray(), they have 'config' and 'associations' keys + // We need to keep them separate to avoid config options being treated as associations + if (isset($options['config'], $options['associations'])) { + // Process associations recursively, but keep config separate + $associations = $this->_reformatContain( + $options['associations'], + $pointer[$table] ?? [], + ); + // Merge config with associations, ensuring config options stay as options + $options = $options['config'] + $associations; + } else { + $options = $this->_reformatContain( + $options, + $pointer[$table] ?? [], + ); + } } if ($options instanceof Closure) { @@ -398,15 +374,15 @@ protected function _reformatContain($associations, $original) $pointer += [$table => []]; - if (isset($options['queryBuilder']) && isset($pointer[$table]['queryBuilder'])) { + if (isset($options['queryBuilder'], $pointer[$table]['queryBuilder'])) { + assert(is_callable($pointer[$table]['queryBuilder'])); $first = $pointer[$table]['queryBuilder']; + assert(is_callable($options['queryBuilder'])); $second = $options['queryBuilder']; - $options['queryBuilder'] = function ($query) use ($first, $second) { - return $second($first($query)); - }; + $options['queryBuilder'] = fn($query) => $second($first($query)); } - if (!is_array($options)) { + if (is_string($options)) { $options = [$options => []]; } @@ -422,16 +398,16 @@ protected function _reformatContain($associations, $original) * This method will not modify the query for loading external associations, i.e. * those that cannot be loaded without executing a separate query. * - * @param \Cake\ORM\Query $query The query to be modified + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query The query to be modified. * @param \Cake\ORM\Table $repository The repository containing the associations * @param bool $includeFields whether to append all fields from the associations * to the passed query. This can be overridden according to the settings defined - * per association in the containments array + * per association in the containments array. * @return void */ - public function attachAssociations(Query $query, Table $repository, $includeFields) + public function attachAssociations(SelectQuery $query, Table $repository, bool $includeFields): void { - if (empty($this->_containments) && $this->_matching === null) { + if (!$this->_containments && $this->_matching === null) { return; } @@ -450,7 +426,7 @@ public function attachAssociations(Query $query, Table $repository, $includeFiel $newAttachable = $this->attachableAssociations($repository); $attachable = array_diff_key($newAttachable, $processed); - } while (!empty($attachable)); + } while ($attachable !== []); } /** @@ -459,10 +435,10 @@ public function attachAssociations(Query $query, Table $repository, $includeFiel * with Cake\ORM\EagerLoadable objects. * * @param \Cake\ORM\Table $repository The table containing the associations to be - * attached - * @return array + * attached. + * @return array */ - public function attachableAssociations(Table $repository) + public function attachableAssociations(Table $repository): array { $contain = $this->normalized($repository); $matching = $this->_matching ? $this->_matching->normalized($repository) : []; @@ -474,13 +450,13 @@ public function attachableAssociations(Table $repository) /** * Returns an array with the associations that need to be fetched using a - * separate query, each array value will contain a Cake\ORM\EagerLoadable object. + * separate query, each array value will contain a {@link \Cake\ORM\EagerLoadable} object. * * @param \Cake\ORM\Table $repository The table containing the associations - * to be loaded - * @return \Cake\ORM\EagerLoadable[] + * to be loaded. + * @return array<\Cake\ORM\EagerLoadable> */ - public function externalAssociations(Table $repository) + public function externalAssociations(Table $repository): array { if ($this->_loadExternal) { return $this->_loadExternal; @@ -493,31 +469,34 @@ public function externalAssociations(Table $repository) /** * Auxiliary function responsible for fully normalizing deep associations defined - * using `contain()` + * using `contain()`. * - * @param \Cake\ORM\Table $parent owning side of the association - * @param string $alias name of the association to be loaded - * @param array $options list of extra options to use for this association - * @param array $paths An array with two values, the first one is a list of dot + * @param \Cake\ORM\Table $parent Owning side of the association. + * @param string $alias Name of the association to be loaded. + * @param array $options List of extra options to use for this association. + * @param array $paths An array with two values, the first one is a list of dot * separated strings representing associations that lead to this `$alias` in the * chain of associations to be loaded. The second value is the path to follow in * entities' properties to fetch a record of the corresponding association. * @return \Cake\ORM\EagerLoadable Object with normalized associations * @throws \InvalidArgumentException When containments refer to associations that do not exist. */ - protected function _normalizeContain(Table $parent, $alias, $options, $paths) + protected function _normalizeContain(Table $parent, string $alias, array $options, array $paths): EagerLoadable { $defaults = $this->_containOptions; - $instance = $parent->association($alias); - if (!$instance) { - throw new InvalidArgumentException( - sprintf('%s is not associated with %s', $parent->getAlias(), $alias) - ); - } + $instance = $parent->getAssociation($alias); $paths += ['aliasPath' => '', 'propertyPath' => '', 'root' => $alias]; $paths['aliasPath'] .= '.' . $alias; - $paths['propertyPath'] .= '.' . $instance->getProperty(); + + if ( + isset($options['matching']) && + $options['matching'] === true + ) { + $paths['propertyPath'] = '_matchingData.' . $alias; + } else { + $paths['propertyPath'] .= '.' . $instance->getProperty(); + } $table = $instance->getTarget(); @@ -528,7 +507,7 @@ protected function _normalizeContain(Table $parent, $alias, $options, $paths) 'config' => array_diff_key($options, $extra), 'aliasPath' => trim($paths['aliasPath'], '.'), 'propertyPath' => trim($paths['propertyPath'], '.'), - 'targetProperty' => $instance->getProperty() + 'targetProperty' => $instance->getProperty(), ]; $config['canBeJoined'] = $instance->canBeJoined($config['config']); $eagerLoadable = new EagerLoadable($alias, $config); @@ -542,7 +521,7 @@ protected function _normalizeContain(Table $parent, $alias, $options, $paths) foreach ($extra as $t => $assoc) { $eagerLoadable->addAssociation( $t, - $this->_normalizeContain($table, $t, $assoc, $paths) + $this->_normalizeContain($table, $t, $assoc, $paths), ); } @@ -558,16 +537,15 @@ protected function _normalizeContain(Table $parent, $alias, $options, $paths) * * @return void */ - protected function _fixStrategies() + protected function _fixStrategies(): void { foreach ($this->_aliasList as $aliases) { foreach ($aliases as $configs) { if (count($configs) < 2) { continue; } - /* @var \Cake\ORM\EagerLoadable $loadable */ foreach ($configs as $loadable) { - if (strpos($loadable->aliasPath(), '.')) { + if (str_contains($loadable->aliasPath(), '.')) { $this->_correctStrategy($loadable); } } @@ -577,19 +555,17 @@ protected function _fixStrategies() /** * Changes the association fetching strategy if required because of duplicate - * under the same direct associations chain + * under the same direct associations chain. * - * @param \Cake\ORM\EagerLoadable $loadable The association config + * @param \Cake\ORM\EagerLoadable $loadable The association config. * @return void */ - protected function _correctStrategy($loadable) + protected function _correctStrategy(EagerLoadable $loadable): void { $config = $loadable->getConfig(); - $currentStrategy = isset($config['strategy']) ? - $config['strategy'] : - 'join'; + $currentStrategy = $config['strategy'] ?? Association::STRATEGY_JOIN; - if (!$loadable->canBeJoined() || $currentStrategy !== 'join') { + if (!$loadable->canBeJoined() || $currentStrategy !== Association::STRATEGY_JOIN) { return; } @@ -602,22 +578,22 @@ protected function _correctStrategy($loadable) * Helper function used to compile a list of all associations that can be * joined in the query. * - * @param array $associations list of associations from which to obtain joins. - * @param array $matching list of associations that should be forcibly joined. - * @return array + * @param array $associations List of associations from which to obtain joins. + * @param array $matching List of associations that should be forcibly joined. + * @return array */ - protected function _resolveJoins($associations, $matching = []) + protected function _resolveJoins(array $associations, array $matching = []): array { $result = []; foreach ($matching as $table => $loadable) { $result[$table] = $loadable; - $result += $this->_resolveJoins($loadable->associations(), []); + $result = $this->mergeJoins($result, $this->_resolveJoins($loadable->associations(), [])); } foreach ($associations as $table => $loadable) { $inMatching = isset($matching[$table]); if (!$inMatching && $loadable->canBeJoined()) { $result[$table] = $loadable; - $result += $this->_resolveJoins($loadable->associations(), []); + $result = $this->mergeJoins($result, $this->_resolveJoins($loadable->associations(), [])); continue; } @@ -633,23 +609,56 @@ protected function _resolveJoins($associations, $matching = []) } /** - * Decorates the passed statement object in order to inject data from associations - * that cannot be joined directly. + * Merges association joins and throws an exception if there are conflicts. * - * @param \Cake\ORM\Query $query The query for which to eager load external - * associations - * @param \Cake\Database\StatementInterface $statement The statement created after executing the $query - * @return \Cake\Database\StatementInterface statement modified statement with extra loaders + * @param array $a + * @param array $b + * @return array */ - public function loadExternal($query, $statement) + private function mergeJoins(array $a, array $b): array { - $external = $this->externalAssociations($query->repository()); - if (empty($external)) { - return $statement; + foreach ($b as $alias => $loadable) { + if (isset($a[$alias])) { + assert(false, sprintf( + 'You cannot join with `%s` because it conflicts with the existing `%s` join.' + . ' The existing join will be lost.', + $loadable->aliasPath(), + $a[$alias]->aliasPath(), + )); + } + } + + return $a + $b; + } + + /** + * Inject data from associations that cannot be joined directly. + * + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query The query for which to eager load external. + * associations. + * @param iterable $results Results. + * @return iterable + * @throws \RuntimeException + */ + public function loadExternal(SelectQuery $query, iterable $results): iterable + { + if (!$results) { + return $results; + } + + $external = $this->externalAssociations($query->getRepository()); + if (!$external) { + return $results; } - $driver = $query->getConnection()->getDriver(); - list($collected, $statement) = $this->_collectKeys($external, $query, $statement); + if (!is_array($results)) { + $results = iterator_to_array($results); + } + if (!$results) { + return $results; + } + + $collected = $this->_collectKeys($external, $query, $results); foreach ($external as $meta) { $contain = $meta->associations(); @@ -659,53 +668,69 @@ public function loadExternal($query, $statement) $path = $meta->aliasPath(); $requiresKeys = $instance->requiresKeys($config); - if ($requiresKeys && empty($collected[$path][$alias])) { - continue; + if ($requiresKeys) { + // If the path or alias has no key the required association load will fail. + // Nested paths are not subject to this condition because they could + // be attached to joined associations. + if ( + !str_contains($path, '.') && + (!array_key_exists($path, $collected) || !array_key_exists($alias, $collected[$path])) + ) { + $message = "Unable to load `{$path}` association. Ensure foreign key in `{$alias}` is selected."; + throw new InvalidArgumentException($message); + } + + // If the association foreign keys are missing skip loading + // as the association could be optional. + if (empty($collected[$path][$alias])) { + continue; + } } - $keys = isset($collected[$path][$alias]) ? $collected[$path][$alias] : null; - $f = $instance->eagerLoader( + $keys = $collected[$path][$alias] ?? null; + $callback = $instance->eagerLoader( $config + [ 'query' => $query, 'contain' => $contain, 'keys' => $keys, - 'nestKey' => $meta->aliasPath() - ] + 'nestKey' => $meta->aliasPath(), + ], ); - $statement = new CallbackStatement($statement, $driver, $f); + $results = array_map($callback, $results); } - return $statement; + return $results; } /** * Returns an array having as keys a dotted path of associations that participate - * in this eager loader. The values of the array will contain the following keys + * in this eager loader. The values of the array will contain the following keys: * - * - alias: The association alias - * - instance: The association instance - * - canBeJoined: Whether or not the association will be loaded using a JOIN - * - entityClass: The entity that should be used for hydrating the results - * - nestKey: A dotted path that can be used to correctly insert the data into the results. - * - matching: Whether or not it is an association loaded through `matching()`. + * - `alias`: The association alias + * - `instance`: The association instance + * - `canBeJoined`: Whether the association will be loaded using a JOIN + * - `entityClass`: The entity that should be used for hydrating the results + * - `nestKey`: A dotted path that can be used to correctly insert the data into the results. + * - `matching`: Whether it is an association loaded through `matching()`. * * @param \Cake\ORM\Table $table The table containing the association that - * will be normalized + * will be normalized. * @return array */ - public function associationsMap($table) + public function associationsMap(Table $table): array { $map = []; - if (!$this->getMatching() && !$this->contain() && empty($this->_joinsMap)) { + if (!$this->getMatching() && !$this->getContain() && $this->_joinsMap === []) { return $map; } + assert($this->_matching !== null, 'EagerLoader not available'); + $map = $this->_buildAssociationsMap($map, $this->_matching->normalized($table), true); $map = $this->_buildAssociationsMap($map, $this->normalized($table)); - $map = $this->_buildAssociationsMap($map, $this->_joinsMap); - return $map; + return $this->_buildAssociationsMap($map, $this->_joinsMap); } /** @@ -713,13 +738,12 @@ public function associationsMap($table) * associationsMap() method. * * @param array $map An initial array for the map. - * @param array $level An array of EagerLoadable instances. - * @param bool $matching Whether or not it is an association loaded through `matching()`. + * @param array<\Cake\ORM\EagerLoadable> $level An array of EagerLoadable instances. + * @param bool $matching Whether it is an association loaded through `matching()`. * @return array */ - protected function _buildAssociationsMap($map, $level, $matching = false) + protected function _buildAssociationsMap(array $map, array $level, bool $matching = false): array { - /* @var \Cake\ORM\EagerLoadable $meta */ foreach ($level as $assoc => $meta) { $canBeJoined = $meta->canBeJoined(); $instance = $meta->instance(); @@ -731,8 +755,8 @@ protected function _buildAssociationsMap($map, $level, $matching = false) 'canBeJoined' => $canBeJoined, 'entityClass' => $instance->getTarget()->getEntityClass(), 'nestKey' => $canBeJoined ? $assoc : $meta->aliasPath(), - 'matching' => $forMatching !== null ? $forMatching : $matching, - 'targetProperty' => $meta->targetProperty() + 'matching' => $forMatching ?? $matching, + 'targetProperty' => $meta->targetProperty(), ]; if ($canBeJoined && $associations) { $map = $this->_buildAssociationsMap($map, $associations, $matching); @@ -749,21 +773,25 @@ protected function _buildAssociationsMap($map, $level, $matching = false) * * @param string $alias The table alias as it appears in the query. * @param \Cake\ORM\Association $assoc The association object the alias represents; - * will be normalized - * @param bool $asMatching Whether or not this join results should be treated as a + * will be normalized. + * @param bool $asMatching Whether this join results should be treated as a * 'matching' association. - * @param string $targetProperty The property name where the results of the join should be nested at. + * @param string|null $targetProperty The property name where the results of the join should be nested at. * If not passed, the default property for the association will be used. * @return void */ - public function addToJoinsMap($alias, Association $assoc, $asMatching = false, $targetProperty = null) - { + public function addToJoinsMap( + string $alias, + Association $assoc, + bool $asMatching = false, + ?string $targetProperty = null, + ): void { $this->_joinsMap[$alias] = new EagerLoadable($alias, [ 'aliasPath' => $alias, 'instance' => $assoc, 'canBeJoined' => true, 'forMatching' => $asMatching, - 'targetProperty' => $targetProperty ?: $assoc->getProperty() + 'targetProperty' => $targetProperty ?: $assoc->getProperty(), ]); } @@ -771,15 +799,14 @@ public function addToJoinsMap($alias, Association $assoc, $asMatching = false, $ * Helper function used to return the keys from the query records that will be used * to eagerly load associations. * - * @param array $external the list of external associations to be loaded - * @param \Cake\ORM\Query $query The query from which the results where generated - * @param \Cake\Database\Statement\BufferedStatement $statement The statement to work on + * @param array<\Cake\ORM\EagerLoadable> $external The list of external associations to be loaded. + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query The query from which the results where generated. + * @param array $results Results array. * @return array */ - protected function _collectKeys($external, $query, $statement) + protected function _collectKeys(array $external, SelectQuery $query, array $results): array { $collectKeys = []; - /* @var \Cake\ORM\EagerLoadable $meta */ foreach ($external as $meta) { $instance = $meta->instance(); if (!$instance->requiresKeys($meta->getConfig())) { @@ -793,43 +820,46 @@ protected function _collectKeys($external, $query, $statement) $alias = $source->getAlias(); $pkFields = []; + /** @var string $key */ foreach ($keys as $key) { $pkFields[] = key($query->aliasField($key, $alias)); } $collectKeys[$meta->aliasPath()] = [$alias, $pkFields, count($pkFields) === 1]; } - - if (empty($collectKeys)) { - return [[], $statement]; - } - - if (!($statement instanceof BufferedStatement)) { - $statement = new BufferedStatement($statement, $query->getConnection()->getDriver()); + if (!$collectKeys) { + return []; } - return [$this->_groupKeys($statement, $collectKeys), $statement]; + return $this->_groupKeys($results, $collectKeys); } /** * Helper function used to iterate a statement and extract the columns - * defined in $collectKeys + * defined in $collectKeys. * - * @param \Cake\Database\Statement\BufferedStatement $statement The statement to read from. - * @param array $collectKeys The keys to collect + * @param array $results Results array. + * @param array $collectKeys The keys to collect. * @return array */ - protected function _groupKeys($statement, $collectKeys) + protected function _groupKeys(array $results, array $collectKeys): array { $keys = []; - while ($result = $statement->fetch('assoc')) { + foreach ($results as $result) { foreach ($collectKeys as $nestKey => $parts) { - // Missed joins will have null in the results. - if ($parts[2] === true && !isset($result[$parts[1][0]])) { - continue; - } if ($parts[2] === true) { - $value = $result[$parts[1][0]]; - $keys[$nestKey][$parts[0]][$value] = $value; + // Missed joins will have null in the results. + if (!array_key_exists($parts[1][0], $result)) { + continue; + } + // Assign empty array to avoid not found association when optional. + if (!isset($result[$parts[1][0]])) { + if (!isset($keys[$nestKey][$parts[0]])) { + $keys[$nestKey][$parts[0]] = []; + } + } else { + $value = $result[$parts[1][0]]; + $keys[$nestKey][$parts[0]][$value] = $value; + } continue; } @@ -842,17 +872,11 @@ protected function _groupKeys($statement, $collectKeys) } } - $statement->rewind(); - return $keys; } /** - * Clone hook implementation - * - * Clone the _matching eager loader as well. - * - * @return void + * Handles cloning eager loaders and eager loadables. */ public function __clone() { diff --git a/src/ORM/Entity.php b/src/ORM/Entity.php index a3771491e02..bd5de5494fa 100644 --- a/src/ORM/Entity.php +++ b/src/ORM/Entity.php @@ -1,4 +1,6 @@ 1, 'name' => 'Andrew']) * ``` * - * @param array $properties hash of properties to set in this entity - * @param array $options list of options to use when creating this entity + * @param array $properties hash of properties to set in this entity + * @param array $options list of options to use when creating this entity */ public function __construct(array $properties = [], array $options = []) { @@ -52,27 +54,31 @@ public function __construct(array $properties = [], array $options = []) 'markClean' => false, 'markNew' => null, 'guard' => false, - 'source' => null + 'source' => null, ]; - if (!empty($options['source'])) { + if ($options['source'] !== null) { $this->setSource($options['source']); } if ($options['markNew'] !== null) { - $this->isNew($options['markNew']); + $this->setNew($options['markNew']); } - if (!empty($properties) && $options['markClean'] && !$options['useSetters']) { - $this->_properties = $properties; + if ($properties) { + //Remember the original field names here. + $this->setOriginalField(array_keys($properties)); - return; - } + if ($options['markClean'] && !$options['useSetters']) { + $this->_fields = $properties; + + return; + } - if (!empty($properties)) { - $this->set($properties, [ + $this->patch($properties, [ + 'asOriginal' => true, 'setter' => $options['useSetters'], - 'guard' => $options['guard'] + 'guard' => $options['guard'], ]); } diff --git a/src/ORM/Exception/MissingBehaviorException.php b/src/ORM/Exception/MissingBehaviorException.php index 725e5e8ac6d..a9fd0ade58c 100644 --- a/src/ORM/Exception/MissingBehaviorException.php +++ b/src/ORM/Exception/MissingBehaviorException.php @@ -1,4 +1,6 @@ |string $message Either the string of the error message, or an array of attributes * that are made available in the view, and sprintf()'d into Exception::$_messageTemplate - * @param int $code The code of the error, is also the HTTP status code for the error. - * @param \Exception|null $previous the previous exception. + * @param int|null $code The code of the error, is also the HTTP status code for the error. + * @param \Throwable|null $previous the previous exception. */ - public function __construct(EntityInterface $entity, $message, $code = null, $previous = null) - { + public function __construct( + EntityInterface $entity, + array|string $message, + ?int $code = null, + ?Throwable $previous = null, + ) { $this->_entity = $entity; + if (is_array($message)) { + $errors = []; + foreach (Hash::flatten($entity->getErrors()) as $field => $error) { + $errors[] = $field . ': "' . $error . '"'; + } + if ($errors) { + $message[] = implode(', ', $errors); + $this->_messageTemplate = 'Entity %s failure. Found the following errors (%s).'; + } + } parent::__construct($message, $code, $previous); } @@ -53,7 +70,7 @@ public function __construct(EntityInterface $entity, $message, $code = null, $pr * * @return \Cake\Datasource\EntityInterface */ - public function getEntity() + public function getEntity(): EntityInterface { return $this->_entity; } diff --git a/src/ORM/Exception/RolledbackTransactionException.php b/src/ORM/Exception/RolledbackTransactionException.php index ac31a9d0fcb..c542b1fa9e6 100644 --- a/src/ORM/Exception/RolledbackTransactionException.php +++ b/src/ORM/Exception/RolledbackTransactionException.php @@ -1,4 +1,6 @@ $entities a single entity or list of entities * @param array $contain A `contain()` compatible array. - * @see \Cake\ORM\Query\contain() + * @see \Cake\ORM\Query\SelectQuery::contain() * @param \Cake\ORM\Table $source The table to use for fetching the top level entities - * @return \Cake\Datasource\EntityInterface|array + * @return \Cake\Datasource\EntityInterface|array<\Cake\Datasource\EntityInterface> */ - public function loadInto($entities, array $contain, Table $source) + public function loadInto(EntityInterface|array $entities, array $contain, Table $source): EntityInterface|array { $returnSingle = false; @@ -49,12 +52,12 @@ public function loadInto($entities, array $contain, Table $source) $returnSingle = true; } - $entities = new Collection($entities); $query = $this->_getQuery($entities, $contain, $source); - $associations = array_keys($query->contain()); + $associations = array_keys($query->getContain()); $entities = $this->_injectResults($entities, $query, $associations, $source); + /** @var \Cake\Datasource\EntityInterface|array<\Cake\Datasource\EntityInterface> */ return $returnSingle ? array_shift($entities) : $entities; } @@ -62,37 +65,36 @@ public function loadInto($entities, array $contain, Table $source) * Builds a query for loading the passed list of entity objects along with the * associations specified in $contain. * - * @param \Cake\Collection\CollectionInterface $objects The original entities + * @param array<\Cake\Datasource\EntityInterface> $entities The original entities * @param array $contain The associations to be loaded * @param \Cake\ORM\Table $source The table to use for fetching the top level entities - * @return \Cake\ORM\Query + * @return \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> */ - protected function _getQuery($objects, $contain, $source) + protected function _getQuery(array $entities, array $contain, Table $source): SelectQuery { $primaryKey = $source->getPrimaryKey(); $method = is_string($primaryKey) ? 'get' : 'extract'; - $keys = $objects->map(function ($entity) use ($primaryKey, $method) { - return $entity->{$method}($primaryKey); - }); + $keys = Hash::map($entities, '{*}', fn(EntityInterface $entity) => $entity->{$method}($primaryKey)); $query = $source ->find() ->select((array)$primaryKey) - ->where(function ($exp, $q) use ($primaryKey, $keys, $source) { + ->where(function (QueryExpression $exp, SelectQuery $q) use ($primaryKey, $keys, $source) { if (is_array($primaryKey) && count($primaryKey) === 1) { $primaryKey = current($primaryKey); } if (is_string($primaryKey)) { - return $exp->in($source->aliasField($primaryKey), $keys->toList()); + return $exp->in($source->aliasField($primaryKey), $keys); } - $types = array_intersect_key($q->defaultTypes(), array_flip($primaryKey)); - $primaryKey = array_map([$source, 'aliasField'], $primaryKey); + $types = array_intersect_key($q->getDefaultTypes(), array_flip($primaryKey)); + $primaryKey = array_map($source->aliasField(...), $primaryKey); - return new TupleComparison($primaryKey, $keys->toList(), $types, 'IN'); + return new TupleComparison($primaryKey, $keys, $types, 'IN'); }) + ->enableAutoFields() ->contain($contain); foreach ($query->getEagerLoader()->attachableAssociations($source) as $loadable) { @@ -109,15 +111,17 @@ protected function _getQuery($objects, $contain, $source) * in the top level entities. * * @param \Cake\ORM\Table $source The table having the top level associations - * @param array $associations The name of the top level associations - * @return array + * @param array $associations The name of the top level associations + * @return array */ - protected function _getPropertyMap($source, $associations) + protected function _getPropertyMap(Table $source, array $associations): array { $map = []; $container = $source->associations(); foreach ($associations as $assoc) { - $map[$assoc] = $container->get($assoc)->getProperty(); + /** @var \Cake\ORM\Association $association */ + $association = $container->get($assoc); + $map[$assoc] = $association->getProperty(); } return $map; @@ -127,24 +131,28 @@ protected function _getPropertyMap($source, $associations) * Injects the results of the eager loader query into the original list of * entities. * - * @param array|\Traversable $objects The original list of entities - * @param \Cake\Collection\CollectionInterface|\Cake\Database\Query $results The loaded results - * @param array $associations The top level associations that were loaded + * @param array<\Cake\Datasource\EntityInterface> $entities The original list of entities + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query The query to load results + * @param array $associations The top level associations that were loaded * @param \Cake\ORM\Table $source The table where the entities came from - * @return array + * @return array<\Cake\Datasource\EntityInterface> */ - protected function _injectResults($objects, $results, $associations, $source) - { + protected function _injectResults( + array $entities, + SelectQuery $query, + array $associations, + Table $source, + ): array { $injected = []; $properties = $this->_getPropertyMap($source, $associations); $primaryKey = (array)$source->getPrimaryKey(); - $results = $results - ->indexBy(function ($e) use ($primaryKey) { - return implode(';', $e->extract($primaryKey)); - }) + /** @var array<\Cake\Datasource\EntityInterface> $results */ + $results = $query + ->all() + ->indexBy(fn(EntityInterface $e) => implode(';', $e->extract($primaryKey))) ->toArray(); - foreach ($objects as $k => $object) { + foreach ($entities as $k => $object) { $key = implode(';', $object->extract($primaryKey)); if (!isset($results[$key])) { $injected[$k] = $object; diff --git a/src/ORM/Locator/LocatorAwareTrait.php b/src/ORM/Locator/LocatorAwareTrait.php index b857518cc47..850af30aea2 100644 --- a/src/ORM/Locator/LocatorAwareTrait.php +++ b/src/ORM/Locator/LocatorAwareTrait.php @@ -1,4 +1,6 @@ setTableLocator($tableLocator); - } - - return $this->getTableLocator(); - } + protected ?LocatorInterface $_tableLocator = null; /** * Sets the table locator. @@ -64,12 +57,45 @@ public function setTableLocator(LocatorInterface $tableLocator) * * @return \Cake\ORM\Locator\LocatorInterface */ - public function getTableLocator() + public function getTableLocator(): LocatorInterface + { + if ($this->_tableLocator !== null) { + return $this->_tableLocator; + } + + $locator = FactoryLocator::get('Table'); + assert( + $locator instanceof LocatorInterface, + '`FactoryLocator` must return an instance of Cake\ORM\LocatorInterface for type `Table`.', + ); + + return $this->_tableLocator = $locator; + } + + /** + * Convenience method to get a table instance. + * + * @template T of \Cake\ORM\Table + * @param class-string|string|null $alias The alias name you want to get. Should be in CamelCase format. + * If `null` then the value of $defaultTable property is used. + * @param array $options The options you want to build the table with. + * If a table has already been loaded the registry options will be ignored. + * @return ($alias is class-string ? T : \Cake\ORM\Table) + * @throws \Cake\Core\Exception\CakeException If `$alias` argument and `$defaultTable` property both are `null`. + * @see \Cake\ORM\Locator\TableLocator::get() + * @since 4.3.0 + */ + public function fetchTable(?string $alias = null, array $options = []): Table { - if (!$this->_tableLocator) { - $this->_tableLocator = TableRegistry::getTableLocator(); + $alias ??= $this->defaultTable; + if (!$alias) { + throw new UnexpectedValueException( + 'You must provide an `$alias` or set the `$defaultTable` property to a non empty string.', + ); } - return $this->_tableLocator; + // phpcs:ignore + /** @var T */ + return $this->getTableLocator()->get($alias, $options); } } diff --git a/src/ORM/Locator/LocatorInterface.php b/src/ORM/Locator/LocatorInterface.php index f8dda59c1d4..71b59cab700 100644 --- a/src/ORM/Locator/LocatorInterface.php +++ b/src/ORM/Locator/LocatorInterface.php @@ -1,4 +1,6 @@ */ -interface LocatorInterface +interface LocatorInterface extends BaseLocatorInterface { + /** + * Returns configuration for an alias or the full configuration array for + * all aliases. + * + * @param string|null $alias Alias to get config for, null for complete config. + * @return array The config data. + */ + public function getConfig(?string $alias = null): array; /** * Stores a list of options to be used when instantiating an object * with a matching alias. * - * @param string|null $alias Name of the alias - * @param array|null $options list of options for the alias - * @return array The config data. + * @param array|string $alias Name of the alias or array to completely + * overwrite current config. + * @param array|null $options list of options for the alias + * @return $this + * @throws \RuntimeException When you attempt to configure an existing + * table instance. */ - public function config($alias = null, $options = null); + public function setConfig(array|string $alias, ?array $options = null); /** * Get a table instance from the registry. * * @param string $alias The alias name you want to get. - * @param array $options The options you want to build the table with. + * @param array $options The options you want to build the table with. * @return \Cake\ORM\Table */ - public function get($alias, array $options = []); + public function get(string $alias, array $options = []): Table; /** - * Check to see if an instance exists in the registry. - * - * @param string $alias The alias to check for. - * @return bool - */ - public function exists($alias); - - /** - * Set an instance. + * Set a table instance. * * @param string $alias The alias to set. - * @param \Cake\ORM\Table $object The table to set. + * @param \Cake\ORM\Table $repository The table to set. * @return \Cake\ORM\Table */ - public function set($alias, Table $object); - - /** - * Clears the registry of configuration and instances. - * - * @return void - */ - public function clear(); - - /** - * Removes an instance from the registry. - * - * @param string $alias The alias to remove. - * @return void - */ - public function remove($alias); + public function set(string $alias, RepositoryInterface $repository): Table; } diff --git a/src/ORM/Locator/TableContainer.php b/src/ORM/Locator/TableContainer.php new file mode 100644 index 00000000000..c6a0dfdbeff --- /dev/null +++ b/src/ORM/Locator/TableContainer.php @@ -0,0 +1,48 @@ +fetchTable($id); + } + + /** + * @inheritDoc + */ + public function has(string $id): bool + { + return str_ends_with($id, 'Table') && is_subclass_of($id, Table::class); + } +} diff --git a/src/ORM/Locator/TableLocator.php b/src/ORM/Locator/TableLocator.php index 36ab5cdaaa7..3b11f58ca06 100644 --- a/src/ORM/Locator/TableLocator.php +++ b/src/ORM/Locator/TableLocator.php @@ -1,4 +1,6 @@ */ -class TableLocator implements LocatorInterface +class TableLocator extends AbstractLocator implements LocatorInterface { - /** - * Configuration for aliases. + * Contains a list of locations where table classes should be looked for. * - * @var array + * @var array */ - protected $_config = []; + protected array $locations = []; /** - * Instances that belong to the registry. + * Configuration for aliases. * - * @var \Cake\ORM\Table[] + * @var array */ - protected $_instances = []; + protected array $_config = []; /** * Contains a list of Table objects that were created out of the * built-in Table class. The list is indexed by table alias * - * @var \Cake\ORM\Table[] + * @var array<\Cake\ORM\Table> + */ + protected array $_fallbacked = []; + + /** + * Fallback class to use + * + * @var class-string<\Cake\ORM\Table> */ - protected $_fallbacked = []; + protected string $fallbackClassName = Table::class; /** - * Contains a list of options that were passed to get() method. + * Whether fallback class should be used if a table class could not be found. * - * @var array + * @var bool */ - protected $_options = []; + protected bool $allowFallbackClass = true; + + protected QueryFactory $queryFactory; /** - * Stores a list of options to be used when instantiating an object - * with a matching alias. + * Constructor. * - * @param string|array $alias Name of the alias or array to completely overwrite current config. - * @param array|null $options list of options for the alias + * @param array|null $locations Locations where tables should be looked for. + * If none provided, the default `Model\Table` under your app's namespace is used. + */ + public function __construct(?array $locations = null, ?QueryFactory $queryFactory = null) + { + if ($locations === null) { + $locations = [ + 'Model/Table', + ]; + } + + foreach ($locations as $location) { + $this->addLocation($location); + } + + $this->queryFactory = $queryFactory ?: new QueryFactory(); + } + + /** + * Set if fallback class should be used. + * + * Controls whether a fallback class should be used to create a table + * instance if a concrete class for alias used in `get()` could not be found. + * + * @param bool $allow Flag to enable or disable fallback * @return $this - * @throws \RuntimeException When you attempt to configure an existing table instance. */ - public function setConfig($alias, $options = null) + public function allowFallbackClass(bool $allow) + { + $this->allowFallbackClass = $allow; + + return $this; + } + + /** + * Set fallback class name. + * + * The class that should be used to create a table instance if a concrete + * class for alias used in `get()` could not be found. Defaults to + * `Cake\ORM\Table`. + * + * @param class-string<\Cake\ORM\Table> $className Fallback class name + * @return $this + */ + public function setFallbackClassName(string $className) + { + $this->fallbackClassName = $className; + + return $this; + } + + /** + * @inheritDoc + */ + public function setConfig(array|string $alias, ?array $options = null) { if (!is_string($alias)) { $this->_config = $alias; @@ -72,10 +138,10 @@ public function setConfig($alias, $options = null) return $this; } - if (isset($this->_instances[$alias])) { - throw new RuntimeException(sprintf( - 'You cannot configure "%s", it has already been constructed.', - $alias + if (isset($this->instances[$alias])) { + throw new DatabaseException(sprintf( + 'You cannot configure `%s`, it has already been constructed.', + $alias, )); } @@ -85,48 +151,15 @@ public function setConfig($alias, $options = null) } /** - * Returns configuration for an alias or the full configuration array for all aliases. - * - * @param string|null $alias Alias to get config for, null for complete config. - * @return array The config data. + * @inheritDoc */ - public function getConfig($alias = null) + public function getConfig(?string $alias = null): array { if ($alias === null) { return $this->_config; } - return isset($this->_config[$alias]) ? $this->_config[$alias] : []; - } - - /** - * Stores a list of options to be used when instantiating an object - * with a matching alias. - * - * The options that can be stored are those that are recognized by `get()` - * If second argument is omitted, it will return the current settings - * for $alias. - * - * If no arguments are passed it will return the full configuration array for - * all aliases - * - * @deprecated 3.4.0 Use setConfig()/getConfig() instead. - * @param string|array|null $alias Name of the alias - * @param array|null $options list of options for the alias - * @return array The config data. - * @throws \RuntimeException When you attempt to configure an existing table instance. - */ - public function config($alias = null, $options = null) - { - if ($alias !== null) { - if (is_string($alias) && $options === null) { - return $this->getConfig($alias); - } - - $this->setConfig($alias, $options); - } - - return $this->getConfig($alias); + return $this->_config[$alias] ?? []; } /** @@ -137,7 +170,7 @@ public function config($alias = null, $options = null) * This is important because table associations are resolved at runtime * and cyclic references need to be handled correctly. * - * The options that can be passed are the same as in Cake\ORM\Table::__construct(), but the + * The options that can be passed are the same as in {@link \Cake\ORM\Table::__construct()}, but the * `className` key is also recognized. * * ### Options @@ -147,132 +180,158 @@ public function config($alias = null, $options = null) * `App\Model\Table\UsersTable` being used. If this class does not exist, * then the default `Cake\ORM\Table` class will be used. By setting the `className` * option you can define the specific class to use. The className option supports - * plugin short class references {@link Cake\Core\App::shortName()}. + * plugin short class references {@link \Cake\Core\App::shortName()}. * - `table` Define the table name to use. If undefined, this option will default to the underscored * version of the alias name. * - `connection` Inject the specific connection object to use. If this option and `connectionName` are undefined, * The table class' `defaultConnectionName()` method will be invoked to fetch the connection name. * - `connectionName` Define the connection name to use. The named connection will be fetched from - * Cake\Datasource\ConnectionManager. + * {@link \Cake\Datasource\ConnectionManager}. * * *Note* If your `$alias` uses plugin syntax only the name part will be used as * key in the registry. This means that if two plugins, or a plugin and app provide * the same alias, the registry will only store the first instance. * - * @param string $alias The alias name you want to get. - * @param array $options The options you want to build the table with. + * @param string $alias The alias name you want to get. Should be in CamelCase format. + * @param array $options The options you want to build the table with. * If a table has already been loaded the options will be ignored. * @return \Cake\ORM\Table * @throws \RuntimeException When you try to configure an alias that already exists. */ - public function get($alias, array $options = []) + public function get(string $alias, array $options = []): Table { - if (isset($this->_instances[$alias])) { - if (!empty($options) && $this->_options[$alias] !== $options) { - throw new RuntimeException(sprintf( - 'You cannot configure "%s", it already exists in the registry.', - $alias - )); - } + /** @var \Cake\ORM\Table */ + return parent::get($alias, $options); + } - return $this->_instances[$alias]; + /** + * @inheritDoc + */ + protected function createInstance(string $alias, array $options): Table + { + if (!str_contains($alias, '\\')) { + [, $classAlias] = pluginSplit($alias); + $options = ['alias' => $classAlias] + $options; + } elseif (!isset($options['alias'])) { + $options['className'] = $alias; } - $this->_options[$alias] = $options; - list(, $classAlias) = pluginSplit($alias); - $options = ['alias' => $classAlias] + $options; - if (isset($this->_config[$alias])) { $options += $this->_config[$alias]; } - if (empty($options['className'])) { - $options['className'] = Inflector::camelize($alias); - } - + $allowFallbackClass = $options['allowFallbackClass'] ?? $this->allowFallbackClass; $className = $this->_getClassName($alias, $options); if ($className) { $options['className'] = $className; - } else { - if (!isset($options['table']) && strpos($options['className'], '\\') === false) { - list(, $table) = pluginSplit($options['className']); + } elseif ($allowFallbackClass) { + if (empty($options['className'])) { + $options['className'] = $alias; + } + if (!isset($options['table']) && !str_contains($options['className'], '\\')) { + [, $table] = pluginSplit($options['className']); $options['table'] = Inflector::underscore($table); } - $options['className'] = 'Cake\ORM\Table'; + $options['className'] = $this->fallbackClassName; + } else { + $message = $options['className'] ?? $alias; + $message = '`' . $message . '`'; + if (!str_contains($message, '\\')) { + $message = 'for alias ' . $message; + } + throw new MissingTableClassException([$message]); } if (empty($options['connection'])) { if (!empty($options['connectionName'])) { $connectionName = $options['connectionName']; } else { - /* @var \Cake\ORM\Table $className */ + /** @var class-string<\Cake\ORM\Table> $className */ $className = $options['className']; $connectionName = $className::defaultConnectionName(); } $options['connection'] = ConnectionManager::get($connectionName); } + if (empty($options['associations'])) { + $associations = new AssociationCollection($this); + $options['associations'] = $associations; + } + if (empty($options['queryFactory'])) { + $options['queryFactory'] = $this->queryFactory; + } $options['registryAlias'] = $alias; - $this->_instances[$alias] = $this->_create($options); + $instance = $this->_create($options); - if ($options['className'] === 'Cake\ORM\Table') { - $this->_fallbacked[$alias] = $this->_instances[$alias]; + if ($options['className'] === $this->fallbackClassName) { + $this->_fallbacked[$alias] = $instance; } - return $this->_instances[$alias]; + return $instance; } /** * Gets the table class name. * - * @param string $alias The alias name you want to get. - * @param array $options Table options array. - * @return string + * @param string $alias The alias name you want to get. Should be in CamelCase format. + * @param array $options Table options array. + * @return string|null */ - protected function _getClassName($alias, array $options = []) + protected function _getClassName(string $alias, array $options = []): ?string { if (empty($options['className'])) { - $options['className'] = Inflector::camelize($alias); + $options['className'] = $alias; + } + + if (str_contains($options['className'], '\\') && class_exists($options['className'])) { + return $options['className']; } - return App::className($options['className'], 'Model/Table', 'Table'); + foreach ($this->locations as $location) { + $class = App::className($options['className'], $location, 'Table'); + if ($class !== null) { + return $class; + } + } + + return null; } /** * Wrapper for creating table instances * - * @param array $options The alias to check for. + * @param array $options The alias to check for. * @return \Cake\ORM\Table */ - protected function _create(array $options) + protected function _create(array $options): Table { - return new $options['className']($options); - } + /** @var class-string<\Cake\ORM\Table> $class */ + $class = $options['className']; - /** - * {@inheritDoc} - */ - public function exists($alias) - { - return isset($this->_instances[$alias]); + return new $class($options); } /** - * {@inheritDoc} + * Set a Table instance. + * + * @param string $alias The alias to set. + * @param \Cake\ORM\Table $repository The Table to set. + * @return \Cake\ORM\Table */ - public function set($alias, Table $object) + public function set(string $alias, RepositoryInterface $repository): Table { - return $this->_instances[$alias] = $object; + return $this->instances[$alias] = $repository; } /** - * {@inheritDoc} + * @inheritDoc */ - public function clear() + public function clear(): void { - $this->_instances = []; - $this->_config = []; + parent::clear(); + $this->_fallbacked = []; + $this->_config = []; } /** @@ -281,22 +340,35 @@ public function clear() * debugging common mistakes when setting up associations or created new table * classes. * - * @return \Cake\ORM\Table[] + * @return array<\Cake\ORM\Table> */ - public function genericInstances() + public function genericInstances(): array { return $this->_fallbacked; } /** - * {@inheritDoc} + * @inheritDoc */ - public function remove($alias) + public function remove(string $alias): void { - unset( - $this->_instances[$alias], - $this->_config[$alias], - $this->_fallbacked[$alias] - ); + parent::remove($alias); + + unset($this->_fallbacked[$alias]); + } + + /** + * Adds a location where tables should be looked for. + * + * @param string $location Location to add. + * @return $this + * @since 3.8.0 + */ + public function addLocation(string $location) + { + $location = str_replace('\\', '/', $location); + $this->locations[] = trim($location, '/'); + + return $this; } } diff --git a/src/ORM/Marshaller.php b/src/ORM/Marshaller.php index 4fb7f8327b4..7573fc2cfac 100644 --- a/src/ORM/Marshaller.php +++ b/src/ORM/Marshaller.php @@ -1,4 +1,6 @@ , TEntity> */ - protected $_table; + protected Table $_table; /** * Constructor. * - * @param \Cake\ORM\Table $table The table this marshaller is for. + * @param \Cake\ORM\Table, TEntity> $table The table this marshaller is for. */ public function __construct(Table $table) { @@ -56,63 +60,77 @@ public function __construct(Table $table) } /** - * Build the map of property => marshalling callable. + * Build the map of property => marshaling callable. * - * @param array $data The data being marshalled. - * @param array $options List of options containing the 'associated' key. + * @param array $data The data being marshaled. + * @param array $options List of options containing the 'associated' key. * @throws \InvalidArgumentException When associations do not exist. - * @return array + * @return array Map of property names to marshaling callables. + * Each callable accepts the value and entity, and returns the marshaled result. */ - protected function _buildPropertyMap($data, $options) + protected function _buildPropertyMap(array $data, array $options): array { $map = []; $schema = $this->_table->getSchema(); // Is a concrete column? foreach (array_keys($data) as $prop) { + $prop = (string)$prop; $columnType = $schema->getColumnType($prop); if ($columnType) { - $map[$prop] = function ($value, $entity) use ($columnType) { - return Type::build($columnType)->marshal($value); - }; + $map[$prop] = TypeFactory::build($columnType)->marshal(...); } } // Map associations - if (!isset($options['associated'])) { - $options['associated'] = []; - } + $options['associated'] ??= []; $include = $this->_normalizeAssociations($options['associated']); foreach ($include as $key => $nested) { if (is_int($key) && is_scalar($nested)) { $key = $nested; $nested = []; } - $assoc = $this->_table->association($key); + + $stringifiedKey = (string)$key; // If the key is not a special field like _ids or _joinData // it is a missing association that we should error on. - if (!$assoc) { - if (substr($key, 0, 1) !== '_') { - throw new \InvalidArgumentException(sprintf( - 'Cannot marshal data for "%s" association. It is not associated with "%s".', - $key, - $this->_table->getAlias() + if (!$this->_table->hasAssociation($stringifiedKey)) { + if ( + !str_starts_with($stringifiedKey, '_') + && (!isset($options['junctionProperty']) || $options['junctionProperty'] !== $stringifiedKey) + ) { + throw new InvalidArgumentException(sprintf( + 'Cannot marshal data for `%s` association. It is not associated with `%s`.', + $stringifiedKey, + $this->_table->getAlias(), )); } continue; } + $assoc = $this->_table->getAssociation($stringifiedKey); + if (isset($options['forceNew'])) { $nested['forceNew'] = $options['forceNew']; } if (isset($options['isMerge'])) { - $callback = function ($value, $entity) use ($assoc, $nested) { - /** @var \Cake\Datasource\EntityInterface $entity */ + $callback = function ( + $value, + EntityInterface $entity, + ) use ( + $assoc, + $nested, + ): array|EntityInterface|null { $options = $nested + ['associated' => [], 'association' => $assoc]; - return $this->_mergeAssociation($entity->get($assoc->getProperty()), $assoc, $value, $options); + return $this->_mergeAssociation( + $this->fieldValue($entity, $assoc->getProperty()), + $assoc, + $value, + $options, + ); }; } else { - $callback = function ($value, $entity) use ($assoc, $nested) { + $callback = function ($value) use ($assoc, $nested): array|EntityInterface|null { $options = $nested + ['associated' => []]; return $this->_marshalAssociation($assoc, $value, $options); @@ -139,9 +157,8 @@ protected function _buildPropertyMap($data, $options) * * - validate: Set to false to disable validation. Can also be a string of the validator ruleset to be applied. * Defaults to true/default. - * - associated: Associations listed here will be marshalled as well. Defaults to null. - * - fieldList: (deprecated) Since 3.4.0. Use fields instead. - * - fields: A whitelist of fields to be assigned to the entity. If not present, + * - associated: Associations listed here will be marshaled as well. Defaults to null. + * - fields: An allowed list of fields to be assigned to the entity. If not present, * the accessible fields list in the entity will be used. Defaults to null. * - accessibleFields: A list of fields to allow or deny in entity accessible fields. Defaults to null * - forceNew: When enabled, belongsToMany associations will have 'new' entities created @@ -158,32 +175,56 @@ protected function _buildPropertyMap($data, $options) * ]); * ``` * - * @param array $data The data to hydrate. - * @param array $options List of options - * @return \Cake\Datasource\EntityInterface + * ``` + * $result = $marshaller->one($data, [ + * 'associated' => [ + * 'Tags' => ['accessibleFields' => ['*' => true]] + * ] + * ]); + * ``` + * + * ``` + * $result = $marshaller->one($data, [ + * 'associated' => [ + * 'Tags' => [ + * 'associated' => ['DeeperAssoc1', 'DeeperAssoc2'] + * ] + * ] + * ]); + * ``` + * + * @param array $data The data to hydrate. + * @param array $options List of options + * @return TEntity * @see \Cake\ORM\Table::newEntity() * @see \Cake\ORM\Entity::$_accessible */ - public function one(array $data, array $options = []) + public function one(array $data, array $options = []): EntityInterface { - list($data, $options) = $this->_prepareDataAndOptions($data, $options); + [$data, $options] = $this->_prepareDataAndOptions($data, $options); $primaryKey = (array)$this->_table->getPrimaryKey(); - $entityClass = $this->_table->getEntityClass(); - /** @var \Cake\Datasource\EntityInterface $entity */ - $entity = new $entityClass(); - $entity->setSource($this->_table->getRegistryAlias()); + $entity = $this->_table->newEmptyEntity(); if (isset($options['accessibleFields'])) { foreach ((array)$options['accessibleFields'] as $key => $value) { $entity->setAccess($key, $value); } } - $errors = $this->_validate($data, $options, true); + + $fieldsToValidate = $options['strictFields'] ? (array)$options['fields'] : []; + $context = [ + 'entity' => $entity, + 'fields' => $fieldsToValidate, + ]; + $errors = $this->_validate($data, $options['validate'], true, $context); $options['isMerge'] = false; $propertyMap = $this->_buildPropertyMap($data, $options); $properties = []; + /** + * @var string $key + */ foreach ($data as $key => $value) { if (!empty($errors[$key])) { if ($entity instanceof InvalidPropertyInterface) { @@ -193,7 +234,7 @@ public function one(array $data, array $options = []) } if ($value === '' && in_array($key, $primaryKey, true)) { - // Skip marshalling '' for pk fields. + // Skip marshaling '' for pk fields. continue; } if (isset($propertyMap[$key])) { @@ -206,11 +247,13 @@ public function one(array $data, array $options = []) if (isset($options['fields'])) { foreach ((array)$options['fields'] as $field) { if (array_key_exists($field, $properties)) { - $entity->set($field, $properties[$field]); + $entity->set($field, $properties[$field], ['asOriginal' => true]); } } + } elseif (method_exists($entity, 'patch')) { + $entity->patch($properties, ['asOriginal' => true]); } else { - $entity->set($properties); + $entity->set($properties, ['asOriginal' => true]); } // Don't flag clean association entities as @@ -222,6 +265,7 @@ public function one(array $data, array $options = []) } $entity->setErrors($errors); + $this->dispatchAfterMarshal($entity, $data, $options); return $entity; } @@ -230,53 +274,38 @@ public function one(array $data, array $options = []) * Returns the validation errors for a data set based on the passed options * * @param array $data The data to validate. - * @param array $options The options passed to this marshaller. + * @param string|bool $validator Validator name or `true` for default validator. * @param bool $isNew Whether it is a new entity or one to be updated. + * @param array $context Additional validation context. * @return array The list of validation errors. * @throws \RuntimeException If no validator can be created. */ - protected function _validate($data, $options, $isNew) + protected function _validate(array $data, string|bool $validator, bool $isNew, array $context = []): array { - if (!$options['validate']) { + if (!$validator) { return []; } - $validator = null; - if ($options['validate'] === true) { - $validator = $this->_table->getValidator(); - } elseif (is_string($options['validate'])) { - $validator = $this->_table->getValidator($options['validate']); - } elseif (is_object($options['validate'])) { - $validator = $options['validate']; + if ($validator === true) { + $validator = null; } - if ($validator === null) { - throw new RuntimeException( - sprintf('validate must be a boolean, a string or an object. Got %s.', gettype($options['validate'])) - ); - } - - return $validator->errors($data, $isNew); + return $this->_table->getValidator($validator)->validate($data, $isNew, $context); } /** * Returns data and options prepared to validate and marshall. * - * @param array $data The data to prepare. - * @param array $options The options passed to this marshaller. + * @param array $data The data to prepare. + * @param array $options The options passed to this marshaller. * @return array An array containing prepared data and options. */ - protected function _prepareDataAndOptions($data, $options) + protected function _prepareDataAndOptions(array $data, array $options): array { - $options += ['validate' => true]; - - if (!isset($options['fields']) && isset($options['fieldList'])) { - $options['fields'] = $options['fieldList']; - unset($options['fieldList']); - } + $options += ['validate' => true, 'fields' => null, 'strictFields' => false]; $tableName = $this->_table->getAlias(); - if (isset($data[$tableName])) { + if (isset($data[$tableName]) && is_array($data[$tableName])) { $data += $data[$tableName]; unset($data[$tableName]); } @@ -292,11 +321,11 @@ protected function _prepareDataAndOptions($data, $options) * Create a new sub-marshaller and marshal the associated data. * * @param \Cake\ORM\Association $assoc The association to marshall - * @param array $value The data to hydrate - * @param array $options List of options. - * @return \Cake\Datasource\EntityInterface|\Cake\Datasource\EntityInterface[]|null + * @param mixed $value The data to hydrate. If not an array, this method will return null. + * @param array $options List of options. + * @return \Cake\Datasource\EntityInterface|array<\Cake\Datasource\EntityInterface>|null */ - protected function _marshalAssociation($assoc, $value, $options) + protected function _marshalAssociation(Association $assoc, mixed $value, array $options): EntityInterface|array|null { if (!is_array($value)) { return null; @@ -304,10 +333,11 @@ protected function _marshalAssociation($assoc, $value, $options) $targetTable = $assoc->getTarget(); $marshaller = $targetTable->marshaller(); $types = [Association::ONE_TO_ONE, Association::MANY_TO_ONE]; - if (in_array($assoc->type(), $types)) { - return $marshaller->one($value, (array)$options); + $type = $assoc->type(); + if (in_array($type, $types, true)) { + return $marshaller->one($value, $options); } - if ($assoc->type() === Association::ONE_TO_MANY || $assoc->type() === Association::MANY_TO_MANY) { + if ($type === Association::ONE_TO_MANY || $type === Association::MANY_TO_MANY) { $hasIds = array_key_exists('_ids', $value); $onlyIds = array_key_exists('onlyIds', $options) && $options['onlyIds']; @@ -318,11 +348,13 @@ protected function _marshalAssociation($assoc, $value, $options) return []; } } - if ($assoc->type() === Association::MANY_TO_MANY) { - return $marshaller->_belongsToMany($assoc, $value, (array)$options); + if ($type === Association::MANY_TO_MANY) { + assert($assoc instanceof BelongsToMany); + + return $marshaller->_belongsToMany($assoc, $value, $options); } - return $marshaller->many($value, (array)$options); + return $marshaller->many($value, $options); } /** @@ -332,9 +364,8 @@ protected function _marshalAssociation($assoc, $value, $options) * * - validate: Set to false to disable validation. Can also be a string of the validator ruleset to be applied. * Defaults to true/default. - * - associated: Associations listed here will be marshalled as well. Defaults to null. - * - fieldList: (deprecated) Since 3.4.0. Use fields instead - * - fields: A whitelist of fields to be assigned to the entity. If not present, + * - associated: Associations listed here will be marshaled as well. Defaults to null. + * - fields: An allowed list of fields to be assigned to the entity. If not present, * the accessible fields list in the entity will be used. Defaults to null. * - accessibleFields: A list of fields to allow or deny in entity accessible fields. Defaults to null * - forceNew: When enabled, belongsToMany associations will have 'new' entities created @@ -342,12 +373,12 @@ protected function _marshalAssociation($assoc, $value, $options) * on missing entities would be ignored. Defaults to false. * * @param array $data The data to hydrate. - * @param array $options List of options - * @return \Cake\Datasource\EntityInterface[] An array of hydrated records. + * @param array $options List of options + * @return array An array of hydrated records. * @see \Cake\ORM\Table::newEntities() * @see \Cake\ORM\Entity::$_accessible */ - public function many(array $data, array $options = []) + public function many(array $data, array $options = []): array { $output = []; foreach ($data as $record) { @@ -366,26 +397,28 @@ public function many(array $data, array $options = []) * Builds the related entities and handles the special casing * for junction table entities. * - * @param \Cake\ORM\Association\BelongsToMany $assoc The association to marshal. + * @param \Cake\ORM\Association\BelongsToMany<\Cake\ORM\Table> $assoc The association to marshal. * @param array $data The data to convert into entities. - * @param array $options List of options. - * @return \Cake\Datasource\EntityInterface[] An array of built entities. + * @param array $options List of options. + * @return array<\Cake\Datasource\EntityInterface> An array of built entities. * @throws \BadMethodCallException * @throws \InvalidArgumentException * @throws \RuntimeException */ - protected function _belongsToMany(BelongsToMany $assoc, array $data, $options = []) + protected function _belongsToMany(BelongsToMany $assoc, array $data, array $options = []): array { - $associated = isset($options['associated']) ? $options['associated'] : []; - $forceNew = isset($options['forceNew']) ? $options['forceNew'] : false; + $associated = $options['associated'] ?? []; + $forceNew = $options['forceNew'] ?? false; $data = array_values($data); $target = $assoc->getTarget(); $primaryKey = array_flip((array)$target->getPrimaryKey()); - $records = $conditions = []; - $primaryCount = count($primaryKey); + $records = []; $conditions = []; + $primaryCount = count($primaryKey); + $junctionProperty = $assoc->getJunctionProperty(); + $options += ['junctionProperty' => $junctionProperty]; foreach ($data as $i => $row) { if (!is_array($row)) { @@ -410,17 +443,16 @@ protected function _belongsToMany(BelongsToMany $assoc, array $data, $options = } } - if (!empty($conditions)) { - $query = $target->find(); - $query->andWhere(function ($exp) use ($conditions) { - /** @var \Cake\Database\Expression\QueryExpression $exp */ - return $exp->or_($conditions); - }); + if ($conditions !== []) { + /** @var \Traversable<\Cake\Datasource\EntityInterface> $results */ + $results = $target->find() + ->andWhere(fn(QueryExpression $exp) => $exp->or($conditions)) + ->all(); $keyFields = array_keys($primaryKey); $existing = []; - foreach ($query as $row) { + foreach ($results as $row) { $k = implode(';', $row->extract($keyFields)); $existing[$k] = $row; } @@ -436,7 +468,7 @@ protected function _belongsToMany(BelongsToMany $assoc, array $data, $options = // Update existing record and child associations if (isset($existing[$key])) { - $records[$i] = $this->merge($existing[$key], $data[$i], $options); + $records[$i] = $this->merge($existing[$key], $row, $options); } } } @@ -444,15 +476,15 @@ protected function _belongsToMany(BelongsToMany $assoc, array $data, $options = $jointMarshaller = $assoc->junction()->marshaller(); $nested = []; - if (isset($associated['_joinData'])) { - $nested = (array)$associated['_joinData']; + if (isset($associated[$junctionProperty])) { + $nested = (array)$associated[$junctionProperty]; } foreach ($records as $i => $record) { - // Update junction table data in _joinData. - if (isset($data[$i]['_joinData'])) { - $joinData = $jointMarshaller->one($data[$i]['_joinData'], $nested); - $record->set('_joinData', $joinData); + // Update junction table data in the junction property (_joinData). + if (isset($data[$i][$junctionProperty])) { + $joinData = $jointMarshaller->one($data[$i][$junctionProperty], $nested); + $record->set($junctionProperty, $joinData); } } @@ -464,43 +496,38 @@ protected function _belongsToMany(BelongsToMany $assoc, array $data, $options = * * @param \Cake\ORM\Association $assoc The association class for the belongsToMany association. * @param array $ids The list of ids to load. - * @return \Cake\Datasource\EntityInterface[] An array of entities. + * @return array<\Cake\Datasource\EntityInterface> An array of entities. */ - protected function _loadAssociatedByIds($assoc, $ids) + protected function _loadAssociatedByIds(Association $assoc, array $ids): array { - if (empty($ids)) { + if (!$ids) { return []; } $target = $assoc->getTarget(); $primaryKey = (array)$target->getPrimaryKey(); $multi = count($primaryKey) > 1; - $primaryKey = array_map([$target, 'aliasField'], $primaryKey); + $primaryKey = array_map($target->aliasField(...), $primaryKey); if ($multi) { $first = current($ids); if (!is_array($first) || count($first) !== count($primaryKey)) { return []; } - $filter = new TupleComparison($primaryKey, $ids, [], 'IN'); + $type = []; + $schema = $target->getSchema(); + foreach ((array)$target->getPrimaryKey() as $column) { + $type[] = $schema->getColumnType($column); + } + $filter = new TupleComparison($primaryKey, $ids, $type, 'IN'); } else { $filter = [$primaryKey[0] . ' IN' => $ids]; } - return $target->find()->where($filter)->toArray(); - } + /** @var \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface> $query */ + $query = $target->find()->where($filter); - /** - * Loads a list of belongs to many from ids. - * - * @param \Cake\ORM\Association $assoc The association class for the belongsToMany association. - * @param array $ids The list of ids to load. - * @return \Cake\Datasource\EntityInterface[] An array of entities. - * @deprecated Use _loadAssociatedByIds() - */ - protected function _loadBelongsToMany($assoc, $ids) - { - return $this->_loadAssociatedByIds($assoc, $ids); + return $query->toArray(); } /** @@ -516,11 +543,10 @@ protected function _loadBelongsToMany($assoc, $ids) * * ### Options: * - * - associated: Associations listed here will be marshalled as well. - * - validate: Whether or not to validate data before hydrating the entities. Can + * - associated: Associations listed here will be marshaled as well. + * - validate: Whether to validate data before hydrating the entities. Can * also be set to a string to use a specific validator. Defaults to true/default. - * - fieldList: (deprecated) Since 3.4.0. Use fields instead - * - fields: A whitelist of fields to be assigned to the entity. If not present + * - fields: An allowed list of fields to be assigned to the entity. If not present * the accessible fields list in the entity will be used. * - accessibleFields: A list of fields to allow or deny in entity accessible fields. * @@ -534,16 +560,27 @@ protected function _loadBelongsToMany($assoc, $ids) * ]); * ``` * - * @param \Cake\Datasource\EntityInterface $entity the entity that will get the + * ``` + * $result = $marshaller->merge($entity, $data, [ + * 'associated' => [ + * 'Tags' => [ + * 'associated' => ['DeeperAssoc1', 'DeeperAssoc2'] + * ] + * ] + * ]); + * ``` + * + * @template TMergedEntity of \Cake\Datasource\EntityInterface + * @param TMergedEntity $entity the entity that will get the * data merged in * @param array $data key value list of fields to be merged into the entity - * @param array $options List of options. - * @return \Cake\Datasource\EntityInterface + * @param array $options List of options. + * @return TMergedEntity * @see \Cake\ORM\Entity::$_accessible */ - public function merge(EntityInterface $entity, array $data, array $options = []) + public function merge(EntityInterface $entity, array $data, array $options = []): EntityInterface { - list($data, $options) = $this->_prepareDataAndOptions($data, $options); + [$data, $options] = $this->_prepareDataAndOptions($data, $options); $isNew = $entity->isNew(); $keys = []; @@ -558,10 +595,18 @@ public function merge(EntityInterface $entity, array $data, array $options = []) } } - $errors = $this->_validate($data + $keys, $options, $isNew); + $fieldsToValidate = $options['strictFields'] ? (array)$options['fields'] : []; + $context = [ + 'entity' => $entity, + 'fields' => $fieldsToValidate, + ]; + $errors = $this->_validate($data + $keys, $options['validate'], $isNew, $context); $options['isMerge'] = true; $propertyMap = $this->_buildPropertyMap($data, $options); - $properties = $marshalledAssocs = []; + $properties = []; + /** + * @var string $key + */ foreach ($data as $key => $value) { if (!empty($errors[$key])) { if ($entity instanceof InvalidPropertyInterface) { @@ -569,39 +614,33 @@ public function merge(EntityInterface $entity, array $data, array $options = []) } continue; } - $original = $entity->get($key); if (isset($propertyMap[$key])) { $value = $propertyMap[$key]($value, $entity); - - // Don't dirty scalar values and objects that didn't - // change. Arrays will always be marked as dirty because - // the original/updated list could contain references to the - // same objects, even though those objects may have changed internally. - if ((is_scalar($value) && $original === $value) || - ($value === null && $original === $value) || - (is_object($value) && !($value instanceof EntityInterface) && $original == $value) - ) { - continue; - } } $properties[$key] = $value; } $entity->setErrors($errors); if (!isset($options['fields'])) { - $entity->set($properties); + if (method_exists($entity, 'patch')) { + $entity->patch($properties); + } else { + $entity->set($properties); + } foreach ($properties as $field => $value) { if ($value instanceof EntityInterface) { $entity->setDirty($field, $value->isDirty()); } } + $this->dispatchAfterMarshal($entity, $data, $options); return $entity; } foreach ((array)$options['fields'] as $field) { + assert(is_string($field)); if (!array_key_exists($field, $properties)) { continue; } @@ -610,6 +649,7 @@ public function merge(EntityInterface $entity, array $data, array $options = []) $entity->setDirty($field, $properties[$field]->isDirty()); } } + $this->dispatchAfterMarshal($entity, $data, $options); return $entity; } @@ -623,7 +663,7 @@ public function merge(EntityInterface $entity, array $data, array $options = []) * Records in `$data` are matched against the entities using the primary key * column. Entries in `$entities` that cannot be matched to any record in * `$data` will be discarded. Records in `$data` that could not be matched will - * be marshalled as a new entity. + * be marshaled as a new entity. * * When merging HasMany or BelongsToMany associations, all the entities in the * `$data` array will appear, those that can be matched by primary key will get @@ -631,22 +671,22 @@ public function merge(EntityInterface $entity, array $data, array $options = []) * * ### Options: * - * - validate: Whether or not to validate data before hydrating the entities. Can + * - validate: Whether to validate data before hydrating the entities. Can * also be set to a string to use a specific validator. Defaults to true/default. - * - associated: Associations listed here will be marshalled as well. - * - fieldList: (deprecated) Since 3.4.0. Use fields instead - * - fields: A whitelist of fields to be assigned to the entity. If not present, + * - associated: Associations listed here will be marshaled as well. + * - fields: An allowed list of fields to be assigned to the entity. If not present, * the accessible fields list in the entity will be used. * - accessibleFields: A list of fields to allow or deny in entity accessible fields. * - * @param \Cake\Datasource\EntityInterface[]|\Traversable $entities the entities that will get the + * @template TMergedEntity of \Cake\Datasource\EntityInterface + * @param iterable $entities the entities that will get the * data merged in * @param array $data list of arrays to be merged into the entities - * @param array $options List of options. - * @return \Cake\Datasource\EntityInterface[] + * @param array $options List of options. + * @return array * @see \Cake\ORM\Entity::$_accessible */ - public function mergeMany($entities, array $data, array $options = []) + public function mergeMany(iterable $entities, array $data, array $options = []): array { $primary = (array)$this->_table->getPrimaryKey(); @@ -654,7 +694,7 @@ public function mergeMany($entities, array $data, array $options = []) ->groupBy(function ($el) use ($primary) { $keys = []; foreach ($primary as $key) { - $keys[] = isset($el[$key]) ? $el[$key] : ''; + $keys[] = $el[$key] ?? ''; } return implode(';', $keys); @@ -664,8 +704,8 @@ public function mergeMany($entities, array $data, array $options = []) }) ->toArray(); - $new = isset($indexed[null]) ? $indexed[null] : []; - unset($indexed[null]); + $new = $indexed[''] ?? []; + unset($indexed['']); $output = []; foreach ($entities as $entity) { @@ -674,7 +714,7 @@ public function mergeMany($entities, array $data, array $options = []) } $key = implode(';', $entity->extract($primary)); - if ($key === null || !isset($indexed[$key])) { + if (!isset($indexed[$key])) { continue; } @@ -682,22 +722,26 @@ public function mergeMany($entities, array $data, array $options = []) unset($indexed[$key]); } - $maybeExistentQuery = (new Collection($indexed)) + $conditions = (new Collection($indexed)) ->map(function ($data, $key) { - return explode(';', $key); - }) - ->filter(function ($keys) use ($primary) { - return count(array_filter($keys, 'strlen')) === count($primary); + return explode(';', (string)$key); }) - ->reduce(function ($query, $keys) use ($primary) { - /** @var \Cake\ORM\Query $query */ - $fields = array_map([$this->_table, 'aliasField'], $primary); - - return $query->orWhere($query->newExpr()->and_(array_combine($fields, $keys))); - }, $this->_table->find()); - - if (!empty($indexed) && count($maybeExistentQuery->clause('where'))) { - foreach ($maybeExistentQuery as $entity) { + ->filter(fn($keys) => count(Hash::filter($keys)) === count($primary)) + ->reduce(function ($conditions, $keys) use ($primary) { + $fields = array_map($this->_table->aliasField(...), $primary); + $conditions['OR'][] = array_combine($fields, $keys); + + return $conditions; + }, ['OR' => []]); + $maybeExistentQuery = $this->_table->find()->where($conditions); + + if ($indexed && count($maybeExistentQuery->clause('where'))) { + /** + * phpcs:ignore SlevomatCodingStandard.Namespaces.FullyQualifiedClassNameInAnnotation.NonFullyQualifiedClassName + * @var \Traversable $existent + */ + $existent = $maybeExistentQuery->all(); + foreach ($existent as $entity) { $key = implode(';', $entity->extract($primary)); if (isset($indexed[$key])) { $output[] = $this->merge($entity, $indexed[$key], $options); @@ -710,7 +754,12 @@ public function mergeMany($entities, array $data, array $options = []) if (!is_array($value)) { continue; } - $output[] = $this->one($value, $options); + /** + * phpcs:ignore SlevomatCodingStandard.Namespaces.FullyQualifiedClassNameInAnnotation.NonFullyQualifiedClassName + * @var TEntity $entity + */ + $entity = $this->one($value, $options); + $output[] = $entity; } return $output; @@ -719,14 +768,18 @@ public function mergeMany($entities, array $data, array $options = []) /** * Creates a new sub-marshaller and merges the associated data. * - * @param \Cake\Datasource\EntityInterface|\Cake\Datasource\EntityInterface[] $original The original entity + * @param \Cake\Datasource\EntityInterface|non-empty-array<\Cake\Datasource\EntityInterface>|null $original The original entity * @param \Cake\ORM\Association $assoc The association to merge - * @param array $value The data to hydrate - * @param array $options List of options. - * @return \Cake\Datasource\EntityInterface|\Cake\Datasource\EntityInterface[]|null + * @param mixed $value The array of data to hydrate. If not an array, this method will return null. + * @param array $options List of options. + * @return \Cake\Datasource\EntityInterface|array<\Cake\Datasource\EntityInterface>|null */ - protected function _mergeAssociation($original, $assoc, $value, $options) - { + protected function _mergeAssociation( + EntityInterface|array|null $original, + Association $assoc, + mixed $value, + array $options, + ): EntityInterface|array|null { if (!$original) { return $this->_marshalAssociation($assoc, $value, $options); } @@ -737,29 +790,47 @@ protected function _mergeAssociation($original, $assoc, $value, $options) $targetTable = $assoc->getTarget(); $marshaller = $targetTable->marshaller(); $types = [Association::ONE_TO_ONE, Association::MANY_TO_ONE]; - if (in_array($assoc->type(), $types)) { - return $marshaller->merge($original, $value, (array)$options); + $type = $assoc->type(); + if (in_array($type, $types, true)) { + /** @var \Cake\Datasource\EntityInterface $original */ + return $marshaller->merge($original, $value, $options); } - if ($assoc->type() === Association::MANY_TO_MANY) { - return $marshaller->_mergeBelongsToMany($original, $assoc, $value, (array)$options); + if ($type === Association::MANY_TO_MANY && is_array($original)) { + assert($assoc instanceof BelongsToMany); + + return $marshaller->_mergeBelongsToMany($original, $assoc, $value, $options); } - return $marshaller->mergeMany($original, $value, (array)$options); + if ($type === Association::ONE_TO_MANY) { + $hasIds = array_key_exists('_ids', $value); + $onlyIds = array_key_exists('onlyIds', $options) && $options['onlyIds']; + if ($hasIds && is_array($value['_ids'])) { + return $this->_loadAssociatedByIds($assoc, $value['_ids']); + } + if ($hasIds || $onlyIds) { + return []; + } + } + + /** + * @var non-empty-array<\Cake\Datasource\EntityInterface> $original + */ + return $marshaller->mergeMany($original, $value, $options); } /** * Creates a new sub-marshaller and merges the associated data for a BelongstoMany * association. * - * @param \Cake\Datasource\EntityInterface $original The original entity - * @param \Cake\ORM\Association $assoc The association to marshall + * @param array<\Cake\Datasource\EntityInterface> $original The original entities list. + * @param \Cake\ORM\Association\BelongsToMany<\Cake\ORM\Table> $assoc The association to marshall * @param array $value The data to hydrate - * @param array $options List of options. - * @return \Cake\Datasource\EntityInterface[] + * @param array $options List of options. + * @return array<\Cake\Datasource\EntityInterface> */ - protected function _mergeBelongsToMany($original, $assoc, $value, $options) + protected function _mergeBelongsToMany(array $original, BelongsToMany $assoc, array $value, array $options): array { - $associated = isset($options['associated']) ? $options['associated'] : []; + $associated = $options['associated'] ?? []; $hasIds = array_key_exists('_ids', $value); $onlyIds = array_key_exists('onlyIds', $options) && $options['onlyIds']; @@ -771,7 +842,8 @@ protected function _mergeBelongsToMany($original, $assoc, $value, $options) return []; } - if (!empty($associated) && !in_array('_joinData', $associated) && !isset($associated['_joinData'])) { + $junctionProperty = $assoc->getJunctionProperty(); + if ($associated && !in_array($junctionProperty, $associated, true) && !isset($associated[$junctionProperty])) { return $this->mergeMany($original, $value, $options); } @@ -779,24 +851,25 @@ protected function _mergeBelongsToMany($original, $assoc, $value, $options) } /** - * Merge the special _joinData property into the entity set. + * Merge the special junction property (_joinData) into the entity set. * - * @param \Cake\Datasource\EntityInterface $original The original entity - * @param \Cake\ORM\Association\BelongsToMany $assoc The association to marshall + * @param array<\Cake\Datasource\EntityInterface> $original The original entities list. + * @param \Cake\ORM\Association\BelongsToMany<\Cake\ORM\Table> $assoc The association to marshall * @param array $value The data to hydrate - * @param array $options List of options. - * @return \Cake\Datasource\EntityInterface[] An array of entities + * @param array $options List of options. + * @return array<\Cake\Datasource\EntityInterface> An array of entities */ - protected function _mergeJoinData($original, $assoc, $value, $options) + protected function _mergeJoinData(array $original, BelongsToMany $assoc, array $value, array $options): array { - $associated = isset($options['associated']) ? $options['associated'] : []; + $associated = $options['associated'] ?? []; $extra = []; + $junctionProperty = $assoc->getJunctionProperty(); foreach ($original as $entity) { // Mark joinData as accessible so we can marshal it properly. - $entity->setAccess('_joinData', true); + $entity->setAccess($junctionProperty, true); - $joinData = $entity->get('_joinData'); - if ($joinData && $joinData instanceof EntityInterface) { + $joinData = $this->fieldValue($entity, $junctionProperty); + if ($joinData instanceof EntityInterface) { $extra[spl_object_hash($entity)] = $joinData; } } @@ -805,37 +878,67 @@ protected function _mergeJoinData($original, $assoc, $value, $options) $marshaller = $joint->marshaller(); $nested = []; - if (isset($associated['_joinData'])) { - $nested = (array)$associated['_joinData']; + if (isset($associated[$junctionProperty])) { + $nested = (array)$associated[$junctionProperty]; } - $options['accessibleFields'] = ['_joinData' => true]; + $options['accessibleFields'] = [$junctionProperty => true]; $records = $this->mergeMany($original, $value, $options); foreach ($records as $record) { $hash = spl_object_hash($record); - $value = $record->get('_joinData'); + $value = $this->fieldValue($record, $junctionProperty); - // Already an entity, no further marshalling required. + // Already an entity, no further marshaling required. if ($value instanceof EntityInterface) { continue; } // Scalar data can't be handled if (!is_array($value)) { - $record->unsetProperty('_joinData'); + $record->unset($junctionProperty); continue; } // Marshal data into the old object, or make a new joinData object. if (isset($extra[$hash])) { - $record->set('_joinData', $marshaller->merge($extra[$hash], $value, $nested)); - } elseif (is_array($value)) { + $record->set($junctionProperty, $marshaller->merge($extra[$hash], $value, $nested)); + } else { $joinData = $marshaller->one($value, $nested); - $record->set('_joinData', $joinData); + $record->set($junctionProperty, $joinData); } } return $records; } + + /** + * dispatch Model.afterMarshal event. + * + * @param \Cake\Datasource\EntityInterface $entity The entity that was marshaled. + * @param array $data readOnly $data to use. + * @param array $options List of options that are readOnly. + * @return void + */ + protected function dispatchAfterMarshal(EntityInterface $entity, array $data, array $options = []): void + { + $data = new ArrayObject($data); + $options = new ArrayObject($options); + $this->_table->dispatchEvent('Model.afterMarshal', compact('entity', 'data', 'options')); + } + + /** + * Get the value of a field from an entity. + * + * It checks whether the field exists in the entity before getting the value + * to avoid MissingPropertyException if `requireFieldPresence` is enabled. + * + * @param \Cake\Datasource\EntityInterface $entity The entity to extract the field from. + * @param string $field The field to extract. + * @return mixed + */ + protected function fieldValue(EntityInterface $entity, string $field): mixed + { + return $entity->has($field) ? $entity->get($field) : null; + } } diff --git a/src/ORM/PropertyMarshalInterface.php b/src/ORM/PropertyMarshalInterface.php index bf2c6d56534..93e4013f56a 100644 --- a/src/ORM/PropertyMarshalInterface.php +++ b/src/ORM/PropertyMarshalInterface.php @@ -1,4 +1,6 @@ callable]` of additional properties to marshal. + * @param \Cake\ORM\Marshaller<\Cake\Datasource\EntityInterface> $marshaller The marshaler of the table the behavior is attached to. + * @param array $map The property map being built. + * @param array $options The options array used in the marshaling call. + * @return array A map of `[property => callable]` of additional properties to marshal. */ - public function buildMarshalMap($marshaller, $map, $options); + public function buildMarshalMap(Marshaller $marshaller, array $map, array $options): array; } diff --git a/src/ORM/Query.php b/src/ORM/Query.php index 47cb2c788de..0aa206233ae 100644 --- a/src/ORM/Query.php +++ b/src/ORM/Query.php @@ -1,1306 +1,16 @@ repository($table); - - if ($this->_repository) { - $this->addDefaultTypes($this->_repository); - } - } - - /** - * {@inheritDoc} - * - * If you pass an instance of a `Cake\ORM\Table` or `Cake\ORM\Association` class, - * all the fields in the schema of the table or the association will be added to - * the select clause. - * - * @param array|\Cake\Database\ExpressionInterface|string|\Cake\ORM\Table|\Cake\ORM\Association $fields fields - * to be added to the list. - * @param bool $overwrite whether to reset fields with passed list or not - * @return $this - */ - public function select($fields = [], $overwrite = false) - { - if ($fields instanceof Association) { - $fields = $fields->getTarget(); - } - - if ($fields instanceof Table) { - $fields = $this->aliasFields($fields->getSchema()->columns(), $fields->getAlias()); - } - - return parent::select($fields, $overwrite); - } - - /** - * Hints this object to associate the correct types when casting conditions - * for the database. This is done by extracting the field types from the schema - * associated to the passed table object. This prevents the user from repeating - * themselves when specifying conditions. - * - * This method returns the same query object for chaining. - * - * @param \Cake\ORM\Table $table The table to pull types from - * @return $this - */ - public function addDefaultTypes(Table $table) - { - $alias = $table->getAlias(); - $map = $table->getSchema()->typeMap(); - $fields = []; - foreach ($map as $f => $type) { - $fields[$f] = $fields[$alias . '.' . $f] = $fields[$alias . '__' . $f] = $type; - } - $this->getTypeMap()->addDefaults($fields); - - return $this; - } - - /** - * Sets the instance of the eager loader class to use for loading associations - * and storing containments. - * - * @param \Cake\ORM\EagerLoader $instance The eager loader to use. - * @return $this - */ - public function setEagerLoader(EagerLoader $instance) - { - $this->_eagerLoader = $instance; - - return $this; - } - - /** - * Returns the currently configured instance. - * - * @return \Cake\ORM\EagerLoader - */ - public function getEagerLoader() - { - if ($this->_eagerLoader === null) { - $this->_eagerLoader = new EagerLoader(); - } - - return $this->_eagerLoader; - } - - /** - * Sets the instance of the eager loader class to use for loading associations - * and storing containments. If called with no arguments, it will return the - * currently configured instance. - * - * @deprecated 3.4.0 Use setEagerLoader()/getEagerLoader() instead. - * @param \Cake\ORM\EagerLoader|null $instance The eager loader to use. Pass null - * to get the current eagerloader. - * @return \Cake\ORM\EagerLoader|$this - */ - public function eagerLoader(EagerLoader $instance = null) - { - if ($instance !== null) { - return $this->setEagerLoader($instance); - } - - return $this->getEagerLoader(); - } - - /** - * Sets the list of associations that should be eagerly loaded along with this - * query. The list of associated tables passed must have been previously set as - * associations using the Table API. - * - * ### Example: - * - * ``` - * // Bring articles' author information - * $query->contain('Author'); - * - * // Also bring the category and tags associated to each article - * $query->contain(['Category', 'Tag']); - * ``` - * - * Associations can be arbitrarily nested using dot notation or nested arrays, - * this allows this object to calculate joins or any additional queries that - * must be executed to bring the required associated data. - * - * ### Example: - * - * ``` - * // Eager load the product info, and for each product load other 2 associations - * $query->contain(['Product' => ['Manufacturer', 'Distributor']); - * - * // Which is equivalent to calling - * $query->contain(['Products.Manufactures', 'Products.Distributors']); - * - * // For an author query, load his region, state and country - * $query->contain('Regions.States.Countries'); - * ``` - * - * It is possible to control the conditions and fields selected for each of the - * contained associations: - * - * ### Example: - * - * ``` - * $query->contain(['Tags' => function ($q) { - * return $q->where(['Tags.is_popular' => true]); - * }]); - * - * $query->contain(['Products.Manufactures' => function ($q) { - * return $q->select(['name'])->where(['Manufactures.active' => true]); - * }]); - * ``` - * - * Each association might define special options when eager loaded, the allowed - * options that can be set per association are: - * - * - `foreignKey`: Used to set a different field to match both tables, if set to false - * no join conditions will be generated automatically. `false` can only be used on - * joinable associations and cannot be used with hasMany or belongsToMany associations. - * - `fields`: An array with the fields that should be fetched from the association. - * - `finder`: The finder to use when loading associated records. Either the name of the - * finder as a string, or an array to define options to pass to the finder. - * - `queryBuilder`: Equivalent to passing a callable instead of an options array. - * - * ### Example: - * - * ``` - * // Set options for the hasMany articles that will be eagerly loaded for an author - * $query->contain([ - * 'Articles' => [ - * 'fields' => ['title', 'author_id'] - * ] - * ]); - * ``` - * - * Finders can be configured to use options. - * - * ``` - * // Retrieve translations for the articles, but only those for the `en` and `es` locales - * $query->contain([ - * 'Articles' => [ - * 'finder' => [ - * 'translations' => [ - * 'locales' => ['en', 'es'] - * ] - * ] - * ] - * ]); - * ``` - * - * When containing associations, it is important to include foreign key columns. - * Failing to do so will trigger exceptions. - * - * ``` - * // Use a query builder to add conditions to the containment - * $query->contain('Authors', function ($q) { - * return $q->where(...); // add conditions - * }); - * // Use special join conditions for multiple containments in the same method call - * $query->contain([ - * 'Authors' => [ - * 'foreignKey' => false, - * 'queryBuilder' => function ($q) { - * return $q->where(...); // Add full filtering conditions - * } - * ], - * 'Tags' => function ($q) { - * return $q->where(...); // add conditions - * } - * ]); - * ``` - * - * If called with no arguments, this function will return an array with - * with the list of previously configured associations to be contained in the - * result. - * - * If called with an empty first argument and `$override` is set to true, the - * previous list will be emptied. - * - * @param array|string|null $associations List of table aliases to be queried. - * @param callable|bool $override The query builder for the association, or - * if associations is an array, a bool on whether to override previous list - * with the one passed - * defaults to merging previous list with the new one. - * @return array|$this - */ - public function contain($associations = null, $override = false) - { - $loader = $this->getEagerLoader(); - if ($override === true) { - $this->clearContain(); - } - - if ($associations === null) { - return $loader->contain(); - } - - $queryBuilder = null; - if (is_callable($override)) { - $queryBuilder = $override; - } - - $result = $loader->contain($associations, $queryBuilder); - $this->_addAssociationsToTypeMap($this->repository(), $this->getTypeMap(), $result); - - return $this; - } - - /** - * Clears the contained associations from the current query. - * - * @return $this - */ - public function clearContain() - { - $this->getEagerLoader()->clearContain(); - $this->_dirty(); - - return $this; - } - - /** - * Used to recursively add contained association column types to - * the query. - * - * @param \Cake\ORM\Table $table The table instance to pluck associations from. - * @param \Cake\Database\TypeMap $typeMap The typemap to check for columns in. - * This typemap is indirectly mutated via Cake\ORM\Query::addDefaultTypes() - * @param array $associations The nested tree of associations to walk. - * @return void - */ - protected function _addAssociationsToTypeMap($table, $typeMap, $associations) - { - foreach ($associations as $name => $nested) { - $association = $table->association($name); - if (!$association) { - continue; - } - $target = $association->getTarget(); - $primary = (array)$target->getPrimaryKey(); - if (empty($primary) || $typeMap->type($target->aliasField($primary[0])) === null) { - $this->addDefaultTypes($target); - } - if (!empty($nested)) { - $this->_addAssociationsToTypeMap($target, $typeMap, $nested); - } - } - } - - /** - * Adds filtering conditions to this query to only bring rows that have a relation - * to another from an associated table, based on conditions in the associated table. - * - * This function will add entries in the `contain` graph. - * - * ### Example: - * - * ``` - * // Bring only articles that were tagged with 'cake' - * $query->matching('Tags', function ($q) { - * return $q->where(['name' => 'cake']); - * ); - * ``` - * - * It is possible to filter by deep associations by using dot notation: - * - * ### Example: - * - * ``` - * // Bring only articles that were commented by 'markstory' - * $query->matching('Comments.Users', function ($q) { - * return $q->where(['username' => 'markstory']); - * ); - * ``` - * - * As this function will create `INNER JOIN`, you might want to consider - * calling `distinct` on this query as you might get duplicate rows if - * your conditions don't filter them already. This might be the case, for example, - * of the same user commenting more than once in the same article. - * - * ### Example: - * - * ``` - * // Bring unique articles that were commented by 'markstory' - * $query->distinct(['Articles.id']) - * ->matching('Comments.Users', function ($q) { - * return $q->where(['username' => 'markstory']); - * ); - * ``` - * - * Please note that the query passed to the closure will only accept calling - * `select`, `where`, `andWhere` and `orWhere` on it. If you wish to - * add more complex clauses you can do it directly in the main query. - * - * @param string $assoc The association to filter by - * @param callable|null $builder a function that will receive a pre-made query object - * that can be used to add custom conditions or selecting some fields - * @return $this - */ - public function matching($assoc, callable $builder = null) - { - $result = $this->getEagerLoader()->setMatching($assoc, $builder)->getMatching(); - $this->_addAssociationsToTypeMap($this->repository(), $this->getTypeMap(), $result); - $this->_dirty(); - - return $this; - } - - /** - * Creates a LEFT JOIN with the passed association table while preserving - * the foreign key matching and the custom conditions that were originally set - * for it. - * - * This function will add entries in the `contain` graph. - * - * ### Example: - * - * ``` - * // Get the count of articles per user - * $usersQuery - * ->select(['total_articles' => $query->func()->count('Articles.id')]) - * ->leftJoinWith('Articles') - * ->group(['Users.id']) - * ->enableAutoFields(true); - * ``` - * - * You can also customize the conditions passed to the LEFT JOIN: - * - * ``` - * // Get the count of articles per user with at least 5 votes - * $usersQuery - * ->select(['total_articles' => $query->func()->count('Articles.id')]) - * ->leftJoinWith('Articles', function ($q) { - * return $q->where(['Articles.votes >=' => 5]); - * }) - * ->group(['Users.id']) - * ->enableAutoFields(true); - * ``` - * - * This will create the following SQL: - * - * ``` - * SELECT COUNT(Articles.id) AS total_articles, Users.* - * FROM users Users - * LEFT JOIN articles Articles ON Articles.user_id = Users.id AND Articles.votes >= 5 - * GROUP BY USers.id - * ``` - * - * It is possible to left join deep associations by using dot notation - * - * ### Example: - * - * ``` - * // Total comments in articles by 'markstory' - * $query - * ->select(['total_comments' => $query->func()->count('Comments.id')]) - * ->leftJoinWith('Comments.Users', function ($q) { - * return $q->where(['username' => 'markstory']); - * ) - * ->group(['Users.id']); - * ``` - * - * Please note that the query passed to the closure will only accept calling - * `select`, `where`, `andWhere` and `orWhere` on it. If you wish to - * add more complex clauses you can do it directly in the main query. - * - * @param string $assoc The association to join with - * @param callable|null $builder a function that will receive a pre-made query object - * that can be used to add custom conditions or selecting some fields - * @return $this - */ - public function leftJoinWith($assoc, callable $builder = null) - { - $result = $this->getEagerLoader() - ->setMatching($assoc, $builder, [ - 'joinType' => QueryInterface::JOIN_TYPE_LEFT, - 'fields' => false - ]) - ->getMatching(); - $this->_addAssociationsToTypeMap($this->repository(), $this->getTypeMap(), $result); - $this->_dirty(); - - return $this; - } - - /** - * Creates an INNER JOIN with the passed association table while preserving - * the foreign key matching and the custom conditions that were originally set - * for it. - * - * This function will add entries in the `contain` graph. - * - * ### Example: - * - * ``` - * // Bring only articles that were tagged with 'cake' - * $query->innerJoinWith('Tags', function ($q) { - * return $q->where(['name' => 'cake']); - * ); - * ``` - * - * This will create the following SQL: - * - * ``` - * SELECT Articles.* - * FROM articles Articles - * INNER JOIN tags Tags ON Tags.name = 'cake' - * INNER JOIN articles_tags ArticlesTags ON ArticlesTags.tag_id = Tags.id - * AND ArticlesTags.articles_id = Articles.id - * ``` - * - * This function works the same as `matching()` with the difference that it - * will select no fields from the association. - * - * @param string $assoc The association to join with - * @param callable|null $builder a function that will receive a pre-made query object - * that can be used to add custom conditions or selecting some fields - * @return $this - * @see \Cake\ORM\Query::matching() - */ - public function innerJoinWith($assoc, callable $builder = null) - { - $result = $this->getEagerLoader() - ->setMatching($assoc, $builder, [ - 'joinType' => QueryInterface::JOIN_TYPE_INNER, - 'fields' => false - ]) - ->getMatching(); - $this->_addAssociationsToTypeMap($this->repository(), $this->getTypeMap(), $result); - $this->_dirty(); - - return $this; - } - - /** - * Adds filtering conditions to this query to only bring rows that have no match - * to another from an associated table, based on conditions in the associated table. - * - * This function will add entries in the `contain` graph. - * - * ### Example: - * - * ``` - * // Bring only articles that were not tagged with 'cake' - * $query->notMatching('Tags', function ($q) { - * return $q->where(['name' => 'cake']); - * ); - * ``` - * - * It is possible to filter by deep associations by using dot notation: - * - * ### Example: - * - * ``` - * // Bring only articles that weren't commented by 'markstory' - * $query->notMatching('Comments.Users', function ($q) { - * return $q->where(['username' => 'markstory']); - * ); - * ``` - * - * As this function will create a `LEFT JOIN`, you might want to consider - * calling `distinct` on this query as you might get duplicate rows if - * your conditions don't filter them already. This might be the case, for example, - * of the same article having multiple comments. - * - * ### Example: - * - * ``` - * // Bring unique articles that were commented by 'markstory' - * $query->distinct(['Articles.id']) - * ->notMatching('Comments.Users', function ($q) { - * return $q->where(['username' => 'markstory']); - * ); - * ``` - * - * Please note that the query passed to the closure will only accept calling - * `select`, `where`, `andWhere` and `orWhere` on it. If you wish to - * add more complex clauses you can do it directly in the main query. - * - * @param string $assoc The association to filter by - * @param callable|null $builder a function that will receive a pre-made query object - * that can be used to add custom conditions or selecting some fields - * @return $this - */ - public function notMatching($assoc, callable $builder = null) - { - $result = $this->getEagerLoader() - ->setMatching($assoc, $builder, [ - 'joinType' => QueryInterface::JOIN_TYPE_LEFT, - 'fields' => false, - 'negateMatch' => true - ]) - ->getMatching(); - $this->_addAssociationsToTypeMap($this->repository(), $this->getTypeMap(), $result); - $this->_dirty(); - - return $this; - } - - /** - * {@inheritDoc} - * - * Populates or adds parts to current query clauses using an array. - * This is handy for passing all query clauses at once. The option array accepts: - * - * - fields: Maps to the select method - * - conditions: Maps to the where method - * - limit: Maps to the limit method - * - order: Maps to the order method - * - offset: Maps to the offset method - * - group: Maps to the group method - * - having: Maps to the having method - * - contain: Maps to the contain options for eager loading - * - join: Maps to the join method - * - page: Maps to the page method - * - * ### Example: - * - * ``` - * $query->applyOptions([ - * 'fields' => ['id', 'name'], - * 'conditions' => [ - * 'created >=' => '2013-01-01' - * ], - * 'limit' => 10 - * ]); - * ``` - * - * Is equivalent to: - * - * ``` - * $query - * ->select(['id', 'name']) - * ->where(['created >=' => '2013-01-01']) - * ->limit(10) - * ``` - */ - public function applyOptions(array $options) - { - $valid = [ - 'fields' => 'select', - 'conditions' => 'where', - 'join' => 'join', - 'order' => 'order', - 'limit' => 'limit', - 'offset' => 'offset', - 'group' => 'group', - 'having' => 'having', - 'contain' => 'contain', - 'page' => 'page', - ]; - - ksort($options); - foreach ($options as $option => $values) { - if (isset($valid[$option], $values)) { - $this->{$valid[$option]}($values); - } else { - $this->_options[$option] = $values; - } - } - - return $this; - } - - /** - * Creates a copy of this current query, triggers beforeFind and resets some state. - * - * The following state will be cleared: - * - * - autoFields - * - limit - * - offset - * - map/reduce functions - * - result formatters - * - order - * - containments - * - * This method creates query clones that are useful when working with subqueries. - * - * @return \Cake\ORM\Query - */ - public function cleanCopy() - { - $clone = clone $this; - $clone->setEagerLoader(clone $this->getEagerLoader()); - $clone->triggerBeforeFind(); - $clone->enableAutoFields(false); - $clone->limit(null); - $clone->order([], true); - $clone->offset(null); - $clone->mapReduce(null, null, true); - $clone->formatResults(null, true); - $clone->setSelectTypeMap(new TypeMap()); - $clone->decorateResults(null, true); - - return $clone; - } - - /** - * Object clone hook. - * - * Destroys the clones inner iterator and clones the value binder, and eagerloader instances. - * - * @return void - */ - public function __clone() - { - parent::__clone(); - if ($this->_eagerLoader) { - $this->_eagerLoader = clone $this->_eagerLoader; - } - } - - /** - * {@inheritDoc} - * - * Returns the COUNT(*) for the query. If the query has not been - * modified, and the count has already been performed the cached - * value is returned - */ - public function count() - { - if ($this->_resultsCount === null) { - $this->_resultsCount = $this->_performCount(); - } - - return $this->_resultsCount; - } - - /** - * Performs and returns the COUNT(*) for the query. - * - * @return int - */ - protected function _performCount() - { - $query = $this->cleanCopy(); - $counter = $this->_counter; - if ($counter) { - $query->counter(null); - - return (int)$counter($query); - } - - $complex = ( - $query->clause('distinct') || - count($query->clause('group')) || - count($query->clause('union')) || - $query->clause('having') - ); - - if (!$complex) { - // Expression fields could have bound parameters. - foreach ($query->clause('select') as $field) { - if ($field instanceof ExpressionInterface) { - $complex = true; - break; - } - } - } - - if (!$complex && $this->_valueBinder !== null) { - $order = $this->clause('order'); - $complex = $order === null ? false : $order->hasNestedExpression(); - } - - $count = ['count' => $query->func()->count('*')]; - - if (!$complex) { - $query->getEagerLoader()->enableAutoFields(false); - $statement = $query - ->select($count, true) - ->enableAutoFields(false) - ->execute(); - } else { - $statement = $this->getConnection()->newQuery() - ->select($count) - ->from(['count_source' => $query]) - ->execute(); - } - - $result = $statement->fetch('assoc')['count']; - $statement->closeCursor(); - - return (int)$result; - } - - /** - * Registers a callable function that will be executed when the `count` method in - * this query is called. The return value for the function will be set as the - * return value of the `count` method. - * - * This is particularly useful when you need to optimize a query for returning the - * count, for example removing unnecessary joins, removing group by or just return - * an estimated number of rows. - * - * The callback will receive as first argument a clone of this query and not this - * query itself. - * - * If the first param is a null value, the built-in counter function will be called - * instead - * - * @param callable|null $counter The counter value - * @return $this - */ - public function counter($counter) - { - $this->_counter = $counter; - - return $this; - } - - /** - * Toggle hydrating entities. - * - * If set to false array results will be returned for the query. - * - * @param bool $enable Use a boolean to set the hydration mode. - * @return $this - */ - public function enableHydration($enable = true) - { - $this->_dirty(); - $this->_hydrate = (bool)$enable; - - return $this; - } - - /** - * Returns the current hydration mode. - * - * @return bool - */ - public function isHydrationEnabled() - { - return $this->_hydrate; - } - - /** - * Toggle hydrating entities. - * - * If set to false array results will be returned. - * - * @deprecated 3.4.0 Use enableHydration()/isHydrationEnabled() instead. - * @param bool|null $enable Use a boolean to set the hydration mode. - * Null will fetch the current hydration mode. - * @return bool|$this A boolean when reading, and $this when setting the mode. - */ - public function hydrate($enable = null) - { - if ($enable === null) { - return $this->isHydrationEnabled(); - } - - return $this->enableHydration($enable); - } - - /** - * {@inheritDoc} - * - * @return $this - * @throws \RuntimeException When you attempt to cache a non-select query. - */ - public function cache($key, $config = 'default') - { - if ($this->_type !== 'select' && $this->_type !== null) { - throw new RuntimeException('You cannot cache the results of non-select queries.'); - } - - return $this->_cache($key, $config); - } - - /** - * {@inheritDoc} - * - * @throws \RuntimeException if this method is called on a non-select Query. - */ - public function all() - { - if ($this->_type !== 'select' && $this->_type !== null) { - throw new RuntimeException( - 'You cannot call all() on a non-select query. Use execute() instead.' - ); - } - - return $this->_all(); - } - - /** - * Trigger the beforeFind event on the query's repository object. - * - * Will not trigger more than once, and only for select queries. - * - * @return void - */ - public function triggerBeforeFind() - { - if (!$this->_beforeFindFired && $this->_type === 'select') { - $table = $this->repository(); - $this->_beforeFindFired = true; - /* @var \Cake\Event\EventDispatcherInterface $table */ - $table->dispatchEvent('Model.beforeFind', [ - $this, - new ArrayObject($this->_options), - !$this->isEagerLoaded() - ]); - } - } - - /** - * {@inheritDoc} - */ - public function sql(ValueBinder $binder = null) - { - $this->triggerBeforeFind(); - - $this->_transformQuery(); - - return parent::sql($binder); - } - - /** - * Executes this query and returns a ResultSet object containing the results. - * This will also setup the correct statement class in order to eager load deep - * associations. - * - * @return \Cake\ORM\ResultSet - */ - protected function _execute() - { - $this->triggerBeforeFind(); - if ($this->_results) { - $decorator = $this->_decoratorClass(); - - return new $decorator($this->_results); - } - - $statement = $this->getEagerLoader()->loadExternal($this, $this->execute()); - - return new ResultSet($this, $statement); - } - - /** - * Applies some defaults to the query object before it is executed. - * - * Specifically add the FROM clause, adds default table fields if none are - * specified and applies the joins required to eager load associations defined - * using `contain` - * - * It also sets the default types for the columns in the select clause - * - * @see \Cake\Database\Query::execute() - * @return void - */ - protected function _transformQuery() - { - if (!$this->_dirty || $this->_type !== 'select') { - return; - } - - if (empty($this->_parts['from'])) { - $this->from([$this->_repository->getAlias() => $this->_repository->table()]); - } - $this->_addDefaultFields(); - $this->getEagerLoader()->attachAssociations($this, $this->_repository, !$this->_hasFields); - $this->_addDefaultSelectTypes(); - } - - /** - * Inspects if there are any set fields for selecting, otherwise adds all - * the fields for the default table. - * - * @return void - */ - protected function _addDefaultFields() - { - $select = $this->clause('select'); - $this->_hasFields = true; - - if (!count($select) || $this->_autoFields === true) { - $this->_hasFields = false; - $this->select($this->repository()->getSchema()->columns()); - $select = $this->clause('select'); - } - - $aliased = $this->aliasFields($select, $this->repository()->getAlias()); - $this->select($aliased, true); - } - - /** - * Sets the default types for converting the fields in the select clause - * - * @return void - */ - protected function _addDefaultSelectTypes() - { - $typeMap = $this->getTypeMap()->getDefaults(); - $select = $this->clause('select'); - $types = []; - - foreach ($select as $alias => $value) { - if (isset($typeMap[$alias])) { - $types[$alias] = $typeMap[$alias]; - continue; - } - if (is_string($value) && isset($typeMap[$value])) { - $types[$alias] = $typeMap[$value]; - } - if ($value instanceof TypedResultInterface) { - $types[$alias] = $value->getReturnType(); - } - } - $this->getSelectTypeMap()->addDefaults($types); - } - - /** - * {@inheritDoc} - * - * @see \Cake\ORM\Table::find() - */ - public function find($finder, array $options = []) - { - return $this->repository()->callFinder($finder, $this, $options); - } - - /** - * Marks a query as dirty, removing any preprocessed information - * from in memory caching such as previous results - * - * @return void - */ - protected function _dirty() - { - $this->_results = null; - $this->_resultsCount = null; - parent::_dirty(); - } - - /** - * Create an update query. - * - * This changes the query type to be 'update'. - * Can be combined with set() and where() methods to create update queries. - * - * @param string|null $table Unused parameter. - * @return $this - */ - public function update($table = null) - { - $table = $table ?: $this->repository()->table(); - - return parent::update($table); - } - - /** - * Create a delete query. - * - * This changes the query type to be 'delete'. - * Can be combined with the where() method to create delete queries. - * - * @param string|null $table Unused parameter. - * @return $this - */ - public function delete($table = null) - { - $repo = $this->repository(); - $this->from([$repo->getAlias() => $repo->table()]); - - return parent::delete(); - } - - /** - * Create an insert query. - * - * This changes the query type to be 'insert'. - * Note calling this method will reset any data previously set - * with Query::values() - * - * Can be combined with the where() method to create delete queries. - * - * @param array $columns The columns to insert into. - * @param array $types A map between columns & their datatypes. - * @return $this - */ - public function insert(array $columns, array $types = []) - { - $table = $this->repository()->table(); - $this->into($table); - - return parent::insert($columns, $types); - } - - /** - * {@inheritDoc} - * - * @throws \BadMethodCallException if the method is called for a non-select query - */ - public function __call($method, $arguments) - { - if ($this->type() === 'select') { - return $this->_call($method, $arguments); - } - - throw new \BadMethodCallException( - sprintf('Cannot call method "%s" on a "%s" query', $method, $this->type()) - ); - } - - /** - * {@inheritDoc} - */ - public function __debugInfo() - { - $eagerLoader = $this->getEagerLoader(); - - return parent::__debugInfo() + [ - 'hydrate' => $this->_hydrate, - 'buffered' => $this->_useBufferedResults, - 'formatters' => count($this->_formatters), - 'mapReducers' => count($this->_mapReduce), - 'contain' => $eagerLoader ? $eagerLoader->contain() : [], - 'matching' => $eagerLoader ? $eagerLoader->getMatching() : [], - 'extraOptions' => $this->_options, - 'repository' => $this->_repository - ]; - } - - /** - * Executes the query and converts the result set into JSON. - * - * Part of JsonSerializable interface. - * - * @return \Cake\Datasource\ResultSetInterface The data to convert to JSON. - */ - public function jsonSerialize() - { - return $this->all(); - } - - /** - * Sets whether or not the ORM should automatically append fields. - * - * By default calling select() will disable auto-fields. You can re-enable - * auto-fields with this method. - * - * @param bool $value Set true to enable, false to disable. - * @return $this - */ - public function enableAutoFields($value = true) - { - $this->_autoFields = (bool)$value; - - return $this; - } - - /** - * Gets whether or not the ORM should automatically append fields. - * - * By default calling select() will disable auto-fields. You can re-enable - * auto-fields with enableAutoFields(). - * - * @return bool The current value. - */ - public function isAutoFieldsEnabled() - { - return $this->_autoFields; - } - - /** - * Get/Set whether or not the ORM should automatically append fields. - * - * By default calling select() will disable auto-fields. You can re-enable - * auto-fields with this method. - * - * @deprecated 3.4.0 Use enableAutoFields()/isAutoFieldsEnabled() instead. - * @param bool|null $value The value to set or null to read the current value. - * @return bool|$this Either the current value or the query object. - */ - public function autoFields($value = null) - { - if ($value === null) { - return $this->isAutoFieldsEnabled(); - } - - return $this->enableAutoFields($value); - } - - /** - * Decorates the results iterator with MapReduce routines and formatters - * - * @param \Traversable $result Original results - * @return \Cake\Datasource\ResultSetInterface - */ - protected function _decorateResults($result) - { - $result = $this->_applyDecorators($result); - - if (!($result instanceof ResultSet) && $this->isBufferedResultsEnabled()) { - $class = $this->_decoratorClass(); - $result = new $class($result->buffered()); - } - return $result; - } -} +class_alias('Cake\ORM\Query\SelectQuery', 'Cake\ORM\Query'); diff --git a/src/ORM/Query/CommonQueryTrait.php b/src/ORM/Query/CommonQueryTrait.php new file mode 100644 index 00000000000..08aa7cf13e4 --- /dev/null +++ b/src/ORM/Query/CommonQueryTrait.php @@ -0,0 +1,89 @@ +getAlias(); + $map = $table->getSchema()->typeMap(); + $fields = []; + foreach ($map as $f => $type) { + $fields[$f] = $fields[$alias . '.' . $f] = $fields[$alias . '__' . $f] = $type; + } + $this->getTypeMap()->addDefaults($fields); + + return $this; + } + + /** + * Set the default Table object that will be used by this query + * and form the `FROM` clause. + * + * @param \Cake\Datasource\RepositoryInterface $repository The default table object to use + * @return $this + */ + public function setRepository(RepositoryInterface $repository) + { + assert( + $repository instanceof Table, + '`$repository` must be an instance of `' . Table::class . '`.', + ); + + $this->_repository = $repository; + + return $this; + } + + /** + * Returns the default repository object that will be used by this query, + * that is, the table that will appear in the "from" clause. + * + * @return \Cake\ORM\Table + */ + public function getRepository(): Table + { + return $this->_repository; + } +} diff --git a/src/ORM/Query/DeleteQuery.php b/src/ORM/Query/DeleteQuery.php new file mode 100644 index 00000000000..f70c4a3a095 --- /dev/null +++ b/src/ORM/Query/DeleteQuery.php @@ -0,0 +1,55 @@ +getConnection()); + + $this->setRepository($table); + $this->addDefaultTypes($table); + } + + /** + * @inheritDoc + */ + public function sql(?ValueBinder $binder = null): string + { + if (empty($this->_parts['from'])) { + $repository = $this->getRepository(); + $this->from([$repository->getAlias() => $repository->getTable()]); + } + + return parent::sql($binder); + } +} diff --git a/src/ORM/Query/InsertQuery.php b/src/ORM/Query/InsertQuery.php new file mode 100644 index 00000000000..d9026eb39f3 --- /dev/null +++ b/src/ORM/Query/InsertQuery.php @@ -0,0 +1,55 @@ +getConnection()); + + $this->setRepository($table); + $this->addDefaultTypes($table); + } + + /** + * @inheritDoc + */ + public function sql(?ValueBinder $binder = null): string + { + if (empty($this->_parts['into'])) { + $repository = $this->getRepository(); + $this->into($repository->getTable()); + } + + return parent::sql($binder); + } +} diff --git a/src/ORM/Query/QueryFactory.php b/src/ORM/Query/QueryFactory.php new file mode 100644 index 00000000000..891a7c9068b --- /dev/null +++ b/src/ORM/Query/QueryFactory.php @@ -0,0 +1,87 @@ + + */ + public function select(Table $table): SelectQuery + { + return new SelectQuery($table); + } + + /** + * Create a new non-hydrating UnhydratedSelectQuery instance. + * + * This is an independent construction seam, like select()/insert()/etc. + * Applications that override select() to return a custom SelectQuery + * subclass and want the same custom behavior on the non-hydrating path + * should override this method too (returning their own + * UnhydratedSelectQuery subclass). + * + * @param \Cake\ORM\Table $table The table this query is starting on. + * @return \Cake\ORM\Query\UnhydratedSelectQuery + * @since 5.4.0 + */ + public function unhydratedSelect(Table $table): UnhydratedSelectQuery + { + return new UnhydratedSelectQuery($table); + } + + /** + * Create a new InsertQuery instance. + * + * @param \Cake\ORM\Table $table The table this query is starting on. + * @return \Cake\ORM\Query\InsertQuery + */ + public function insert(Table $table): InsertQuery + { + return new InsertQuery($table); + } + + /** + * Create a new UpdateQuery instance. + * + * @param \Cake\ORM\Table $table The table this query is starting on. + * @return \Cake\ORM\Query\UpdateQuery + */ + public function update(Table $table): UpdateQuery + { + return new UpdateQuery($table); + } + + /** + * Create a new DeleteQuery instance. + * + * @param \Cake\ORM\Table $table The table this query is starting on. + * @return \Cake\ORM\Query\DeleteQuery + */ + public function delete(Table $table): DeleteQuery + { + return new DeleteQuery($table); + } +} diff --git a/src/ORM/Query/SelectQuery.php b/src/ORM/Query/SelectQuery.php new file mode 100644 index 00000000000..7c80902cc27 --- /dev/null +++ b/src/ORM/Query/SelectQuery.php @@ -0,0 +1,1843 @@ + + */ +class SelectQuery extends DbSelectQuery implements JsonSerializable, QueryInterface +{ + use CommonQueryTrait; + + /** + * Indicates that the operation should append to the list + * + * @var int + */ + public const APPEND = 0; + + /** + * Indicates that the operation should prepend to the list + * + * @var int + */ + public const PREPEND = 1; + + /** + * Indicates that the operation should overwrite the list + * + * @var bool + */ + public const OVERWRITE = true; + + /** + * Whether the user select any fields before being executed, this is used + * to determined if any fields should be automatically be selected. + * + * @var bool|null + */ + protected ?bool $_hasFields = null; + + /** + * Tracks whether the original query should include + * fields from the top level table. + * + * @var bool|null + */ + protected ?bool $_autoFields = null; + + /** + * Whether to hydrate results into entity objects + * + * @var bool + */ + protected bool $_hydrate = true; + + /** + * DTO class for projection instead of entity hydration + * + * @var class-string|null + */ + protected ?string $dtoClass = null; + + /** + * Whether aliases are generated for fields. + * + * @var bool + */ + protected bool $aliasingEnabled = true; + + /** + * A callback used to calculate the total amount of + * records this query will match when not using `limit` + * + * @var \Closure|null + */ + protected ?Closure $_counter = null; + + /** + * Instance of a class responsible for storing association containments and + * for eager loading them when this query is executed + * + * @var \Cake\ORM\EagerLoader|null + */ + protected ?EagerLoader $_eagerLoader = null; + + /** + * Whether the query is standalone or the product of an eager load operation. + * + * @var bool + */ + protected bool $_eagerLoaded = false; + + /** + * True if the beforeFind event has already been triggered for this query + * + * @var bool + */ + protected bool $_beforeFindFired = false; + + /** + * The COUNT(*) for the query. + * + * When set, count query execution will be bypassed. + * + * @var int|null + */ + protected ?int $_resultsCount = null; + + /** + * Result set factory + * + * @var \Cake\ORM\ResultSetFactory<\Cake\Datasource\EntityInterface|array> + */ + protected ResultSetFactory $resultSetFactory; + + /** + * A ResultSet. + * + * When set, SelectQuery execution will be bypassed. + * + * @var iterable|null + * @see \Cake\ORM\Query\SelectQuery::setResult() + */ + protected ?iterable $_results = null; + + /** + * List of map-reduce routines that should be applied over the query + * result + * + * @var array + */ + protected array $_mapReduce = []; + + /** + * List of formatter classes or callbacks that will post-process the + * results when fetched + * + * @var array<\Closure> + */ + protected array $_formatters = []; + + /** + * A query cacher instance if this query has caching enabled. + * + * @var \Cake\Datasource\QueryCacher|null + */ + protected ?QueryCacher $_cache = null; + + /** + * Holds any custom options passed using applyOptions that could not be processed + * by any method in this class. + * + * @var array + */ + protected array $_options = []; + + /** + * Constructor + * + * @param \Cake\ORM\Table $table The table this query is starting on + */ + public function __construct(Table $table) + { + parent::__construct($table->getConnection()); + + $this->setRepository($table); + $this->addDefaultTypes($table); + } + + /** + * Set the result set for a query. + * + * Setting the result set of a query will make execute() a no-op. Instead + * of executing the SQL query and fetching results, the ResultSet provided to this + * method will be returned. + * + * This method is most useful when combined with results stored in a persistent cache. + * + * @param iterable $results The results this query should return. + * @return $this + */ + public function setResult(iterable $results) + { + $this->_results = $results; + + return $this; + } + + /** + * Executes this query and returns a results iterator. This function is required + * for implementing the IteratorAggregate interface and allows the query to be + * iterated without having to call execute() manually, thus making it look like + * a result set instead of the query itself. + * + * @return \Cake\Datasource\ResultSetInterface + */ + public function getIterator(): ResultSetInterface + { + return $this->all(); + } + + /** + * Enable result caching for this query. + * + * If a query has caching enabled, it will do the following when executed: + * + * - Check the cache for $key. If there are results no SQL will be executed. + * Instead the cached results will be returned. + * - When the cached data is stale/missing the result set will be cached as the query + * is executed. + * + * ### Usage + * + * ``` + * // Simple string key + config + * $query->cache('my_key', 'db_results'); + * + * // Function to generate key. + * $query->cache(function ($q) { + * $key = serialize($q->clause('select')); + * $key .= serialize($q->clause('where')); + * return md5($key); + * }); + * + * // Using a pre-built cache engine. + * $query->cache('my_key', $engine); + * + * // Disable caching + * $query->cache(false); + * ``` + * + * @param \Closure|string|false $key Either the cache key or a function to generate the cache key. + * When using a function, this query instance will be supplied as an argument. + * @param \Psr\SimpleCache\CacheInterface|string $config Either the name of the cache config to use, or + * a cache engine instance. + * @return $this + */ + public function cache(Closure|string|false $key, CacheInterface|string $config = 'default') + { + if ($key === false) { + $this->_cache = null; + + return $this; + } + + $this->_cache = new QueryCacher($key, $config); + + return $this; + } + + /** + * Returns the current configured query `_eagerLoaded` value + * + * @return bool + */ + public function isEagerLoaded(): bool + { + return $this->_eagerLoaded; + } + + /** + * Sets the query instance to be an eager loaded query. If no argument is + * passed, the current configured query `_eagerLoaded` value is returned. + * + * @param bool $value Whether to eager load. + * @return $this + */ + public function eagerLoaded(bool $value) + { + $this->_eagerLoaded = $value; + + return $this; + } + + /** + * Returns a key => value array representing a single aliased field + * that can be passed directly to the select() method. + * The key will contain the alias and the value the actual field name. + * + * If the field is already aliased, then it will not be changed. + * If no $alias is passed, the default table for this query will be used. + * + * @param string $field The field to alias + * @param string|null $alias the alias used to prefix the field + * @return array + */ + public function aliasField(string $field, ?string $alias = null): array + { + if (str_contains($field, '.')) { + $aliasedField = $field; + [$alias, $field] = explode('.', $field); + } else { + $alias = $alias ?: $this->getRepository()->getAlias(); + $aliasedField = $alias . '.' . $field; + } + + $key = sprintf('%s__%s', $alias, $field); + + return [$key => $aliasedField]; + } + + /** + * Runs `aliasField()` for each field in the provided list and returns + * the result under a single array. + * + * @param array $fields The fields to alias + * @param string|null $defaultAlias The default alias + * @return array + */ + public function aliasFields(array $fields, ?string $defaultAlias = null): array + { + $aliased = []; + foreach ($fields as $alias => $field) { + if (is_numeric($alias) && is_string($field)) { + $aliased += $this->aliasField($field, $defaultAlias); + continue; + } + $aliased[$alias] = $field; + } + + return $aliased; + } + + /** + * Fetch the results for this query. + * + * Will return either the results set through setResult(), or execute this query + * and return the ResultSetDecorator object ready for streaming of results. + * + * ResultSetDecorator is a traversable object that implements the methods found + * on Cake\Collection\Collection. + * + * @return \Cake\Datasource\ResultSetInterface + */ + public function all(): ResultSetInterface + { + if ($this->_results !== null) { + if (!($this->_results instanceof ResultSetInterface)) { + $this->_results = $this->_decorateResults($this->_results); + } + + return $this->_results; + } + + $results = $this->_cache?->fetch($this); + if ($results === null) { + $results = $this->_decorateResults($this->_execute()); + $this->_cache?->store($this, $results); + } + $this->_results = $results; + + return $results; + } + + /** + * Returns an array representation of the results after executing the query. + * + * @return array + */ + public function toArray(): array + { + return $this->all()->toArray(); + } + + /** + * Register a new MapReduce routine to be executed on top of the database results + * + * The MapReduce routing will only be run when the query is executed and the first + * result is attempted to be fetched. + * + * If the third argument is set to true, it will erase previous map reducers + * and replace it with the arguments passed. + * + * @param \Closure|null $mapper The mapper function + * @param \Closure|null $reducer The reducing function + * @param bool $overwrite Set to true to overwrite existing map + reduce functions. + * @return $this + * @see \Cake\Collection\Iterator\MapReduce for details on how to use emit data to the map reducer. + */ + public function mapReduce(?Closure $mapper = null, ?Closure $reducer = null, bool $overwrite = false) + { + if ($overwrite) { + $this->_mapReduce = []; + } + if ($mapper === null) { + if (!$overwrite) { + throw new InvalidArgumentException('$mapper can be null only when $overwrite is true.'); + } + + return $this; + } + $this->_mapReduce[] = compact('mapper', 'reducer'); + + return $this; + } + + /** + * Returns the list of previously registered map reduce routines. + * + * @return array + */ + public function getMapReducers(): array + { + return $this->_mapReduce; + } + + /** + * Registers a new formatter callback function that is to be executed when trying + * to fetch the results from the database. + * + * If the second argument is set to true, it will erase previous formatters + * and replace them with the passed first argument. + * + * Callbacks are required to return an iterator object, which will be used as + * the return value for this query's result. Formatter functions are applied + * after all the `MapReduce` routines for this query have been executed. + * + * Formatting callbacks will receive two arguments, the first one being an object + * implementing `\Cake\Collection\CollectionInterface`, that can be traversed and + * modified at will. The second one being the query instance on which the formatter + * callback is being applied. + * + * Usually the query instance received by the formatter callback is the same query + * instance on which the callback was attached to, except for in a joined + * association, in that case the callback will be invoked on the association source + * side query, and it will receive that query instance instead of the one on which + * the callback was originally attached to - see the examples below! + * + * ### Examples: + * + * Return all results from the table indexed by id: + * + * ``` + * $query->select(['id', 'name'])->formatResults(function ($results) { + * return $results->indexBy('id'); + * }); + * ``` + * + * Add a new column to the ResultSet: + * + * ``` + * $query->select(['name', 'birth_date'])->formatResults(function ($results) { + * return $results->map(function ($row) { + * $row['age'] = $row['birth_date']->diff(new DateTime)->y; + * + * return $row; + * }); + * }); + * ``` + * + * Add a new column to the results with respect to the query's hydration configuration: + * + * ``` + * $query->formatResults(function ($results, $query) { + * return $results->map(function ($row) use ($query) { + * $data = [ + * 'bar' => 'baz', + * ]; + * + * if ($query->isHydrationEnabled()) { + * $row['foo'] = new Foo($data) + * } else { + * $row['foo'] = $data; + * } + * + * return $row; + * }); + * }); + * ``` + * + * Retaining access to the association target query instance of joined associations, + * by inheriting the contain callback's query argument: + * + * ``` + * // Assuming a `Articles belongsTo Authors` association that uses the join strategy + * + * $articlesQuery->contain('Authors', function ($authorsQuery) { + * return $authorsQuery->formatResults(function ($results, $query) use ($authorsQuery) { + * // Here `$authorsQuery` will always be the instance + * // where the callback was attached to. + * + * // The instance passed to the callback in the second + * // argument (`$query`), will be the one where the + * // callback is actually being applied to, in this + * // example that would be `$articlesQuery`. + * + * // ... + * + * return $results; + * }); + * }); + * ``` + * + * @param \Closure|null $formatter The formatting function + * @param int|bool $mode Whether to overwrite, append or prepend the formatter. + * @return $this + * @throws \InvalidArgumentException + */ + public function formatResults(?Closure $formatter = null, int|bool $mode = self::APPEND) + { + if ($mode === self::OVERWRITE) { + $this->_formatters = []; + } + if ($formatter === null) { + if ($mode !== self::OVERWRITE) { + throw new InvalidArgumentException('$formatter can be null only when $mode is overwrite.'); + } + + return $this; + } + + if ($mode === self::PREPEND) { + array_unshift($this->_formatters, $formatter); + + return $this; + } + + $this->_formatters[] = $formatter; + + return $this; + } + + /** + * Returns the list of previously registered format routines. + * + * @return array<\Closure> + */ + public function getResultFormatters(): array + { + return $this->_formatters; + } + + /** + * Returns the first result out of executing this query, if the query has not been + * executed before, it will set the limit clause to 1 for performance reasons. + * + * ### Example: + * + * ``` + * $singleUser = $query->select(['id', 'username'])->first(); + * ``` + * + * @return TSubject|null The first result from the ResultSet. + */ + public function first(): mixed + { + if ($this->_dirty) { + $this->limit(1); + } + + return $this->all()->first(); + } + + /** + * Get the first result from the executing query or raise an exception. + * + * @throws \Cake\Datasource\Exception\RecordNotFoundException When there is no first record. + * @return TSubject The first result from the ResultSet. + */ + public function firstOrFail(): mixed + { + $entity = $this->first(); + if (!$entity) { + $table = $this->getRepository(); + throw new RecordNotFoundException(sprintf( + 'Record not found in table `%s`.', + $table->getTable(), + )); + } + + return $entity; + } + + /** + * Returns an array with the custom options that were applied to this query + * and that were not already processed by another method in this class. + * + * ### Example: + * + * ``` + * $query->applyOptions(['doABarrelRoll' => true, 'fields' => ['id', 'name']); + * $query->getOptions(); // Returns ['doABarrelRoll' => true] + * ``` + * + * @see \Cake\Datasource\QueryInterface::applyOptions() to read about the options that will + * be processed by this class and not returned by this function + * @return array + * @see \Cake\ORM\Query\SelectQuery::applyOptions() + */ + public function getOptions(): array + { + return $this->_options; + } + + /** + * Populates or adds parts to current query clauses using an array. + * This is handy for passing all query clauses at once. + * + * The method accepts the following query clause related options: + * + * - fields: Maps to the select method + * - conditions: Maps to the where method + * - limit: Maps to the limit method + * - order: Maps to the order method + * - offset: Maps to the offset method + * - group: Maps to the group method + * - having: Maps to the having method + * - contain: Maps to the contain options for eager loading + * - join: Maps to the join method + * - page: Maps to the page method + * + * All other options will not affect the query, but will be stored + * as custom options that can be read via `getOptions()`. Furthermore + * they are automatically passed to `Model.beforeFind`. + * + * ### Example: + * + * ``` + * $query->applyOptions([ + * 'fields' => ['id', 'name'], + * 'conditions' => [ + * 'created >=' => '2013-01-01' + * ], + * 'limit' => 10, + * ]); + * ``` + * + * Is equivalent to: + * + * ``` + * $query + * ->select(['id', 'name']) + * ->where(['created >=' => '2013-01-01']) + * ->limit(10) + * ``` + * + * Custom options can be read via `getOptions()`: + * + * ``` + * $query->applyOptions([ + * 'fields' => ['id', 'name'], + * 'custom' => 'value', + * ]); + * ``` + * + * Here `$options` will hold `['custom' => 'value']` (the `fields` + * option will be applied to the query instead of being stored, as + * it's a query clause related option): + * + * ``` + * $options = $query->getOptions(); + * ``` + * + * @param array $options The options to be applied + * @return $this + * @see \Cake\ORM\Query\SelectQuery::getOptions() + */ + public function applyOptions(array $options) + { + $valid = [ + 'select' => 'select', + 'fields' => 'select', + 'conditions' => 'where', + 'where' => 'where', + 'join' => 'join', + 'order' => 'orderBy', + 'orderBy' => 'orderBy', + 'limit' => 'limit', + 'offset' => 'offset', + 'group' => 'groupBy', + 'groupBy' => 'groupBy', + 'having' => 'having', + 'contain' => 'contain', + 'page' => 'page', + ]; + + ksort($options); + foreach ($options as $option => $values) { + if (isset($valid[$option], $values)) { + $this->{$valid[$option]}($values); + } else { + $this->_options[$option] = $values; + } + } + + return $this; + } + + /** + * Decorates the results iterator with MapReduce routines and formatters + * + * @param iterable $result Original results + * @return \Cake\Datasource\ResultSetInterface + */ + protected function _decorateResults(iterable $result): ResultSetInterface + { + $resultSetClass = $this->resultSetFactory()->getResultSetClass(); + + if ($this->_mapReduce) { + foreach ($this->_mapReduce as $functions) { + $result = new MapReduce($result, $functions['mapper'], $functions['reducer']); + } + $result = new $resultSetClass($result); + } + + if (!($result instanceof ResultSetInterface)) { + $result = new $resultSetClass($result); + } + + if ($this->_formatters) { + foreach ($this->_formatters as $formatter) { + $result = $formatter($result, $this); + } + + if (!($result instanceof ResultSetInterface)) { + $result = new $resultSetClass($result); + } + } + + // DTO projection runs AFTER all other formatters so behaviors see arrays/entities + if ($this->dtoClass !== null) { + // Get the cached hydrator once, avoiding method_exists() check on every row + $hydrator = $this->resultSetFactory()->getDtoHydrator($this->dtoClass); + $result = $result->map($hydrator); + + if (!($result instanceof ResultSetInterface)) { + $result = new $resultSetClass($result); + } + } + + return $result; + } + + /** + * Adds new fields to be returned by a `SELECT` statement when this query is + * executed. Fields can be passed as an array of strings, array of expression + * objects, a single expression or a single string. + * + * If an array is passed, keys will be used to alias fields using the value as the + * real field to be aliased. It is possible to alias strings, Expression objects or + * even other Query objects. + * + * If a callback is passed, the returning array of the function will + * be used as the list of fields. + * + * By default, this function will append any passed argument to the list of fields + * to be selected, unless the second argument is set to true. + * + * ### Examples: + * + * ``` + * $query->select(['id', 'title']); // Produces SELECT id, title + * $query->select(['author' => 'author_id']); // Appends author: SELECT id, title, author_id as author + * $query->select('id', true); // Resets the list: SELECT id + * $query->select(['total' => $countQuery]); // SELECT id, (SELECT ...) AS total + * $query->select(function ($query) { + * return ['article_id', 'total' => $query->count('*')]; + * }) + * ``` + * + * By default, no fields are selected, if you have an instance of `Cake\ORM\Query\SelectQuery` and try to + * append fields you should also call `Cake\ORM\Query\SelectQuery::enableAutoFields()` to select the + * default fields from the table. + * + * If you pass an instance of a `Cake\ORM\Table` or `Cake\ORM\Association` class, + * all the fields in the schema of the table or the association will be added to + * the select clause. + * + * @param \Cake\Database\ExpressionInterface|\Cake\ORM\Table|\Cake\ORM\Association|\Closure|array|string|float|int $fields Fields + * to be added to the list. + * @param bool $overwrite whether to reset fields with passed list or not + * @return $this + */ + public function select( + ExpressionInterface|Table|Association|Closure|array|string|float|int $fields = [], + bool $overwrite = false, + ) { + if ($fields instanceof Association) { + $fields = $fields->getTarget(); + } + + if ($fields instanceof Table) { + if ($this->aliasingEnabled) { + $fields = $this->aliasFields($fields->getSchema()->columns(), $fields->getAlias()); + } else { + $fields = $fields->getSchema()->columns(); + } + } + + return parent::select($fields, $overwrite); + } + + /** + * Behaves the exact same as `select()` except adds the field to the list of fields selected and + * does not disable auto-selecting fields for Associations. + * + * Use this instead of calling `select()` then `enableAutoFields()` to re-enable auto-fields. + * + * @param \Cake\Database\ExpressionInterface|\Cake\ORM\Table|\Cake\ORM\Association|\Closure|array|string|float|int $fields Fields + * to be added to the list. + * @return $this + */ + public function selectAlso( + ExpressionInterface|Table|Association|Closure|array|string|float|int $fields, + ) { + $this->select($fields); + $this->_autoFields = true; + + return $this; + } + + /** + * All the fields associated with the passed table except the excluded + * fields will be added to the select clause of the query. Passed excluded fields should not be aliased. + * After the first call to this method, a second call cannot be used to remove fields that have already + * been added to the query by the first. If you need to change the list after the first call, + * pass overwrite boolean true which will reset the select clause removing all previous additions. + * + * @param \Cake\ORM\Table|\Cake\ORM\Association $table The table to use to get an array of columns + * @param array $excludedFields The un-aliased column names you do not want selected from $table + * @param bool $overwrite Whether to reset/remove previous selected fields + * @return $this + */ + public function selectAllExcept(Table|Association $table, array $excludedFields, bool $overwrite = false) + { + if ($table instanceof Association) { + $table = $table->getTarget(); + } + + $fields = array_diff($table->getSchema()->columns(), $excludedFields); + if ($this->aliasingEnabled) { + $fields = $this->aliasFields($fields); + } + + return $this->select($fields, $overwrite); + } + + /** + * Sets the instance of the eager loader class to use for loading associations + * and storing containments. + * + * @param \Cake\ORM\EagerLoader $instance The eager loader to use. + * @return $this + */ + public function setEagerLoader(EagerLoader $instance) + { + $this->_eagerLoader = $instance; + + return $this; + } + + /** + * Returns the currently configured instance. + * + * @return \Cake\ORM\EagerLoader + */ + public function getEagerLoader(): EagerLoader + { + return $this->_eagerLoader ??= new EagerLoader(); + } + + /** + * Sets the list of associations that should be eagerly loaded along with this + * query. The list of associated tables passed must have been previously set as + * associations using the Table API. + * + * ### Example: + * + * ``` + * // Bring articles' author information + * $query->contain('Author'); + * + * // Also bring the category and tags associated to each article + * $query->contain(['Category', 'Tag']); + * ``` + * + * Associations can be arbitrarily nested using dot notation or nested arrays, + * this allows this object to calculate joins or any additional queries that + * must be executed to bring the required associated data. + * + * ### Example: + * + * ``` + * // Eager load the product info, and for each product load other 2 associations + * $query->contain(['Product' => ['Manufacturer', 'Distributor']); + * + * // Which is equivalent to calling + * $query->contain(['Products.Manufactures', 'Products.Distributors']); + * + * // For an author query, load his region, state and country + * $query->contain('Regions.States.Countries'); + * ``` + * + * It is possible to control the conditions and fields selected for each of the + * contained associations: + * + * ### Example: + * + * ``` + * $query->contain(['Tags' => function ($q) { + * return $q->where(['Tags.is_popular' => true]); + * }]); + * + * $query->contain(['Products.Manufactures' => function ($q) { + * return $q->select(['name'])->where(['Manufactures.active' => true]); + * }]); + * ``` + * + * Each association might define special options when eager loaded, the allowed + * options that can be set per association are: + * + * - `foreignKey`: Used to set a different field to match both tables, if set to false + * no join conditions will be generated automatically. `false` can only be used on + * joinable associations and cannot be used with hasMany or belongsToMany associations. + * - `fields`: An array with the fields that should be fetched from the association. + * - `finder`: The finder to use when loading associated records. Either the name of the + * finder as a string, or an array to define options to pass to the finder. + * - `queryBuilder`: Equivalent to passing a callback instead of an options array. + * + * ### Example: + * + * ``` + * // Set options for the hasMany articles that will be eagerly loaded for an author + * $query->contain([ + * 'Articles' => [ + * 'fields' => ['title', 'author_id'] + * ] + * ]); + * ``` + * + * Finders can be configured to use options. + * + * ``` + * // Retrieve translations for the articles, but only those for the `en` and `es` locales + * $query->contain([ + * 'Articles' => [ + * 'finder' => [ + * 'translations' => [ + * 'locales' => ['en', 'es'] + * ] + * ] + * ] + * ]); + * ``` + * + * When containing associations, it is important to include foreign key columns. + * Failing to do so will trigger exceptions. + * + * ``` + * // Use a query builder to add conditions to the containment + * $query->contain('Authors', function ($q) { + * return $q->where(...); // add conditions + * }); + * // Use special join conditions for multiple containments in the same method call + * $query->contain([ + * 'Authors' => [ + * 'foreignKey' => false, + * 'queryBuilder' => function ($q) { + * return $q->where(...); // Add full filtering conditions + * } + * ], + * 'Tags' => function ($q) { + * return $q->where(...); // add conditions + * } + * ]); + * ``` + * + * If called with an empty first argument and `$override` is set to true, the + * previous list will be emptied. + * + * @param array|string $associations List of table aliases to be queried. + * @param \Closure|bool $override The query builder for the association, or + * if associations is an array, a bool on whether to override previous list + * with the one passed + * defaults to merging previous list with the new one. + * @return $this + */ + public function contain(array|string $associations, Closure|bool $override = false) + { + $loader = $this->getEagerLoader(); + if ($override === true) { + $this->clearContain(); + } + + $queryBuilder = null; + if ($override instanceof Closure) { + $queryBuilder = $override; + } + + if ($associations) { + $loader->contain($associations, $queryBuilder); + } + $this->_addAssociationsToTypeMap( + $this->getRepository(), + $this->getTypeMap(), + $loader->getContain(), + ); + + return $this; + } + + /** + * @return array + */ + public function getContain(): array + { + return $this->getEagerLoader()->getContain(); + } + + /** + * Clears the contained associations from the current query. + * + * @return $this + */ + public function clearContain() + { + $this->getEagerLoader()->clearContain(); + $this->_dirty(); + + return $this; + } + + /** + * Used to recursively add contained association column types to + * the query. + * + * @param \Cake\ORM\Table $table The table instance to pluck associations from. + * @param \Cake\Database\TypeMap $typeMap The typemap to check for columns in. + * This typemap is indirectly mutated via {@link \Cake\ORM\Query\SelectQuery::addDefaultTypes()} + * @param array $associations The nested tree of associations to walk. + * @return void + */ + protected function _addAssociationsToTypeMap(Table $table, TypeMap $typeMap, array $associations): void + { + foreach ($associations as $name => $nested) { + if (!$table->hasAssociation($name)) { + continue; + } + $association = $table->getAssociation($name); + $target = $association->getTarget(); + $primary = (array)$target->getPrimaryKey(); + if (!$primary || $typeMap->type($target->aliasField($primary[0])) === null) { + $this->addDefaultTypes($target); + } + if ($nested) { + $this->_addAssociationsToTypeMap($target, $typeMap, $nested); + } + } + } + + /** + * Adds filtering conditions to this query to only bring rows that have a relation + * to another from an associated table, based on conditions in the associated table. + * + * This function will add entries in the `contain` graph. + * + * ### Example: + * + * ``` + * // Bring only articles that were tagged with 'cake' + * $query->matching('Tags', function ($q) { + * return $q->where(['name' => 'cake']); + * }); + * ``` + * + * It is possible to filter by deep associations by using dot notation: + * + * ### Example: + * + * ``` + * // Bring only articles that were commented by 'markstory' + * $query->matching('Comments.Users', function ($q) { + * return $q->where(['username' => 'markstory']); + * }); + * ``` + * + * As this function will create `INNER JOIN`, you might want to consider + * calling `distinct` on this query as you might get duplicate rows if + * your conditions don't filter them already. This might be the case, for example, + * of the same user commenting more than once in the same article. + * + * ### Example: + * + * ``` + * // Bring unique articles that were commented by 'markstory' + * $query->distinct(['Articles.id']) + * ->matching('Comments.Users', function ($q) { + * return $q->where(['username' => 'markstory']); + * }); + * ``` + * + * Please note that the query passed to the closure will only accept calling + * `select`, `where`, `andWhere` and `orWhere` on it. If you wish to + * add more complex clauses you can do it directly in the main query. + * + * @param string $assoc The association to filter by + * @param \Closure|null $builder a function that will receive a pre-made query object + * that can be used to add custom conditions or selecting some fields + * @return $this + */ + public function matching(string $assoc, ?Closure $builder = null) + { + $result = $this->getEagerLoader()->setMatching($assoc, $builder)->getMatching(); + $this->_addAssociationsToTypeMap($this->getRepository(), $this->getTypeMap(), $result); + $this->_dirty(); + + return $this; + } + + /** + * Creates a LEFT JOIN with the passed association table while preserving + * the foreign key matching and the custom conditions that were originally set + * for it. + * + * This function will add entries in the `contain` graph. + * + * ### Example: + * + * ``` + * // Get the count of articles per user + * $usersQuery + * ->select(['total_articles' => $query->func()->count('Articles.id')]) + * ->leftJoinWith('Articles') + * ->groupBy(['Users.id']) + * ->enableAutoFields(); + * ``` + * + * You can also customize the conditions passed to the LEFT JOIN: + * + * ``` + * // Get the count of articles per user with at least 5 votes + * $usersQuery + * ->select(['total_articles' => $query->func()->count('Articles.id')]) + * ->leftJoinWith('Articles', function ($q) { + * return $q->where(['Articles.votes >=' => 5]); + * }) + * ->groupBy(['Users.id']) + * ->enableAutoFields(); + * ``` + * + * This will create the following SQL: + * + * ``` + * SELECT COUNT(Articles.id) AS total_articles, Users.* + * FROM users Users + * LEFT JOIN articles Articles ON Articles.user_id = Users.id AND Articles.votes >= 5 + * GROUP BY USers.id + * ``` + * + * It is possible to left join deep associations by using dot notation + * + * ### Example: + * + * ``` + * // Total comments in articles by 'markstory' + * $query + * ->select(['total_comments' => $query->func()->count('Comments.id')]) + * ->leftJoinWith('Comments.Users', function ($q) { + * return $q->where(['username' => 'markstory']); + * }) + * ->groupBy(['Users.id']); + * ``` + * + * Please note that the query passed to the closure will only accept calling + * `select`, `where`, `andWhere` and `orWhere` on it. If you wish to + * add more complex clauses you can do it directly in the main query. + * + * @param string $assoc The association to join with + * @param \Closure|null $builder a function that will receive a pre-made query object + * that can be used to add custom conditions or selecting some fields + * @return $this + */ + public function leftJoinWith(string $assoc, ?Closure $builder = null) + { + $result = $this->getEagerLoader() + ->setMatching($assoc, $builder, [ + 'joinType' => static::JOIN_TYPE_LEFT, + 'fields' => false, + ]) + ->getMatching(); + $this->_addAssociationsToTypeMap($this->getRepository(), $this->getTypeMap(), $result); + $this->_dirty(); + + return $this; + } + + /** + * Creates an INNER JOIN with the passed association table while preserving + * the foreign key matching and the custom conditions that were originally set + * for it. + * + * This function will add entries in the `contain` graph. + * + * ### Example: + * + * ``` + * // Bring only articles that were tagged with 'cake' + * $query->innerJoinWith('Tags', function ($q) { + * return $q->where(['name' => 'cake']); + * }); + * ``` + * + * This will create the following SQL: + * + * ``` + * SELECT Articles.* + * FROM articles Articles + * INNER JOIN tags Tags ON Tags.name = 'cake' + * INNER JOIN articles_tags ArticlesTags ON ArticlesTags.tag_id = Tags.id + * AND ArticlesTags.articles_id = Articles.id + * ``` + * + * This function works the same as `matching()` with the difference that it + * will select no fields from the association. + * + * @param string $assoc The association to join with + * @param \Closure|null $builder a function that will receive a pre-made query object + * that can be used to add custom conditions or selecting some fields + * @return $this + * @see \Cake\ORM\Query\SelectQuery::matching() + */ + public function innerJoinWith(string $assoc, ?Closure $builder = null) + { + $result = $this->getEagerLoader() + ->setMatching($assoc, $builder, [ + 'joinType' => static::JOIN_TYPE_INNER, + 'fields' => false, + ]) + ->getMatching(); + $this->_addAssociationsToTypeMap($this->getRepository(), $this->getTypeMap(), $result); + $this->_dirty(); + + return $this; + } + + /** + * Adds filtering conditions to this query to only bring rows that have no match + * to another from an associated table, based on conditions in the associated table. + * + * This function will add entries in the `contain` graph. + * + * ### Example: + * + * ``` + * // Bring only articles that were not tagged with 'cake' + * $query->notMatching('Tags', function ($q) { + * return $q->where(['name' => 'cake']); + * }); + * ``` + * + * It is possible to filter by deep associations by using dot notation: + * + * ### Example: + * + * ``` + * // Bring only articles that weren't commented by 'markstory' + * $query->notMatching('Comments.Users', function ($q) { + * return $q->where(['username' => 'markstory']); + * }); + * ``` + * + * As this function will create a `LEFT JOIN`, you might want to consider + * calling `distinct` on this query as you might get duplicate rows if + * your conditions don't filter them already. This might be the case, for example, + * of the same article having multiple comments. + * + * ### Example: + * + * ``` + * // Bring unique articles that were commented by 'markstory' + * $query->distinct(['Articles.id']) + * ->notMatching('Comments.Users', function ($q) { + * return $q->where(['username' => 'markstory']); + * }); + * ``` + * + * Please note that the query passed to the closure will only accept calling + * `select`, `where`, `andWhere` and `orWhere` on it. If you wish to + * add more complex clauses you can do it directly in the main query. + * + * @param string $assoc The association to filter by + * @param \Closure|null $builder a function that will receive a pre-made query object + * that can be used to add custom conditions or selecting some fields + * @return $this + */ + public function notMatching(string $assoc, ?Closure $builder = null) + { + $result = $this->getEagerLoader() + ->setMatching($assoc, $builder, [ + 'joinType' => static::JOIN_TYPE_LEFT, + 'fields' => false, + 'negateMatch' => true, + ]) + ->getMatching(); + $this->_addAssociationsToTypeMap($this->getRepository(), $this->getTypeMap(), $result); + $this->_dirty(); + + return $this; + } + + /** + * Creates a copy of this current query, triggers beforeFind and resets some state. + * + * The following state will be cleared: + * + * - autoFields + * - limit + * - offset + * - map/reduce functions + * - result formatters + * - order + * - containments + * + * This method creates query clones that are useful when working with subqueries. + * + * @return static + */ + public function cleanCopy(): static + { + $clone = clone $this; + $clone->triggerBeforeFind(); + $clone->disableAutoFields(); + $clone->limit(null); + $clone->orderBy([], true); + $clone->offset(null); + $clone->mapReduce(null, null, true); + $clone->formatResults(null, self::OVERWRITE); + $clone->setSelectTypeMap(new TypeMap()); + $clone->decorateResults(null, true); + + return $clone; + } + + /** + * Clears the internal result cache and the internal count value from the current + * query object. + * + * @return $this + */ + public function clearResult() + { + $this->_dirty(); + + return $this; + } + + /** + * {@inheritDoc} + * + * Handles cloning eager loaders. + */ + public function __clone() + { + parent::__clone(); + if ($this->_eagerLoader !== null) { + $this->_eagerLoader = clone $this->_eagerLoader; + } + } + + /** + * {@inheritDoc} + * + * Returns the COUNT(*) for the query. If the query has not been + * modified, and the count has already been performed the cached + * value is returned + * + * @return int + */ + public function count(): int + { + return $this->_resultsCount ??= $this->_performCount(); + } + + /** + * Performs and returns the COUNT(*) for the query. + * + * @return int + */ + protected function _performCount(): int + { + $query = $this->cleanCopy(); + $counter = $this->_counter; + if ($counter !== null) { + $query->counter(null); + + return (int)$counter($query); + } + + $complex = ( + $query->clause('distinct') || + count($query->clause('group')) || + count($query->clause('union')) || + count($query->clause('intersect')) || + $query->clause('having') + ); + + if (!$complex) { + // Expression fields could have bound parameters. + foreach ($query->clause('select') as $field) { + if ($field instanceof ExpressionInterface) { + $complex = true; + break; + } + } + } + + if (!$complex && $this->_valueBinder !== null) { + $order = $this->clause('order'); + assert($order === null || $order instanceof QueryExpression); + $complex = $order === null ? false : $order->hasNestedExpression(); + } + + $count = ['count' => $query->func()->count('*')]; + + if ($complex) { + $statement = $this->getConnection()->selectQuery() + ->select($count) + ->from(['count_source' => $query]) + ->execute(); + } else { + $query->getEagerLoader()->disableAutoFields(); + $statement = $query + ->select($count, true) + ->disableAutoFields() + ->execute(); + } + + $result = $statement->fetch(PDO::FETCH_ASSOC); + + return $result === false ? 0 : (int)$result['count']; + } + + /** + * Registers a callback that will be executed when the `count` method in + * this query is called. The return value for the function will be set as the + * return value of the `count` method. + * + * This is particularly useful when you need to optimize a query for returning the + * count, for example removing unnecessary joins, removing group by or just return + * an estimated number of rows. + * + * The callback will receive as first argument a clone of this query and not this + * query itself. + * + * If the first param is a null value, the built-in counter function will be called + * instead + * + * @param \Closure|null $counter The counter value + * @return $this + */ + public function counter(?Closure $counter) + { + $this->_counter = $counter; + + return $this; + } + + /** + * Toggle hydrating entities. + * + * If set to false array results will be returned for the query. + * + * @param bool $enable Use a boolean to set the hydration mode. + * @return $this + */ + public function enableHydration(bool $enable = true) + { + $this->_dirty(); + $this->_hydrate = $enable; + + return $this; + } + + /** + * Disable hydrating entities. + * + * Disabling hydration will cause array results to be returned for the query + * instead of entities. + * + * @deprecated 5.4.0 Use {@see \Cake\ORM\Table::unhydratedFind()} for + * type-safe non-hydrated reads. The fluent toggle returns a `static` + * that lies about its result shape; `unhydratedFind()` returns an + * `UnhydratedSelectQuery` whose type matches the runtime. Removed in 6.0. + * @return static> + * @phpcsSuppress SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingNativeTypeHint + */ + public function disableHydration() + { + $this->_dirty(); + $this->_hydrate = false; + + /** @phpstan-ignore return.type */ + return $this; + } + + /** + * Returns the current hydration mode. + * + * @return bool + */ + public function isHydrationEnabled(): bool + { + return $this->_hydrate; + } + + /** + * Project results into a DTO class instead of entities. + * + * When DTO projection is enabled, results will be hydrated into + * the specified DTO class instead of entity objects. + * + * @param class-string $dtoClass The DTO class name + * @return $this + */ + public function projectAs(string $dtoClass) + { + $this->_dirty(); + $this->dtoClass = $dtoClass; + + return $this; + } + + /** + * Get the DTO class for projection. + * + * @return class-string|null + */ + public function getDtoClass(): ?string + { + return $this->dtoClass; + } + + /** + * Check if DTO projection is enabled. + * + * @return bool + */ + public function isDtoProjectionEnabled(): bool + { + return $this->dtoClass !== null; + } + + /** + * Trigger the beforeFind event on the query's repository object. + * + * Will not trigger more than once, and only for select queries. + * + * @return void + */ + public function triggerBeforeFind(): void + { + if (!$this->_beforeFindFired) { + $this->_beforeFindFired = true; + + $repository = $this->getRepository(); + $repository->dispatchEvent('Model.beforeFind', [ + $this, + new ArrayObject($this->_options), + !$this->isEagerLoaded(), + ]); + } + } + + /** + * @inheritDoc + */ + public function sql(?ValueBinder $binder = null): string + { + $this->triggerBeforeFind(); + + $this->_transformQuery(); + + return parent::sql($binder); + } + + /** + * Executes this query and returns an iterable containing the results. + * + * @return iterable + */ + protected function _execute(): iterable + { + $this->triggerBeforeFind(); + if ($this->_results !== null) { + return $this->_results; + } + + if ($this->bufferedResults) { + $results = parent::all(); + } else { + $results = $this->execute(); + } + $results = $this->getEagerLoader()->loadExternal($this, $results); + + return $this->resultSetFactory()->createResultSet($results, $this); + } + + /** + * Get result set factory. + * + * @return \Cake\ORM\ResultSetFactory<\Cake\Datasource\EntityInterface|array> + */ + public function resultSetFactory(): ResultSetFactory + { + return $this->resultSetFactory ??= new ResultSetFactory(); + } + + /** + * Applies some defaults to the query object before it is executed. + * + * Specifically add the FROM clause, adds default table fields if none are + * specified and applies the joins required to eager load associations defined + * using `contain` + * + * It also sets the default types for the columns in the select clause + * + * @see \Cake\Database\Query::execute() + * @return void + */ + protected function _transformQuery(): void + { + if (!$this->_dirty) { + return; + } + + $repository = $this->getRepository(); + + if (empty($this->_parts['from'])) { + $this->from([$repository->getAlias() => $repository->getTable()]); + } + $this->_addDefaultFields(); + $this->getEagerLoader()->attachAssociations($this, $repository, !$this->_hasFields); + $this->_addDefaultSelectTypes(); + } + + /** + * Inspects if there are any set fields for selecting, otherwise adds all + * the fields for the default table. + * + * @return void + */ + protected function _addDefaultFields(): void + { + $select = $this->clause('select'); + $this->_hasFields = true; + + $repository = $this->getRepository(); + + if (!count($select) || $this->_autoFields === true) { + $this->_hasFields = false; + $this->select($repository->getSchema()->columns()); + $select = $this->clause('select'); + } + + if ($this->aliasingEnabled) { + $select = $this->aliasFields($select, $repository->getAlias()); + } + $this->select($select, true); + } + + /** + * Sets the default types for converting the fields in the select clause + * + * @return void + */ + protected function _addDefaultSelectTypes(): void + { + $typeMap = $this->getTypeMap()->getDefaults(); + $select = $this->clause('select'); + $types = []; + + foreach ($select as $alias => $value) { + if ($value instanceof TypedResultInterface) { + $types[$alias] = $value->getReturnType(); + continue; + } + if (isset($typeMap[$alias])) { + $types[$alias] = $typeMap[$alias]; + continue; + } + if (is_string($value) && isset($typeMap[$value])) { + $types[$alias] = $typeMap[$value]; + } + } + $this->getSelectTypeMap()->addDefaults($types); + } + + /** + * {@inheritDoc} + * + * @param string $finder The finder method to use. + * @param mixed ...$args Arguments that match up to finder-specific parameters + * @return static Returns a modified query. + */ + public function find(string $finder, mixed ...$args): static + { + $table = $this->getRepository(); + + return $table->callFinder($finder, $this, ...$args); + } + + /** + * Disable auto adding table's alias to the fields of SELECT clause. + * + * @return $this + */ + public function disableAutoAliasing() + { + $this->aliasingEnabled = false; + + return $this; + } + + /** + * Marks a query as dirty, removing any preprocessed information + * from in memory caching such as previous results + * + * @return void + */ + protected function _dirty(): void + { + $this->_results = null; + $this->_resultsCount = null; + parent::_dirty(); + } + + /** + * @inheritDoc + */ + public function __debugInfo(): array + { + $eagerLoader = $this->getEagerLoader(); + + return parent::__debugInfo() + [ + 'hydrate' => $this->_hydrate, + 'formatters' => count($this->_formatters), + 'mapReducers' => count($this->_mapReduce), + 'contain' => $eagerLoader->getContain(), + 'matching' => $eagerLoader->getMatching(), + 'extraOptions' => $this->_options, + 'repository' => $this->_repository, + ]; + } + + /** + * Executes the query and converts the result set into JSON. + * + * Part of JsonSerializable interface. + * + * @return \Cake\Datasource\ResultSetInterface The data to convert to JSON. + */ + public function jsonSerialize(): ResultSetInterface + { + return $this->all(); + } + + /** + * Sets whether the ORM should automatically append fields. + * + * By default, calling select() will disable auto-fields. You can re-enable + * auto-fields with this method. + * + * @param bool $value Set true to enable, false to disable. + * @return $this + */ + public function enableAutoFields(bool $value = true) + { + $this->_autoFields = $value; + + return $this; + } + + /** + * Disables automatically appending fields. + * + * @return $this + */ + public function disableAutoFields() + { + $this->_autoFields = false; + + return $this; + } + + /** + * Gets whether the ORM should automatically append fields. + * + * By default, calling select() will disable auto-fields. You can re-enable + * auto-fields with enableAutoFields(). + * + * @return bool|null The current value. Returns null if neither enabled or disabled yet. + */ + public function isAutoFieldsEnabled(): ?bool + { + return $this->_autoFields; + } +} + +// phpcs:disable +class_exists(\Cake\ORM\Query::class); +// phpcs:enable diff --git a/src/ORM/Query/UnhydratedSelectQuery.php b/src/ORM/Query/UnhydratedSelectQuery.php new file mode 100644 index 00000000000..4ff262648b1 --- /dev/null +++ b/src/ORM/Query/UnhydratedSelectQuery.php @@ -0,0 +1,43 @@ +disableHydration()` at runtime — it is + * fully substitutable for {@see SelectQuery} (eager loading, association + * finders and the rest of the ORM may treat it like any other select query). + * Its sole purpose is the static type: because it extends + * `SelectQuery>`, `first()` / `firstOrFail()` / `all()` / + * `toArray()` / iteration resolve to arrays instead of `entity|array`, and + * that binding survives finder dispatch where a bare generic annotation would + * decay. + * + * Use {@see \Cake\ORM\Table::unhydratedFind()} as the entry point. This class is the + * type-safe replacement for `SelectQuery->disableHydration()`, which becomes + * a hard error in 6.0. + * + * @extends \Cake\ORM\Query\SelectQuery> + */ +class UnhydratedSelectQuery extends SelectQuery +{ + /** + * @var bool + */ + protected bool $_hydrate = false; +} diff --git a/src/ORM/Query/UpdateQuery.php b/src/ORM/Query/UpdateQuery.php new file mode 100644 index 00000000000..bda5c14d43f --- /dev/null +++ b/src/ORM/Query/UpdateQuery.php @@ -0,0 +1,55 @@ +getConnection()); + + $this->setRepository($table); + $this->addDefaultTypes($table); + } + + /** + * @inheritDoc + */ + public function sql(?ValueBinder $binder = null): string + { + if (empty($this->_parts['update'])) { + $repository = $this->getRepository(); + $this->update($repository->getTable()); + } + + return parent::sql($binder); + } +} diff --git a/src/ORM/README.md b/src/ORM/README.md index 8629095b564..a6b3c51340b 100644 --- a/src/ORM/README.md +++ b/src/ORM/README.md @@ -27,8 +27,8 @@ specify a driver to use: use Cake\Datasource\ConnectionManager; ConnectionManager::setConfig('default', [ - 'className' => 'Cake\Database\Connection', - 'driver' => 'Cake\Database\Driver\Mysql', + 'className' => \Cake\Database\Connection::class, + 'driver' => \Cake\Database\Driver\Mysql::class, 'database' => 'test', 'username' => 'root', 'password' => 'secret', @@ -40,18 +40,50 @@ ConnectionManager::setConfig('default', [ Once a 'default' connection is registered, it will be used by all the Table mappers if no explicit connection is defined. +## Using Table Locator + +In order to access table instances you need to use a *Table Locator*. + +```php +use Cake\ORM\Locator\TableLocator; + +$locator = new TableLocator(); +$articles = $locator->get('Articles'); +``` + +You can also use a trait for easy access to the locator instance: + +```php +use Cake\ORM\Locator\LocatorAwareTrait; + +$articles = $this->getTableLocator()->get('Articles'); +``` + +By default, classes using `LocatorAwareTrait` will share a global locator instance. +You can inject your own locator instance into the object: + +```php +use Cake\ORM\Locator\TableLocator; +use Cake\ORM\Locator\LocatorAwareTrait; + +$locator = new TableLocator(); +$this->setTableLocator($locator); + +$articles = $this->getTableLocator()->get('Articles'); +``` + ## Creating Associations In your table classes you can define the relations between your tables. CakePHP's ORM supports 4 association types out of the box: * belongsTo - E.g. Many articles belong to a user. -* hasOne - E.g. A user has one profile -* hasMany - E.g. A user has many articles +* hasOne - E.g. A user has one profile. +* hasMany - E.g. A user has many articles. * belongsToMany - E.g. An article belongsToMany tags. You define associations in your table's `initialize()` method. See the -[documentation](https://book.cakephp.org/3.0/en/orm/associations.html) for +[documentation](https://book.cakephp.org/5/en/orm/associations.html) for complete examples. ## Reading Data @@ -59,16 +91,16 @@ complete examples. Once you've defined some table classes you can read existing data in your tables: ```php -use Cake\ORM\TableRegistry; +use Cake\ORM\Locator\LocatorAwareTrait; -$articles = TableRegistry::get('Articles'); +$articles = $this->getTableLocator()->get('Articles'); foreach ($articles->find() as $article) { echo $article->title; } ``` -You can use the [query builder](https://book.cakephp.org/3.0/en/orm/query-builder.html) to create -complex queries, and a [variety of methods](https://book.cakephp.org/3.0/en/orm/retrieving-data-and-resultsets.html) +You can use the [query builder](https://book.cakephp.org/5/en/orm/query-builder.html) to create +complex queries, and a [variety of methods](https://book.cakephp.org/5/en/orm/retrieving-data-and-resultsets.html) to access your data. ## Saving Data @@ -77,7 +109,7 @@ Table objects provide ways to convert request data into entities, and then persi those entities to the database: ```php -use Cake\ORM\TableRegistry; +use Cake\ORM\Locator\LocatorAwareTrait; $data = [ 'title' => 'My first article', @@ -92,7 +124,7 @@ $data = [ ] ]; -$articles = TableRegistry::get('Articles'); +$articles = $this->getTableLocator()->get('Articles'); $article = $articles->newEntity($data, [ 'associated' => ['Tags', 'Comments'] ]); @@ -102,7 +134,7 @@ $articles->save($article, [ ``` The above shows how you can easily marshal and save an entity and its -associations in a simple & powerful way. Consult the [ORM documentation](https://book.cakephp.org/3.0/en/orm/saving-data.html) +associations in a simple & powerful way. Consult the [ORM documentation](https://book.cakephp.org/5/en/orm/saving-data.html) for more in-depth examples. ## Deleting Data @@ -110,15 +142,16 @@ for more in-depth examples. Once you have a reference to an entity, you can use it to delete data: ```php -$articles = TableRegistry::get('Articles'); +$articles = $this->getTableLocator()->get('Articles'); $article = $articles->get(2); $articles->delete($article); ``` ## Meta Data Cache -It is recommended to enable meta data cache for production systems to avoid performance issues. +It is recommended to enable metadata cache for production systems to avoid performance issues. For e.g. file system strategy your bootstrap file could look like this: + ```php use Cake\Cache\Engine\FileEngine; @@ -127,11 +160,82 @@ $cacheConfig = [ 'duration' => '+1 year', 'serialize' => true, 'prefix' => 'orm_', -], +]; Cache::setConfig('_cake_model_', $cacheConfig); ``` +Cache configs are optional, so you must require ``cachephp/cache`` to add one. + +## Creating Custom Table and Entity Classes + +By default, the Cake ORM uses the `\Cake\ORM\Table` and `\Cake\ORM\Entity` classes to +interact with the database. While using the default classes makes sense for +quick scripts and small applications, you will often want to use your own +classes for adding your custom logic. + +When using the ORM as a standalone package, you are free to choose where to +store these classes. For example, you could use the `Data` folder for this: + +```php +setEntityClass(Article::class); + $this->belongsTo('Users', ['className' => UsersTable::class]); + } +} +``` + +This table class is now setup to connect to the `articles` table in your +database and return instances of `Article` when fetching results. In order to +get an instance of this class, as shown before, you can use the `TableLocator`: + +```php +get('Articles', ['className' => ArticlesTable::class]); +``` + +### Using Conventions-Based Loading + +It may get quite tedious having to specify each time the class name to load. So +the Cake ORM can do most of the work for you if you give it some configuration. + +The convention is to have all ORM related classes inside the `src/Model` folder, +that is the `Model` sub-namespace for your app. So you will usually have the +`src/Model/Table` and `src/Model/Entity` folders in your project. But first, we +need to inform Cake of the namespace your application lives in: + +```php + + * @extends \Cake\Collection\Collection */ -class ResultSet implements ResultSetInterface +class ResultSet extends Collection implements ResultSetInterface { - - use CollectionTrait; - - /** - * Original query from where results were generated - * - * @var \Cake\ORM\Query - * @deprecated 3.1.6 Due to a memory leak, this property cannot be used anymore - */ - protected $_query; - - /** - * Database statement holding the results - * - * @var \Cake\Database\StatementInterface - */ - protected $_statement; - - /** - * Points to the next record number that should be fetched - * - * @var int - */ - protected $_index = 0; - - /** - * Last record fetched from the statement - * - * @var array - */ - protected $_current; - - /** - * Default table instance - * - * @var \Cake\ORM\Table - */ - protected $_defaultTable; - - /** - * The default table alias - * - * @var string - */ - protected $_defaultAlias; - - /** - * List of associations that should be placed under the `_matchingData` - * result key. - * - * @var array - */ - protected $_matchingMap = []; - - /** - * List of associations that should be eager loaded. - * - * @var array - */ - protected $_containMap = []; - - /** - * Map of fields that are fetched from the statement with - * their type and the table they belong to - * - * @var array - */ - protected $_map = []; - - /** - * List of matching associations and the column keys to expect - * from each of them. - * - * @var array - */ - protected $_matchingMapColumns = []; - - /** - * Results that have been fetched or hydrated into the results. - * - * @var array|\ArrayAccess - */ - protected $_results = []; - - /** - * Whether to hydrate results into objects or not - * - * @var bool - */ - protected $_hydrate = true; - - /** - * Tracks value of $_autoFields property of $query passed to constructor. - * - * @var bool - */ - protected $_autoFields; - - /** - * The fully namespaced name of the class to use for hydrating results - * - * @var string - */ - protected $_entityClass; - - /** - * Whether or not to buffer results fetched from the statement - * - * @var bool - */ - protected $_useBuffering = true; - - /** - * Holds the count of records in this result set - * - * @var int - */ - protected $_count; - - /** - * Type cache for type converters. - * - * Converters are indexed by alias and column name. - * - * @var array - */ - protected $_types = []; - - /** - * The Database driver object. - * - * Cached in a property to avoid multiple calls to the same function. - * - * @var \Cake\Database\Driver - */ - protected $_driver; - - /** - * Constructor - * - * @param \Cake\ORM\Query $query Query from where results come - * @param \Cake\Database\StatementInterface $statement The statement to fetch from - */ - public function __construct($query, $statement) - { - $repository = $query->repository(); - $this->_statement = $statement; - $this->_driver = $query->getConnection()->getDriver(); - $this->_defaultTable = $query->repository(); - $this->_calculateAssociationMap($query); - $this->_hydrate = $query->isHydrationEnabled(); - $this->_entityClass = $repository->getEntityClass(); - $this->_useBuffering = $query->isBufferedResultsEnabled(); - $this->_defaultAlias = $this->_defaultTable->getAlias(); - $this->_calculateColumnMap($query); - $this->_autoFields = $query->isAutoFieldsEnabled(); - - if ($this->_useBuffering) { - $count = $this->count(); - $this->_results = new SplFixedArray($count); - } - } - - /** - * Returns the current record in the result iterator - * - * Part of Iterator interface. - * - * @return array|object - */ - public function current() - { - return $this->_current; - } - - /** - * Returns the key of the current record in the iterator - * - * Part of Iterator interface. - * - * @return int - */ - public function key() - { - return $this->_index; - } - - /** - * Advances the iterator pointer to the next record - * - * Part of Iterator interface. - * - * @return void - */ - public function next() - { - $this->_index++; - } - - /** - * Rewinds a ResultSet. - * - * Part of Iterator interface. - * - * @throws \Cake\Database\Exception - * @return void - */ - public function rewind() - { - if ($this->_index == 0) { - return; - } - - if (!$this->_useBuffering) { - $msg = 'You cannot rewind an un-buffered ResultSet. Use Query::bufferResults() to get a buffered ResultSet.'; - throw new Exception($msg); - } - - $this->_index = 0; - } - - /** - * Whether there are more results to be fetched from the iterator - * - * Part of Iterator interface. - * - * @return bool - */ - public function valid() - { - if ($this->_useBuffering) { - $valid = $this->_index < $this->_count; - if ($valid && $this->_results[$this->_index] !== null) { - $this->_current = $this->_results[$this->_index]; - - return true; - } - if (!$valid) { - return $valid; - } - } - - $this->_current = $this->_fetchResult(); - $valid = $this->_current !== false; - - if ($valid && $this->_useBuffering) { - $this->_results[$this->_index] = $this->_current; - } - if (!$valid && $this->_statement !== null) { - $this->_statement->closeCursor(); - } - - return $valid; - } - - /** - * Get the first record from a result set. - * - * This method will also close the underlying statement cursor. - * - * @return array|object - */ - public function first() - { - foreach ($this as $result) { - if ($this->_statement && !$this->_useBuffering) { - $this->_statement->closeCursor(); - } - - return $result; - } - } - - /** - * Serializes a resultset. - * - * Part of Serializable interface. - * - * @return string Serialized object - */ - public function serialize() - { - if (!$this->_useBuffering) { - $msg = 'You cannot serialize an un-buffered ResultSet. Use Query::bufferResults() to get a buffered ResultSet.'; - throw new Exception($msg); - } - - while ($this->valid()) { - $this->next(); - } - - if ($this->_results instanceof SplFixedArray) { - return serialize($this->_results->toArray()); - } - - return serialize($this->_results); - } - - /** - * Unserializes a resultset. - * - * Part of Serializable interface. - * - * @param string $serialized Serialized object - * @return void - */ - public function unserialize($serialized) - { - $results = (array)(unserialize($serialized) ?: []); - $this->_results = SplFixedArray::fromArray($results); - $this->_useBuffering = true; - $this->_count = $this->_results->count(); - } - - /** - * Gives the number of rows in the result set. - * - * Part of the Countable interface. - * - * @return int - */ - public function count() - { - if ($this->_count !== null) { - return $this->_count; - } - if ($this->_statement) { - return $this->_count = $this->_statement->rowCount(); - } - - if ($this->_results instanceof SplFixedArray) { - $this->_count = $this->_results->count(); - } else { - $this->_count = count($this->_results); - } - - return $this->_count; - } - - /** - * Calculates the list of associations that should get eager loaded - * when fetching each record - * - * @param \Cake\ORM\Query $query The query from where to derive the associations - * @return void - */ - protected function _calculateAssociationMap($query) - { - $map = $query->getEagerLoader()->associationsMap($this->_defaultTable); - $this->_matchingMap = (new Collection($map)) - ->match(['matching' => true]) - ->indexBy('alias') - ->toArray(); - - $this->_containMap = (new Collection(array_reverse($map))) - ->match(['matching' => false]) - ->indexBy('nestKey') - ->toArray(); - } - - /** - * Creates a map of row keys out of the query select clause that can be - * used to hydrate nested result sets more quickly. - * - * @param \Cake\ORM\Query $query The query from where to derive the column map - * @return void - */ - protected function _calculateColumnMap($query) - { - $map = []; - foreach ($query->clause('select') as $key => $field) { - $key = trim($key, '"`[]'); - - if (strpos($key, '__') <= 0) { - $map[$this->_defaultAlias][$key] = $key; - continue; - } - - $parts = explode('__', $key, 2); - $map[$parts[0]][$key] = $parts[1]; - } - - foreach ($this->_matchingMap as $alias => $assoc) { - if (!isset($map[$alias])) { - continue; - } - $this->_matchingMapColumns[$alias] = $map[$alias]; - unset($map[$alias]); - } - - $this->_map = $map; - } - - /** - * Creates a map of Type converter classes for each of the columns that should - * be fetched by this object. - * - * @deprecated 3.2.0 Not used anymore. Type casting is done at the statement level - * @return void - */ - protected function _calculateTypeMap() - { - } - - /** - * Returns the Type classes for each of the passed fields belonging to the - * table. - * - * @param \Cake\ORM\Table $table The table from which to get the schema - * @param array $fields The fields whitelist to use for fields in the schema. - * @return array - */ - protected function _getTypes($table, $fields) - { - $types = []; - $schema = $table->getSchema(); - $map = array_keys(Type::map() + ['string' => 1, 'text' => 1, 'boolean' => 1]); - $typeMap = array_combine( - $map, - array_map(['Cake\Database\Type', 'build'], $map) - ); - - foreach (['string', 'text'] as $t) { - if (get_class($typeMap[$t]) === 'Cake\Database\Type') { - unset($typeMap[$t]); - } - } - - foreach (array_intersect($fields, $schema->columns()) as $col) { - $typeName = $schema->getColumnType($col); - if (isset($typeMap[$typeName])) { - $types[$col] = $typeMap[$typeName]; - } - } - - return $types; - } - - /** - * Helper function to fetch the next result from the statement or - * seeded results. - * - * @return mixed - */ - protected function _fetchResult() - { - if (!$this->_statement) { - return false; - } - - $row = $this->_statement->fetch('assoc'); - if ($row === false) { - return $row; - } - - return $this->_groupResult($row); - } - - /** - * Correctly nests results keys including those coming from associations - * - * @param array $row Array containing columns and values or false if there is no results - * @return array Results - */ - protected function _groupResult($row) - { - $defaultAlias = $this->_defaultAlias; - $results = $presentAliases = []; - $options = [ - 'useSetters' => false, - 'markClean' => true, - 'markNew' => false, - 'guard' => false - ]; - - foreach ($this->_matchingMapColumns as $alias => $keys) { - $matching = $this->_matchingMap[$alias]; - $results['_matchingData'][$alias] = array_combine( - $keys, - array_intersect_key($row, $keys) - ); - if ($this->_hydrate) { - /* @var \Cake\ORM\Table $table */ - $table = $matching['instance']; - $options['source'] = $table->getRegistryAlias(); - /* @var \Cake\Datasource\EntityInterface $entity */ - $entity = new $matching['entityClass']($results['_matchingData'][$alias], $options); - $results['_matchingData'][$alias] = $entity; - } - } - - foreach ($this->_map as $table => $keys) { - $results[$table] = array_combine($keys, array_intersect_key($row, $keys)); - $presentAliases[$table] = true; - } - - unset($presentAliases[$defaultAlias]); - - foreach ($this->_containMap as $assoc) { - $alias = $assoc['nestKey']; - - if ($assoc['canBeJoined'] && empty($this->_map[$alias])) { - continue; - } - - /* @var \Cake\ORM\Association $instance */ - $instance = $assoc['instance']; - - if (!$assoc['canBeJoined'] && !isset($row[$alias])) { - $results = $instance->defaultRowValue($results, $assoc['canBeJoined']); - continue; - } - - if (!$assoc['canBeJoined']) { - $results[$alias] = $row[$alias]; - } - - $target = $instance->getTarget(); - $options['source'] = $target->getRegistryAlias(); - unset($presentAliases[$alias]); - - if ($assoc['canBeJoined'] && $this->_autoFields !== false) { - $hasData = false; - foreach ($results[$alias] as $v) { - if ($v !== null && $v !== []) { - $hasData = true; - break; - } - } - - if (!$hasData) { - $results[$alias] = null; - } - } - - if ($this->_hydrate && $results[$alias] !== null && $assoc['canBeJoined']) { - $entity = new $assoc['entityClass']($results[$alias], $options); - $results[$alias] = $entity; - } - - $results = $instance->transformRow($results, $alias, $assoc['canBeJoined'], $assoc['targetProperty']); - } - - foreach ($presentAliases as $alias => $present) { - if (!isset($results[$alias])) { - continue; - } - $results[$defaultAlias][$alias] = $results[$alias]; - } - - if (isset($results['_matchingData'])) { - $results[$defaultAlias]['_matchingData'] = $results['_matchingData']; - } - - $options['source'] = $this->_defaultTable->registryAlias(); - if (isset($results[$defaultAlias])) { - $results = $results[$defaultAlias]; - } - if ($this->_hydrate && !($results instanceof EntityInterface)) { - $results = new $this->_entityClass($results, $options); - } - - return $results; - } - - /** - * Casts all values from a row brought from a table to the correct - * PHP type. - * - * @param string $alias The table object alias - * @param array $values The values to cast - * @deprecated 3.2.0 Not used anymore. Type casting is done at the statement level - * @return array - */ - protected function _castValues($alias, $values) - { - return $values; - } - - /** - * Returns an array that can be used to describe the internal state of this - * object. - * - * @return array - */ - public function __debugInfo() - { - return [ - 'items' => $this->toArray(), - ]; - } } diff --git a/src/ORM/ResultSetFactory.php b/src/ORM/ResultSetFactory.php new file mode 100644 index 00000000000..983848e0d56 --- /dev/null +++ b/src/ORM/ResultSetFactory.php @@ -0,0 +1,364 @@ +> + */ + protected string $resultSetClass = ResultSet::class; + + /** + * Create a result set instance. + * + * @param iterable $results Results. + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array>|null $query Query from where results came. + * @return \Cake\Datasource\ResultSetInterface + */ + public function createResultSet(iterable $results, ?SelectQuery $query = null): ResultSetInterface + { + if ($query) { + $data = $this->collectData($query); + + if (is_array($results)) { + foreach ($results as $i => $row) { + $results[$i] = $this->groupResult($row, $data); + } + + $results = SplFixedArray::fromArray($results); + } else { + $results = (new Collection($results)) + ->map(function ($row) use ($data) { + return $this->groupResult($row, $data); + }); + } + } + + return new $this->resultSetClass($results); + } + + /** + * Get repository and its associations data for nesting results key and + * entity hydration. + * + * @param \Cake\ORM\Query\SelectQuery<\Cake\Datasource\EntityInterface|array> $query The query from where to derive the data. + * @return array{primaryAlias: string, registryAlias: string, entityClass: class-string<\Cake\Datasource\EntityInterface>, hydrate: bool, autoFields: bool|null, matchingColumns: array, dtoClass: class-string|null, matchingAssoc: array, containAssoc: array, fields: array} + */ + protected function collectData(SelectQuery $query): array + { + $primaryTable = $query->getRepository(); + $data = [ + 'primaryAlias' => $primaryTable->getAlias(), + 'registryAlias' => $primaryTable->getRegistryAlias(), + 'entityClass' => $primaryTable->getEntityClass(), + 'hydrate' => $query->isHydrationEnabled(), + 'autoFields' => $query->isAutoFieldsEnabled(), + 'matchingColumns' => [], + 'dtoClass' => $query->getDtoClass(), + ]; + + $assocMap = $query->getEagerLoader()->associationsMap($primaryTable); + $data['matchingAssoc'] = (new Collection($assocMap)) + ->match(['matching' => true]) + ->indexBy('alias') + ->toArray(); + + $data['containAssoc'] = (new Collection(array_reverse($assocMap))) + ->match(['matching' => false]) + ->indexBy('nestKey') + ->toArray(); + + $fields = []; + foreach ($query->clause('select') as $key => $field) { + $key = trim((string)$key, '"`[]'); + + if (strpos($key, '__') <= 0) { + $fields[$data['primaryAlias']][$key] = $key; + continue; + } + + $parts = explode('__', $key, 2); + $fields[$parts[0]][$key] = $parts[1]; + } + + foreach ($data['matchingAssoc'] as $alias => $assoc) { + if (!isset($fields[$alias])) { + continue; + } + $data['matchingColumns'][$alias] = $fields[$alias]; + unset($fields[$alias]); + } + + $data['fields'] = $fields; + + return $data; + } + + /** + * Correctly nests results keys including those coming from associations. + * + * Hydrate row array into entity if hydration is enabled. + * + * @param array $row Array containing columns and values. + * @param array $data Array containing table and query metadata + * @return \Cake\Datasource\EntityInterface|array + */ + protected function groupResult(array $row, array $data): EntityInterface|array + { + $results = []; + $presentAliases = []; + $options = [ + 'useSetters' => false, + 'markClean' => true, + 'markNew' => false, + 'guard' => false, + ]; + + foreach ($data['matchingColumns'] as $alias => $keys) { + $matching = $data['matchingAssoc'][$alias]; + $results['_matchingData'][$alias] = array_combine( + $keys, + array_intersect_key($row, $keys), + ); + if ($data['hydrate'] && $data['dtoClass'] === null) { + $table = $matching['instance']; + assert($table instanceof Table || $table instanceof Association); + + $options['source'] = $table->getRegistryAlias(); + $entity = new $matching['entityClass']($results['_matchingData'][$alias], $options); + assert($entity instanceof EntityInterface); + + $results['_matchingData'][$alias] = $entity; + } + } + + foreach ($data['fields'] as $table => $keys) { + $results[$table] = array_combine($keys, array_intersect_key($row, $keys)); + $presentAliases[$table] = true; + } + + // If the default table is not in the results, set + // it to an empty array so that any contained + // associations hydrate correctly. + $results[$data['primaryAlias']] ??= []; + + unset($presentAliases[$data['primaryAlias']]); + + foreach ($data['containAssoc'] as $assoc) { + $alias = $assoc['nestKey']; + /** @var bool $canBeJoined */ + $canBeJoined = $assoc['canBeJoined']; + if ($canBeJoined && empty($data['fields'][$alias])) { + continue; + } + + $instance = $assoc['instance']; + assert($instance instanceof Association); + + if (!$canBeJoined && !isset($row[$alias])) { + $results = $instance->defaultRowValue($results, $canBeJoined); + continue; + } + + if (!$canBeJoined) { + $results[$alias] = $row[$alias]; + } + + $target = $instance->getTarget(); + $options['source'] = $target->getRegistryAlias(); + unset($presentAliases[$alias]); + + if ($assoc['canBeJoined'] && $data['autoFields'] !== false) { + $hasData = false; + foreach ($results[$alias] as $v) { + if ($v !== null && $v !== []) { + $hasData = true; + break; + } + } + + if (!$hasData) { + $results[$alias] = null; + } + } + + if ($data['hydrate'] && $data['dtoClass'] === null && $results[$alias] !== null && $assoc['canBeJoined']) { + $entity = new $assoc['entityClass']($results[$alias], $options); + $results[$alias] = $entity; + } + + $results = $instance->transformRow($results, $alias, $assoc['canBeJoined'], $assoc['targetProperty']); + } + + foreach ($presentAliases as $alias => $present) { + if (!isset($results[$alias])) { + continue; + } + $results[$data['primaryAlias']][$alias] = $results[$alias]; + } + + if (isset($results['_matchingData'])) { + $results[$data['primaryAlias']]['_matchingData'] = $results['_matchingData']; + } + + $options['source'] = $data['registryAlias']; + if (isset($results[$data['primaryAlias']])) { + $results = $results[$data['primaryAlias']]; + } + + // DTO projection returns arrays - DTO mapping happens in formatter phase + if ($data['dtoClass'] !== null) { + return $results; + } + + if ($data['hydrate'] && !($results instanceof EntityInterface)) { + /** @var \Cake\Datasource\EntityInterface */ + return new $data['entityClass']($results, $options); + } + + return $results; + } + + /** + * Cached DtoMapper instance + * + * @var \Cake\ORM\DtoMapper|null + */ + protected ?DtoMapper $dtoMapper = null; + + /** + * Cached DTO hydrator callables by class name. + * Avoids method_exists() check on every row. + * + * @var array + */ + protected static array $dtoHydrators = []; + + /** + * Hydrate a row into a DTO. + * + * Supports two patterns: + * - Static `createFromArray($data, $nested)` factory method (cakephp-dto style) + * - Constructor with named parameters (DtoMapper reflection) + * + * @param array $row Nested array data + * @param class-string $dtoClass DTO class name + * @return object + */ + public function hydrateDto(array $row, string $dtoClass): object + { + return $this->getDtoHydrator($dtoClass)($row); + } + + /** + * Get a cached hydrator callable for a DTO class. + * + * The hydrator is determined once per class and cached to avoid + * method_exists() checks on every row. + * + * @param class-string $dtoClass DTO class name + * @return callable(array): object + */ + public function getDtoHydrator(string $dtoClass): callable + { + if (!isset(static::$dtoHydrators[$dtoClass])) { + // Check for array style static factory method (cakephp-dto style) + if (method_exists($dtoClass, 'createFromArray')) { + static::$dtoHydrators[$dtoClass] = static function (array $row) use ($dtoClass): object { + return $dtoClass::createFromArray($row, true); + }; + } else { + // Use DtoMapper for plain readonly DTOs with named constructor params + $mapper = $this->getDtoMapper(); + static::$dtoHydrators[$dtoClass] = static function (array $row) use ($mapper, $dtoClass): object { + return $mapper->map($row, $dtoClass); + }; + } + } + + return static::$dtoHydrators[$dtoClass]; + } + + /** + * Clear the DTO hydrator cache. + * + * Useful for testing or when classes are reloaded. + * + * @return void + */ + public static function clearDtoHydratorCache(): void + { + static::$dtoHydrators = []; + } + + /** + * Get or create the DtoMapper instance. + * + * @return \Cake\ORM\DtoMapper + */ + public function getDtoMapper(): DtoMapper + { + return $this->dtoMapper ??= new DtoMapper(); + } + + /** + * Set the ResultSet class to use. + * + * @param class-string<\Cake\Datasource\ResultSetInterface> $resultSetClass Class name. + * @return $this + */ + public function setResultSetClass(string $resultSetClass) + { + if (!is_subclass_of($resultSetClass, ResultSetInterface::class)) { + throw new InvalidArgumentException(sprintf( + 'Invalid ResultSet class `%s`. It must implement `%s`', + $resultSetClass, + ResultSetInterface::class, + )); + } + + $this->resultSetClass = $resultSetClass; + + return $this; + } + + /** + * Get the ResultSet class to use. + * + * @return class-string<\Cake\Datasource\ResultSetInterface> + */ + public function getResultSetClass(): string + { + return $this->resultSetClass; + } +} diff --git a/src/ORM/Rule/ExistsIn.php b/src/ORM/Rule/ExistsIn.php index 47afd05e549..32dea8af9c1 100644 --- a/src/ORM/Rule/ExistsIn.php +++ b/src/ORM/Rule/ExistsIn.php @@ -1,4 +1,6 @@ */ - protected $_fields; + protected array $_fields; /** * The repository where the field will be looked for * - * @var \Cake\Datasource\RepositoryInterface|\Cake\ORM\Association|string + * @var \Cake\ORM\Table|\Cake\ORM\Association|string */ - protected $_repository; + protected Table|Association|string $_repository; /** * Options for the constructor * - * @var array + * @var array */ - protected $_options = []; + protected array $_options = []; /** * Constructor. @@ -52,14 +54,14 @@ class ExistsIn * Available option for $options is 'allowNullableNulls' flag. * Set to true to accept composite foreign keys where one or more nullable columns are null. * - * @param string|array $fields The field or fields to check existence as primary key. - * @param \Cake\Datasource\RepositoryInterface|\Cake\ORM\Association|string $repository The repository where the field will be looked for, - * or the association name for the repository. - * @param array $options The options that modify the rules behavior. + * @param array|string $fields The field or fields to check existence as primary key. + * @param \Cake\ORM\Table|\Cake\ORM\Association|string $repository The repository where the + * field will be looked for, or the association name for the repository. + * @param array $options The options that modify the rule's behavior. * Options 'allowNullableNulls' will make the rule pass if given foreign keys are set to `null`. * Notice: allowNullableNulls cannot pass by database columns set to `NOT NULL`. */ - public function __construct($fields, $repository, array $options = []) + public function __construct(array|string $fields, Table|Association|string $repository, array $options = []) { $options += ['allowNullableNulls' => false]; $this->_options = $options; @@ -72,37 +74,48 @@ public function __construct($fields, $repository, array $options = []) * Performs the existence check * * @param \Cake\Datasource\EntityInterface $entity The entity from where to extract the fields - * @param array $options Options passed to the check, + * @param array $options Options passed to the check, * where the `repository` key is required. - * @throws \RuntimeException When the rule refers to an undefined association. + * @throws \Cake\Database\Exception\DatabaseException When the rule refers to an undefined association. * @return bool */ - public function __invoke(EntityInterface $entity, array $options) + public function __invoke(EntityInterface $entity, array $options): bool { if (is_string($this->_repository)) { - $repository = $options['repository']->association($this->_repository); - if (!$repository) { - throw new RuntimeException(sprintf( - "ExistsIn rule for '%s' is invalid. '%s' is not associated with '%s'.", + /** @var \Cake\ORM\Table $table */ + $table = $options['repository']; + + if (!$table->hasAssociation($this->_repository)) { + throw new DatabaseException(sprintf( + 'ExistsIn rule for `%s` is invalid. `%s` is not associated with `%s`.', implode(', ', $this->_fields), $this->_repository, - get_class($options['repository']) + $options['repository']::class, )); } + $repository = $table->getAssociation($this->_repository); $this->_repository = $repository; } - $source = $target = $this->_repository; - $isAssociation = $target instanceof Association; - $bindingKey = $isAssociation ? (array)$target->getBindingKey() : (array)$target->getPrimaryKey(); - $realTarget = $isAssociation ? $target->getTarget() : $target; + $fields = $this->_fields; + $target = $this->_repository; + if ($target instanceof Association) { + $bindingKey = (array)$target->getBindingKey(); + $realTarget = $target->getTarget(); + } else { + $bindingKey = (array)$target->getPrimaryKey(); + $realTarget = $target; + } if (!empty($options['_sourceTable']) && $realTarget === $options['_sourceTable']) { return true; } if (!empty($options['repository'])) { + /** @var \Cake\ORM\Table $source */ $source = $options['repository']; + } else { + $source = $this->_repository; } if ($source instanceof Association) { $source = $source->getSource(); @@ -118,38 +131,38 @@ public function __invoke(EntityInterface $entity, array $options) if ($this->_options['allowNullableNulls']) { $schema = $source->getSchema(); - foreach ($this->_fields as $i => $field) { - if ($schema->getColumn($field) && $schema->isNullable($field) && $entity->get($field) === null) { - unset($bindingKey[$i], $this->_fields[$i]); + foreach ($fields as $i => $field) { + if ($schema->hasColumn($field) && $schema->isNullable($field) && $entity->get($field) === null) { + unset($bindingKey[$i], $fields[$i]); } } } $primary = array_map( - [$target, 'aliasField'], - $bindingKey + fn(string $key) => $target->aliasField($key) . ' IS', + $bindingKey, ); $conditions = array_combine( $primary, - $entity->extract($this->_fields) + $entity->extract($fields), ); return $target->exists($conditions); } /** - * Checks whether or not the given entity fields are nullable and null. + * Checks whether the given entity fields are nullable and null. * * @param \Cake\Datasource\EntityInterface $entity The entity to check. * @param \Cake\ORM\Table $source The table to use schema from. * @return bool */ - protected function _fieldsAreNull($entity, $source) + protected function _fieldsAreNull(EntityInterface $entity, Table $source): bool { $nulls = 0; $schema = $source->getSchema(); foreach ($this->_fields as $field) { - if ($schema->getColumn($field) && $schema->isNullable($field) && $entity->get($field) === null) { + if ($schema->hasColumn($field) && $schema->isNullable($field) && $entity->get($field) === null) { $nulls++; } } diff --git a/src/ORM/Rule/ExistsInNullable.php b/src/ORM/Rule/ExistsInNullable.php new file mode 100644 index 00000000000..7f89ff8a5c8 --- /dev/null +++ b/src/ORM/Rule/ExistsInNullable.php @@ -0,0 +1,42 @@ +|string $fields The field or fields to check existence as primary key. + * @param \Cake\ORM\Table|\Cake\ORM\Association|string $repository The repository where the + * field will be looked for, or the association name for the repository. + * @param array $options The options that modify the rule's behavior. + */ + public function __construct(array|string $fields, Table|Association|string $repository, array $options = []) + { + $options += ['allowNullableNulls' => true]; + parent::__construct($fields, $repository, $options); + } +} diff --git a/src/ORM/Rule/IsUnique.php b/src/ORM/Rule/IsUnique.php index 2d024ed1683..5c3d4ad421e 100644 --- a/src/ORM/Rule/IsUnique.php +++ b/src/ORM/Rule/IsUnique.php @@ -1,4 +1,6 @@ */ - protected $_fields; + protected array $_fields; /** - * The options to use. + * The unique check options * - * @var array + * @var array */ - protected $_options; + protected array $_options = [ + 'allowMultipleNulls' => true, + ]; /** * Constructor. * * ### Options * - * - `allowMultipleNulls` Set to false to disallow multiple null values in - * multi-column unique rules. By default this is `true` to emulate how SQL UNIQUE - * keys work. + * - `allowMultipleNulls` Allows any field to have multiple null values. Defaults to true. * - * @param array $fields The list of fields to check uniqueness for - * @param array $options The additional options for this rule. + * @param array $fields The list of fields to check uniqueness for + * @param array $options The options for unique checks. */ public function __construct(array $fields, array $options = []) { $this->_fields = $fields; - $this->_options = $options + ['allowMultipleNulls' => true]; + $this->_options = $options + $this->_options; } /** @@ -59,49 +61,48 @@ public function __construct(array $fields, array $options = []) * * @param \Cake\Datasource\EntityInterface $entity The entity from where to extract the fields * where the `repository` key is required. - * @param array $options Options passed to the check, + * @param array $options Options passed to the check, * @return bool */ - public function __invoke(EntityInterface $entity, array $options) + public function __invoke(EntityInterface $entity, array $options): bool { if (!$entity->extract($this->_fields, true)) { return true; } - $allowMultipleNulls = $this->_options['allowMultipleNulls']; - $alias = $options['repository']->getAlias(); - $conditions = $this->_alias($alias, $entity->extract($this->_fields), $allowMultipleNulls); + $fields = $entity->extract($this->_fields); + if ($this->_options['allowMultipleNulls'] && array_filter($fields, 'is_null')) { + return true; + } + + /** @var \Cake\ORM\Table $repository */ + $repository = $options['repository']; + + $alias = $repository->getAlias(); + $conditions = $this->_alias($alias, $fields); if ($entity->isNew() === false) { - $keys = (array)$options['repository']->getPrimaryKey(); - $keys = $this->_alias($alias, $entity->extract($keys), $allowMultipleNulls); - if (array_filter($keys, 'strlen')) { + $keys = (array)$repository->getPrimaryKey(); + $keys = $this->_alias($alias, $entity->extract($keys)); + if (Hash::filter($keys)) { $conditions['NOT'] = $keys; } } - return !$options['repository']->exists($conditions); + return !$repository->exists($conditions); } /** * Add a model alias to all the keys in a set of conditions. * - * Null values will be omitted from the generated conditions, - * as SQL UNIQUE indexes treat `NULL != NULL` - * * @param string $alias The alias to add. * @param array $conditions The conditions to alias. - * @param bool $multipleNulls Whether or not to allow multiple nulls. - * @return array + * @return array */ - protected function _alias($alias, $conditions, $multipleNulls) + protected function _alias(string $alias, array $conditions): array { $aliased = []; foreach ($conditions as $key => $value) { - if ($multipleNulls) { - $aliased["$alias.$key"] = $value; - } else { - $aliased["$alias.$key IS"] = $value; - } + $aliased["{$alias}.{$key} IS"] = $value; } return $aliased; diff --git a/src/ORM/Rule/LinkConstraint.php b/src/ORM/Rule/LinkConstraint.php new file mode 100644 index 00000000000..4b18e881323 --- /dev/null +++ b/src/ORM/Rule/LinkConstraint.php @@ -0,0 +1,188 @@ +_association = $association; + $this->_requiredLinkState = $requiredLinkStatus; + } + + /** + * Callable handler. + * + * Performs the actual link check. + * + * @param \Cake\Datasource\EntityInterface $entity The entity involved in the operation. + * @param array $options Options passed from the rules checker. + * @return bool Whether the check was successful. + */ + public function __invoke(EntityInterface $entity, array $options): bool + { + $table = $options['repository'] ?? null; + if (!($table instanceof Table)) { + throw new InvalidArgumentException( + 'Argument 2 is expected to have a `repository` key that holds an instance of `\Cake\ORM\Table`.', + ); + } + + $association = $this->_association; + if (!$association instanceof Association) { + $association = $table->getAssociation($association); + } + + $count = $this->_countLinks($association, $entity); + + if ( + ( + $this->_requiredLinkState === static::STATUS_LINKED && + $count < 1 + ) || + ( + $this->_requiredLinkState === static::STATUS_NOT_LINKED && + $count !== 0 + ) + ) { + return false; + } + + return true; + } + + /** + * Alias fields. + * + * @param array $fields The fields that should be aliased. + * @param \Cake\ORM\Table $source The object to use for aliasing. + * @return array The aliased fields + */ + protected function _aliasFields(array $fields, Table $source): array + { + foreach ($fields as $key => $value) { + $fields[$key] = $source->aliasField($value); + } + + return $fields; + } + + /** + * Build conditions. + * + * @param array $fields The condition fields. + * @param array $values The condition values. + * @return array A conditions array combined from the passed fields and values. + */ + protected function _buildConditions(array $fields, array $values): array + { + if (count($fields) !== count($values)) { + throw new InvalidArgumentException(sprintf( + 'The number of fields is expected to match the number of values, got %d field(s) and %d value(s).', + count($fields), + count($values), + )); + } + + return array_combine($fields, $values); + } + + /** + * Count links. + * + * @param \Cake\ORM\Association $association The association for which to count links. + * @param \Cake\Datasource\EntityInterface $entity The entity involved in the operation. + * @return int The number of links. + */ + protected function _countLinks(Association $association, EntityInterface $entity): int + { + $source = $association->getSource(); + + $primaryKey = (array)$source->getPrimaryKey(); + if (!$entity->has($primaryKey)) { + throw new DatabaseException(sprintf( + 'LinkConstraint rule on `%s` requires all primary key values for building the counting ' . + 'conditions, expected values for `(%s)`, got `(%s)`.', + $source->getAlias(), + implode(', ', $primaryKey), + implode(', ', $entity->extract($primaryKey)), + )); + } + + $aliasedPrimaryKey = $this->_aliasFields($primaryKey, $source); + $conditions = $this->_buildConditions( + $aliasedPrimaryKey, + $entity->extract($primaryKey), + ); + + return $source + ->find() + ->matching($association->getName()) + ->where($conditions) + ->count(); + } +} diff --git a/src/ORM/Rule/ValidCount.php b/src/ORM/Rule/ValidCount.php index 33a98081251..06722848a2f 100644 --- a/src/ORM/Rule/ValidCount.php +++ b/src/ORM/Rule/ValidCount.php @@ -1,4 +1,6 @@ _field = $field; } @@ -45,10 +46,10 @@ public function __construct($field) * Performs the count check * * @param \Cake\Datasource\EntityInterface $entity The entity from where to extract the fields. - * @param array $options Options passed to the check. + * @param array $options Options passed to the check. * @return bool True if successful, else false. */ - public function __invoke(EntityInterface $entity, array $options) + public function __invoke(EntityInterface $entity, array $options): bool { $value = $entity->{$this->_field}; if (!is_array($value) && !$value instanceof Countable) { diff --git a/src/ORM/RulesChecker.php b/src/ORM/RulesChecker.php index feea8c89836..b41553f7c32 100644 --- a/src/ORM/RulesChecker.php +++ b/src/ORM/RulesChecker.php @@ -1,4 +1,6 @@ add($rules->isUnique(['email'], 'The email should be unique')); * ``` * - * @param array $fields The list of fields to check for uniqueness. - * @param string|array|null $message The error message to show in case the rule does not pass. Can + * ### Options + * + * - `allowMultipleNulls` Allows any field to have multiple null values. Defaults to false. + * + * @param array $fields The list of fields to check for uniqueness. + * @param array|string|null $message The error message to show in case the rule does not pass. Can * also be an array of options. When an array, the 'message' key can be used to provide a message. - * @return callable + * @return \Cake\Datasource\RuleInvoker */ - public function isUnique(array $fields, $message = null) + public function isUnique(array $fields, array|string|null $message = null): RuleInvoker { - $options = []; - if (is_array($message)) { - $options = $message + ['message' => null]; - $message = $options['message']; - unset($options['message']); - } + $options = is_array($message) ? $message : ['message' => $message]; + $message = $options['message'] ?? null; + unset($options['message']); + if (!$message) { if ($this->_useI18n) { $message = __d('cake', 'This value is already in use'); @@ -83,15 +91,19 @@ public function isUnique(array $fields, $message = null) * 'message' sets a custom error message. * Set 'allowNullableNulls' to true to accept composite foreign keys where one or more nullable columns are null. * - * @param string|array $field The field or list of fields to check for existence by - * primary key lookup in the other table. - * @param object|string $table The table name where the fields existence will be checked. - * @param string|array|null $message The error message to show in case the rule does not pass. Can + * @param array|string $field The field or list of fields to check for existence by + * primary key lookup in the other table. + * @param \Cake\ORM\Table|\Cake\ORM\Association|string $table The table object or association name for the table + * where the fields existence will be checked. + * @param array|string|null $message The error message to show in case the rule does not pass. Can * also be an array of options. When an array, the 'message' key can be used to provide a message. - * @return callable + * @return \Cake\Datasource\RuleInvoker */ - public function existsIn($field, $table, $message = null) - { + public function existsIn( + array|string $field, + Table|Association|string $table, + array|string|null $message = null, + ): RuleInvoker { $options = []; if (is_array($message)) { $options = $message + ['message' => null]; @@ -112,6 +124,192 @@ public function existsIn($field, $table, $message = null) return $this->_addError(new ExistsIn($field, $table, $options), '_existsIn', compact('errorField', 'message')); } + /** + * Returns a callable that can be used as a rule for checking that the value provided in a + * field exists as the primary key of another table. Accepts composite foreign keys where + * one or more nullable columns are null. + * + * This is a convenience wrapper around `ExistsIn` with `allowNullableNulls` set to `true` by default. + * + * ### Example: + * + * ``` + * $rules->add($rules->existsInNullable(['author_id', 'site_id'], 'SiteAuthors')); + * ``` + * + * This is equivalent to: + * + * ``` + * $rules->add($rules->existsIn(['author_id', 'site_id'], 'SiteAuthors', ['allowNullableNulls' => true])); + * ``` + * + * @param array|string $field The field or fields to check existence as primary key. + * @param \Cake\ORM\Table|\Cake\ORM\Association|string $table The table object or association name for the table + * where the fields existence will be checked. + * @param array|string|null $message The error message to show in case the rule does not pass. Can + * also be an array of options. When an array, the 'message' key can be used to provide a message. + * @return \Cake\Datasource\RuleInvoker + * @since 5.3.0 + */ + public function existsInNullable( + array|string $field, + Table|Association|string $table, + array|string|null $message = null, + ): RuleInvoker { + $options = []; + if (is_array($message)) { + $options = $message + ['message' => null]; + $message = $options['message']; + unset($options['message']); + } + + if (!$message) { + if ($this->_useI18n) { + $message = __d('cake', 'This value does not exist'); + } else { + $message = 'This value does not exist'; + } + } + + $errorField = is_string($field) ? $field : current($field); + + return $this->_addError( + new ExistsInNullable($field, $table, $options), + '_existsIn', + compact('errorField', 'message'), + ); + } + + /** + * Validates whether links to the given association exist. + * + * ### Example: + * + * ``` + * $rules->addUpdate($rules->isLinkedTo('Articles', 'article')); + * ``` + * + * On a `Comments` table that has a `belongsTo Articles` association, this check would ensure that comments + * can only be edited as long as they are associated to an existing article. + * + * @param \Cake\ORM\Association|string $association The association to check for links. + * @param string|null $field The name of the association property. When supplied, this is the name used to set + * possible errors. When absent, the name is inferred from `$association`. + * @param string|null $message The error message to show in case the rule does not pass. + * @return \Cake\Datasource\RuleInvoker + * @since 4.0.0 + */ + public function isLinkedTo( + Association|string $association, + ?string $field = null, + ?string $message = null, + ): RuleInvoker { + return $this->_addLinkConstraintRule( + $association, + $field, + $message, + LinkConstraint::STATUS_LINKED, + '_isLinkedTo', + ); + } + + /** + * Validates whether links to the given association do not exist. + * + * ### Example: + * + * ``` + * $rules->addDelete($rules->isNotLinkedTo('Comments', 'comments')); + * ``` + * + * On a `Articles` table that has a `hasMany Comments` association, this check would ensure that articles + * can only be deleted when no associated comments exist. + * + * @param \Cake\ORM\Association|string $association The association to check for links. + * @param string|null $field The name of the association property. When supplied, this is the name used to set + * possible errors. When absent, the name is inferred from `$association`. + * @param string|null $message The error message to show in case the rule does not pass. + * @return \Cake\Datasource\RuleInvoker + * @since 4.0.0 + */ + public function isNotLinkedTo( + Association|string $association, + ?string $field = null, + ?string $message = null, + ): RuleInvoker { + return $this->_addLinkConstraintRule( + $association, + $field, + $message, + LinkConstraint::STATUS_NOT_LINKED, + '_isNotLinkedTo', + ); + } + + /** + * Adds a link constraint rule. + * + * @param \Cake\ORM\Association|string $association The association to check for links. + * @param string|null $errorField The name of the property to use for setting possible errors. When absent, + * the name is inferred from `$association`. + * @param string|null $message The error message to show in case the rule does not pass. + * @param string $linkStatus The link status required for the check to pass. + * @param string $ruleName The alias/name of the rule. + * @return \Cake\Datasource\RuleInvoker + * @throws \InvalidArgumentException In case the `$association` argument is of an invalid type. + * @since 4.0.0 + * @see \Cake\ORM\RulesChecker::isLinkedTo() + * @see \Cake\ORM\RulesChecker::isNotLinkedTo() + * @see \Cake\ORM\Rule\LinkConstraint::STATUS_LINKED + * @see \Cake\ORM\Rule\LinkConstraint::STATUS_NOT_LINKED + */ + protected function _addLinkConstraintRule( + Association|string $association, + ?string $errorField, + ?string $message, + string $linkStatus, + string $ruleName, + ): RuleInvoker { + if ($association instanceof Association) { + $associationAlias = $association->getName(); + $errorField ??= $association->getProperty(); + } else { + $associationAlias = $association; + + if ($errorField === null) { + $repository = $this->_options['repository'] ?? null; + if ($repository instanceof Table) { + $association = $repository->getAssociation($association); + $errorField = $association->getProperty(); + } else { + $errorField = Inflector::underscore($association); + } + } + } + + if (!$message) { + if ($this->_useI18n) { + $message = __d( + 'cake', + 'Cannot modify row: a constraint for the `{0}` association fails.', + $associationAlias, + ); + } else { + $message = sprintf( + 'Cannot modify row: a constraint for the `%s` association fails.', + $associationAlias, + ); + } + } + + $rule = new LinkConstraint( + $association, + $linkStatus, + ); + + return $this->_addError($rule, $ruleName, compact('errorField', 'message')); + } + /** * Validates the count of associated records. * @@ -119,10 +317,14 @@ public function existsIn($field, $table, $message = null) * @param int $count The expected count. * @param string $operator The operator for the count comparison. * @param string|null $message The error message to show in case the rule does not pass. - * @return callable + * @return \Cake\Datasource\RuleInvoker */ - public function validCount($field, $count = 0, $operator = '>', $message = null) - { + public function validCount( + string $field, + int $count = 0, + string $operator = '>', + ?string $message = null, + ): RuleInvoker { if (!$message) { if ($this->_useI18n) { $message = __d('cake', 'The count does not match {0}{1}', [$operator, $count]); @@ -136,7 +338,7 @@ public function validCount($field, $count = 0, $operator = '>', $message = null) return $this->_addError( new ValidCount($field), '_validCount', - compact('count', 'operator', 'errorField', 'message') + compact('count', 'operator', 'errorField', 'message'), ); } } diff --git a/src/ORM/SaveOptionsBuilder.php b/src/ORM/SaveOptionsBuilder.php deleted file mode 100644 index 97e1ed2fe9c..00000000000 --- a/src/ORM/SaveOptionsBuilder.php +++ /dev/null @@ -1,221 +0,0 @@ -_table = $table; - $this->parseArrayOptions($options); - - parent::__construct(); - } - - /** - * Takes an options array and populates the option object with the data. - * - * This can be used to turn an options array into the object. - * - * @throws \InvalidArgumentException If a given option key does not exist. - * @param array $array Options array. - * @return \Cake\ORM\SaveOptionsBuilder - */ - public function parseArrayOptions($array) - { - foreach ($array as $key => $value) { - $this->{$key}($value); - } - - return $this; - } - - /** - * Set associated options. - * - * @param string|array $associated String or array of associations. - * @return \Cake\ORM\SaveOptionsBuilder - */ - public function associated($associated) - { - $associated = $this->_normalizeAssociations($associated); - $this->_associated($this->_table, $associated); - $this->_options['associated'] = $associated; - - return $this; - } - - /** - * Checks that the associations exists recursively. - * - * @param \Cake\ORM\Table $table Table object. - * @param array $associations An associations array. - * @return void - */ - protected function _associated(Table $table, array $associations) - { - foreach ($associations as $key => $associated) { - if (is_int($key)) { - $this->_checkAssociation($table, $associated); - continue; - } - $this->_checkAssociation($table, $key); - if (isset($associated['associated'])) { - $this->_associated($table->association($key)->getTarget(), $associated['associated']); - continue; - } - } - } - - /** - * Checks if an association exists. - * - * @throws \RuntimeException If no such association exists for the given table. - * @param \Cake\ORM\Table $table Table object. - * @param string $association Association name. - * @return void - */ - protected function _checkAssociation(Table $table, $association) - { - if (!$table->associations()->has($association)) { - throw new RuntimeException(sprintf('Table `%s` is not associated with `%s`', get_class($table), $association)); - } - } - - /** - * Set the guard option. - * - * @param bool $guard Guard the properties or not. - * @return \Cake\ORM\SaveOptionsBuilder - */ - public function guard($guard) - { - $this->_options['guard'] = (bool)$guard; - - return $this; - } - - /** - * Set the validation rule set to use. - * - * @param string $validate Name of the validation rule set to use. - * @return \Cake\ORM\SaveOptionsBuilder - */ - public function validate($validate) - { - $this->_table->getValidator($validate); - $this->_options['validate'] = $validate; - - return $this; - } - - /** - * Set check existing option. - * - * @param bool $checkExisting Guard the properties or not. - * @return \Cake\ORM\SaveOptionsBuilder - */ - public function checkExisting($checkExisting) - { - $this->_options['checkExisting'] = (bool)$checkExisting; - - return $this; - } - - /** - * Option to check the rules. - * - * @param bool $checkRules Check the rules or not. - * @return \Cake\ORM\SaveOptionsBuilder - */ - public function checkRules($checkRules) - { - $this->_options['checkRules'] = (bool)$checkRules; - - return $this; - } - - /** - * Sets the atomic option. - * - * @param bool $atomic Atomic or not. - * @return \Cake\ORM\SaveOptionsBuilder - */ - public function atomic($atomic) - { - $this->_options['atomic'] = (bool)$atomic; - - return $this; - } - - /** - * @return array - */ - public function toArray() - { - return $this->_options; - } - - /** - * Setting custom options. - * - * @param string $option Option key. - * @param mixed $value Option value. - * @return \Cake\ORM\SaveOptionsBuilder - */ - public function set($option, $value) - { - if (method_exists($this, $option)) { - return $this->{$option}($value); - } - $this->_options[$option] = $value; - - return $this; - } -} diff --git a/src/ORM/Table.php b/src/ORM/Table.php index fd422266bf3..3a77392c7d8 100644 --- a/src/ORM/Table.php +++ b/src/ORM/Table.php @@ -1,4 +1,6 @@ = array{} + * @template TEntity of \Cake\Datasource\EntityInterface = \Cake\Datasource\EntityInterface */ class Table implements RepositoryInterface, EventListenerInterface, EventDispatcherInterface, ValidatorAwareInterface { - use EventDispatcherTrait; use RulesAwareTrait; use ValidatorAwareTrait; + /** + * Name of default validation set. + * + * @var string + */ + public const DEFAULT_VALIDATOR = 'default'; + /** * The alias this object is assigned to validators as. * * @var string */ - const VALIDATOR_PROVIDER_NAME = 'table'; + public const VALIDATOR_PROVIDER_NAME = 'table'; /** * The name of the event dispatched when a validator has been built. * * @var string */ - const BUILD_VALIDATOR_EVENT = 'Model.buildValidator'; + public const BUILD_VALIDATOR_EVENT = 'Model.buildValidator'; /** * The rules class name that is used. * - * @var string + * @var class-string<\Cake\ORM\RulesChecker> */ - const RULES_CLASS = 'Cake\ORM\RulesChecker'; + public const RULES_CLASS = RulesChecker::class; + + /** + * The IsUnique class name that is used. + * + * @var class-string<\Cake\ORM\Rule\IsUnique> + */ + public const IS_UNIQUE_CLASS = IsUnique::class; /** * Name of the table as it can be found in the database * - * @var string + * @var string|null */ - protected $_table; + protected ?string $_table = null; /** * Human name giving to this particular instance. Multiple objects representing * the same database table can exist by using different aliases. * - * @var string + * @var string|null */ - protected $_alias; + protected ?string $_alias = null; /** * Connection instance * - * @var \Cake\Database\Connection + * @var \Cake\Database\Connection|null */ - protected $_connection; + protected ?Connection $_connection = null; /** * The schema object containing a description of this table fields * - * @var \Cake\Database\Schema\TableSchema + * @var \Cake\Database\Schema\TableSchemaInterface|null */ - protected $_schema; + protected ?TableSchemaInterface $_schema = null; /** * The name of the field that represents the primary key in the table * - * @var string|array + * @var array|string|null */ - protected $_primaryKey; + protected array|string|null $_primaryKey = null; /** - * The name of the field that represents a human readable representation of a row + * The name of the field that represents a human-readable representation of a row * - * @var string + * @var array|string|null */ - protected $_displayField; + protected array|string|null $_displayField = null; /** * The associations container for this Table. * * @var \Cake\ORM\AssociationCollection */ - protected $_associations; + protected AssociationCollection $_associations; /** * BehaviorRegistry for this table * * @var \Cake\ORM\BehaviorRegistry */ - protected $_behaviors; + protected BehaviorRegistry $_behaviors; /** * The name of the class that represent a single row for this table * - * @var string + * @var class-string|null */ - protected $_entityClass; + protected ?string $_entityClass = null; + + /** + * Whether to assert that entities passed to save/delete/patch/loadInto + * match the table's configured entity class. Disable per table via + * {@see Table::disableEntityClassAssertion()} when foreign entities are + * passed intentionally. + * + * @var bool + */ + protected bool $assertEntityClass = true; /** * Registry key used to create this table object * - * @var string + * @var string|null */ - protected $_registryAlias; + protected ?string $_registryAlias = null; + + protected QueryFactory $queryFactory; /** * Initializes a new instance @@ -233,7 +293,7 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc * - connection: The connection instance to use * - entityClass: The fully namespaced class name of the entity class that will * represent rows in this table. - * - schema: A \Cake\Database\Schema\TableSchema object or an array that can be + * - schema: A \Cake\Database\Schema\TableSchemaInterface object or an array that can be * passed to it. * - eventManager: An instance of an event manager to use for internal events * - behaviors: A BehaviorRegistry. Generally not used outside of tests. @@ -242,54 +302,41 @@ class Table implements RepositoryInterface, EventListenerInterface, EventDispatc * validation set, or an associative array, where key is the name of the * validation set and value the Validator instance. * - * @param array $config List of options for this table + * @param array $config List of options for this table. */ public function __construct(array $config = []) { - if (!empty($config['registryAlias'])) { - $this->setRegistryAlias($config['registryAlias']); - } - if (!empty($config['table'])) { - $this->setTable($config['table']); - } - if (!empty($config['alias'])) { - $this->setAlias($config['alias']); - } - if (!empty($config['connection'])) { - $this->setConnection($config['connection']); - } - if (!empty($config['schema'])) { - $this->setSchema($config['schema']); - } - if (!empty($config['entityClass'])) { - $this->setEntityClass($config['entityClass']); - } - $eventManager = $behaviors = $associations = null; - if (!empty($config['eventManager'])) { - $eventManager = $config['eventManager']; - } - if (!empty($config['behaviors'])) { - $behaviors = $config['behaviors']; - } - if (!empty($config['associations'])) { - $associations = $config['associations']; + $methodConfigs = [ + 'registryAlias', + 'table', + 'alias', + 'connection', + 'schema', + 'entityClass', + ]; + foreach ($methodConfigs as $cfg) { + if (isset($config[$cfg])) { + $this->{'set' . $cfg}($config[$cfg]); + } } - if (!empty($config['validator'])) { - if (!is_array($config['validator'])) { - $this->setValidator(static::DEFAULT_VALIDATOR, $config['validator']); - } else { + if (isset($config['validator'])) { + if (is_array($config['validator'])) { foreach ($config['validator'] as $name => $validator) { $this->setValidator($name, $validator); } + } else { + $this->setValidator(static::DEFAULT_VALIDATOR, $config['validator']); } } - $this->_eventManager = $eventManager ?: new EventManager(); - $this->_behaviors = $behaviors ?: new BehaviorRegistry(); + $this->_eventManager = $config['eventManager'] ?? new EventManager(); + $this->_behaviors = $config['behaviors'] ?? new BehaviorRegistry(); $this->_behaviors->setTable($this); - $this->_associations = $associations ?: new AssociationCollection(); + $this->_associations = $config['associations'] ?? new AssociationCollection(); + $this->queryFactory = $config['queryFactory'] ?? new QueryFactory(); $this->initialize($config); - $this->_eventManager->on($this); + + $this->getEventManager()->on($this); $this->dispatchEvent('Model.initialize'); } @@ -297,12 +344,12 @@ public function __construct(array $config = []) * Get the default connection name. * * This method is used to get the fallback connection name if an - * instance is created through the TableRegistry without a connection. + * instance is created through the TableLocator without a connection. * * @return string - * @see \Cake\ORM\TableRegistry::get() + * @see \Cake\ORM\Locator\TableLocator::get() */ - public static function defaultConnectionName() + public static function defaultConnectionName(): string { return 'default'; } @@ -322,20 +369,23 @@ public static function defaultConnectionName() * } * ``` * - * @param array $config Configuration options passed to the constructor + * @param array $config Configuration options passed to the constructor * @return void */ - public function initialize(array $config) + public function initialize(array $config): void { } /** * Sets the database table name. * + * This can include the database schema name in the form 'schema.table'. + * If the name must be quoted, enable automatic identifier quoting. + * * @param string $table Table name. * @return $this */ - public function setTable($table) + public function setTable(string $table) { $this->_table = $table; @@ -345,15 +395,19 @@ public function setTable($table) /** * Returns the database table name. * + * This can include the database schema name if set using `setTable()`. + * * @return string */ - public function getTable() + public function getTable(): string { if ($this->_table === null) { - $table = namespaceSplit(get_class($this)); - $table = substr(end($table), 0, -5); + $table = namespaceSplit(static::class); + $table = substr((string)end($table), 0, -5) ?: $this->_alias; if (!$table) { - $table = $this->getAlias(); + throw new CakeException( + 'You must specify either the `alias` or the `table` option for the constructor.', + ); } $this->_table = Inflector::underscore($table); } @@ -361,29 +415,13 @@ public function getTable() return $this->_table; } - /** - * Returns the database table name or sets a new one. - * - * @deprecated 3.4.0 Use setTable()/getTable() instead. - * @param string|null $table the new table name - * @return string - */ - public function table($table = null) - { - if ($table !== null) { - $this->setTable($table); - } - - return $this->getTable(); - } - /** * Sets the table alias. * * @param string $alias Table alias * @return $this */ - public function setAlias($alias) + public function setAlias(string $alias) { $this->_alias = $alias; @@ -395,41 +433,33 @@ public function setAlias($alias) * * @return string */ - public function getAlias() + public function getAlias(): string { if ($this->_alias === null) { - $alias = namespaceSplit(get_class($this)); - $alias = substr(end($alias), 0, -5) ?: $this->_table; + $alias = namespaceSplit(static::class); + $alias = substr((string)end($alias), 0, -5) ?: $this->_table; + if (!$alias) { + throw new CakeException( + 'You must specify either the `alias` or the `table` option for the constructor.', + ); + } $this->_alias = $alias; } return $this->_alias; } - /** - * {@inheritDoc} - * @deprecated 3.4.0 Use setAlias()/getAlias() instead. - */ - public function alias($alias = null) - { - if ($alias !== null) { - $this->setAlias($alias); - } - - return $this->getAlias(); - } - /** * Alias a field with the table's current alias. * - * If field is already aliased it will result in no-op. + * If field is already aliased, it will result in no-op. * * @param string $field The field to alias. * @return string The field prefixed with the table alias. */ - public function aliasField($field) + public function aliasField(string $field): string { - if (strpos($field, '.') !== false) { + if (str_contains($field, '.')) { return $field; } @@ -442,7 +472,7 @@ public function aliasField($field) * @param string $registryAlias The key used to access this object. * @return $this */ - public function setRegistryAlias($registryAlias) + public function setRegistryAlias(string $registryAlias) { $this->_registryAlias = $registryAlias; @@ -454,38 +484,18 @@ public function setRegistryAlias($registryAlias) * * @return string */ - public function getRegistryAlias() - { - if ($this->_registryAlias === null) { - $this->_registryAlias = $this->getAlias(); - } - - return $this->_registryAlias; - } - - /** - * Returns the table registry key used to create this table instance or sets one. - * - * @deprecated 3.4.0 Use setRegistryAlias()/getRegistryAlias() instead. - * @param string|null $registryAlias the key used to access this object - * @return string - */ - public function registryAlias($registryAlias = null) + public function getRegistryAlias(): string { - if ($registryAlias !== null) { - $this->setRegistryAlias($registryAlias); - } - - return $this->getRegistryAlias(); + return $this->_registryAlias ??= $this->getAlias(); } /** * Sets the connection instance. * - * @param \Cake\Database\Connection|\Cake\Datasource\ConnectionInterface $connection The connection instance + * @param \Cake\Database\Connection $connection The connection instance * @return $this */ - public function setConnection(ConnectionInterface $connection) + public function setConnection(Connection $connection) { $this->_connection = $connection; @@ -497,55 +507,47 @@ public function setConnection(ConnectionInterface $connection) * * @return \Cake\Database\Connection */ - public function getConnection() + public function getConnection(): Connection { - return $this->_connection; - } - - /** - * Returns the connection instance or sets a new one - * - * @deprecated 3.4.0 Use setConnection()/getConnection() instead. - * @param \Cake\Datasource\ConnectionInterface|null $connection The new connection instance - * @return \Cake\Datasource\ConnectionInterface - */ - public function connection(ConnectionInterface $connection = null) - { - if ($connection !== null) { - $this->setConnection($connection); + if (!$this->_connection) { + $connection = ConnectionManager::get(static::defaultConnectionName()); + assert($connection instanceof Connection); + $this->_connection = $connection; } - return $this->getConnection(); + return $this->_connection; } /** * Returns the schema table object describing this table's properties. * - * @return \Cake\Database\Schema\TableSchema + * @return \Cake\Database\Schema\TableSchemaInterface */ - public function getSchema() + public function getSchema(): TableSchemaInterface { if ($this->_schema === null) { - $this->_schema = $this->_initializeSchema( - $this->getConnection() - ->getSchemaCollection() - ->describe($this->getTable()) - ); + $this->_schema = $this->getConnection() + ->getSchemaCollection() + ->describe($this->getTable()); + if (Configure::read('debug')) { + $this->checkAliasLengths(); + } } + /** @var \Cake\Database\Schema\TableSchemaInterface */ return $this->_schema; } /** * Sets the schema table object describing this table's properties. * - * If an array is passed, a new TableSchema will be constructed + * If an array is passed, a new TableSchemaInterface will be constructed * out of it and used as the schema for this table. * - * @param array|\Cake\Database\Schema\TableSchema $schema Schema to be used for this table + * @param \Cake\Database\Schema\TableSchemaInterface|array $schema Schema to be used for this table * @return $this */ - public function setSchema($schema) + public function setSchema(TableSchemaInterface|array $schema) { if (is_array($schema)) { $constraints = []; @@ -555,7 +557,7 @@ public function setSchema($schema) unset($schema['_constraints']); } - $schema = new TableSchema($this->getTable(), $schema); + $schema = $this->getConnection()->getWriteDriver()->newTableSchema($this->getTable(), $schema); foreach ($constraints as $name => $value) { $schema->addConstraint($name, $value); @@ -563,54 +565,46 @@ public function setSchema($schema) } $this->_schema = $schema; + if (Configure::read('debug')) { + $this->checkAliasLengths(); + } return $this; } /** - * Returns the schema table object describing this table's properties. + * Checks if all table name + column name combinations used for + * queries fit into the max length allowed by database driver. * - * If a TableSchema is passed, it will be used for this table - * instead of the default one. - * - * If an array is passed, a new TableSchema will be constructed - * out of it and used as the schema for this table. - * - * @deprecated 3.4.0 Use setSchema()/getSchema() instead. - * @param array|\Cake\Database\Schema\TableSchema|null $schema New schema to be used for this table - * @return \Cake\Database\Schema\TableSchema + * @return void + * @throws \Cake\Database\Exception\DatabaseException When an alias combination is too long */ - public function schema($schema = null) + protected function checkAliasLengths(): void { - if ($schema !== null) { - $this->setSchema($schema); + if ($this->_schema === null) { + throw new DatabaseException(sprintf( + 'Unable to check max alias lengths for `%s` without schema.', + $this->getAlias(), + )); } - return $this->getSchema(); - } + $maxLength = $this->getConnection()->getWriteDriver()->getMaxAliasLength(); + if ($maxLength === null) { + return; + } - /** - * Override this function in order to alter the schema used by this table. - * This function is only called after fetching the schema out of the database. - * If you wish to provide your own schema to this table without touching the - * database, you can override schema() or inject the definitions though that - * method. - * - * ### Example: - * - * ``` - * protected function _initializeSchema(\Cake\Database\Schema\TableSchema $schema) { - * $schema->setColumnType('preferences', 'json'); - * return $schema; - * } - * ``` - * - * @param \Cake\Database\Schema\TableSchema $schema The table definition fetched from database. - * @return \Cake\Database\Schema\TableSchema the altered schema - */ - protected function _initializeSchema(TableSchema $schema) - { - return $schema; + $table = $this->getAlias(); + foreach ($this->_schema->columns() as $name) { + if (strlen($table . '__' . $name) > $maxLength) { + $nameLength = $maxLength - 2; + throw new DatabaseException( + 'ORM queries generate field aliases using the table name/alias and column name. ' . + "The table alias `{$table}` and column `{$name}` create an alias longer than ({$nameLength}). " . + 'You must change the table schema in the database and shorten either the table or column ' . + 'identifier so they fit within the database alias limits.', + ); + } + } } /** @@ -622,20 +616,18 @@ protected function _initializeSchema(TableSchema $schema) * @param string $field The field to check for. * @return bool True if the field exists, false if it does not. */ - public function hasField($field) + public function hasField(string $field): bool { - $schema = $this->getSchema(); - - return $schema->getColumn($field) !== null; + return $this->getSchema()->getColumn($field) !== null; } /** * Sets the primary key field name. * - * @param string|array $key Sets a new name to be used as primary key + * @param array|string $key Sets a new name to be used as primary key * @return $this */ - public function setPrimaryKey($key) + public function setPrimaryKey(array|string $key) { $this->_primaryKey = $key; @@ -645,12 +637,12 @@ public function setPrimaryKey($key) /** * Returns the primary key field name. * - * @return string|array + * @return array|string */ - public function getPrimaryKey() + public function getPrimaryKey(): array|string { if ($this->_primaryKey === null) { - $key = (array)$this->getSchema()->primaryKey(); + $key = $this->getSchema()->getPrimaryKey(); if (count($key) === 1) { $key = $key[0]; } @@ -660,31 +652,15 @@ public function getPrimaryKey() return $this->_primaryKey; } - /** - * Returns the primary key field name or sets a new one - * - * @deprecated 3.4.0 Use setPrimaryKey()/getPrimaryKey() instead. - * @param string|array|null $key Sets a new name to be used as primary key - * @return string|array - */ - public function primaryKey($key = null) - { - if ($key !== null) { - $this->setPrimaryKey($key); - } - - return $this->getPrimaryKey(); - } - /** * Sets the display field. * - * @param string $key Name to be used as display field. + * @param array|string $field Name to be used as display field. * @return $this */ - public function setDisplayField($key) + public function setDisplayField(array|string $field) { - $this->_displayField = $key; + $this->_displayField = $field; return $this; } @@ -692,63 +668,60 @@ public function setDisplayField($key) /** * Returns the display field. * - * @return string + * @return array|string|null */ - public function getDisplayField() + public function getDisplayField(): array|string|null { - if ($this->_displayField === null) { - $schema = $this->getSchema(); - $primary = (array)$this->getPrimaryKey(); - $this->_displayField = array_shift($primary); - if ($schema->getColumn('title')) { - $this->_displayField = 'title'; - } - if ($schema->getColumn('name')) { - $this->_displayField = 'name'; - } + if ($this->_displayField !== null) { + return $this->_displayField; } - return $this->_displayField; - } + $schema = $this->getSchema(); + foreach (['title', 'name', 'label'] as $field) { + if ($schema->hasColumn($field)) { + return $this->_displayField = $field; + } + } - /** - * Returns the display field or sets a new one - * - * @deprecated 3.4.0 Use setDisplayField()/getDisplayField() instead. - * @param string|null $key sets a new name to be used as display field - * @return string - */ - public function displayField($key = null) - { - if ($key !== null) { - return $this->setDisplayField($key); + foreach ($schema->columns() as $column) { + $columnSchema = $schema->getColumn($column); + if ( + $columnSchema && + $columnSchema['null'] !== true && + $columnSchema['type'] === 'string' && + !preg_match('/pass|token|secret/i', $column) + ) { + return $this->_displayField = $column; + } } - return $this->getDisplayField(); + return $this->_displayField = $this->getPrimaryKey(); } /** * Returns the class used to hydrate rows for this table. * - * @return string + * @return class-string */ - public function getEntityClass() + public function getEntityClass(): string { if (!$this->_entityClass) { - $default = '\Cake\ORM\Entity'; - $self = get_called_class(); + /** @var class-string $default */ + $default = Entity::class; + $self = static::class; $parts = explode('\\', $self); - if ($self === __CLASS__ || count($parts) < 3) { + if ($self === self::class || count($parts) < 3) { return $this->_entityClass = $default; } - $alias = Inflector::singularize(substr(array_pop($parts), 0, -5)); - $name = implode('\\', array_slice($parts, 0, -1)) . '\Entity\\' . $alias; + $alias = Inflector::classify(Inflector::underscore(substr(array_pop($parts), 0, -5))); + $name = implode('\\', array_slice($parts, 0, -1)) . '\\Entity\\' . $alias; if (!class_exists($name)) { return $this->_entityClass = $default; } + /** @var class-string|null $class */ $class = App::className($name, 'Model/Entity'); if (!$class) { throw new MissingEntityException([$name]); @@ -767,10 +740,11 @@ public function getEntityClass() * @throws \Cake\ORM\Exception\MissingEntityException when the entity class cannot be found * @return $this */ - public function setEntityClass($name) + public function setEntityClass(string $name) { + /** @var class-string|null $class */ $class = App::className($name, 'Model/Entity'); - if (!$class) { + if ($class === null) { throw new MissingEntityException([$name]); } @@ -780,21 +754,79 @@ public function setEntityClass($name) } /** - * Returns the class used to hydrate rows for this table or sets - * a new one + * Enables the assertion that entities passed to save/delete/patch/loadInto + * match the table's configured entity class. * - * @deprecated 3.4.0 Use setEntityClass()/getEntityClass() instead. - * @param string|null $name The name of the class to use - * @throws \Cake\ORM\Exception\MissingEntityException when the entity class cannot be found - * @return string + * @param bool $enable Whether to enable. Defaults to true. + * @return $this */ - public function entityClass($name = null) + public function enableEntityClassAssertion(bool $enable = true) { - if ($name !== null) { - $this->setEntityClass($name); + $this->assertEntityClass = $enable; + + return $this; + } + + /** + * Disables the entity-class assertion for this table. Use when foreign + * entities are passed intentionally (e.g. polymorphic patterns). + * + * @return $this + */ + public function disableEntityClassAssertion() + { + $this->assertEntityClass = false; + + return $this; + } + + /** + * Returns whether the entity-class assertion is enabled for this table. + * + * @return bool + */ + public function isEntityClassAssertionEnabled(): bool + { + return $this->assertEntityClass; + } + + /** + * Asserts that the given entity belongs to this table instance. + * + * The entity must either be an instance of the table's configured entity + * class, or an instance of the generic ``\Cake\ORM\Entity`` class. The + * generic class is allowed as an escape hatch for ad-hoc usage such as + * ``$table->delete(new Entity(['id' => 1]))``. + * + * Catches mistakes like ``$this->Invoices->delete($orderEntity)`` where + * an entity from a different table is passed. + * + * @param \Cake\Datasource\EntityInterface $entity The entity to validate. + * @return void + * @throws \InvalidArgumentException When the entity does not match the + * configured entity class. + */ + protected function assertEntityClass(EntityInterface $entity): void + { + if (!$this->assertEntityClass) { + return; + } + + if ($entity->getSource() === $this->getRegistryAlias()) { + return; + } + + $entityClass = $this->getEntityClass(); + if ($entity instanceof $entityClass || $entity::class === Entity::class) { + return; } - return $this->getEntityClass(); + throw new InvalidArgumentException(sprintf( + 'Entity of class `%s` does not match the entity class `%s` configured for table `%s`.', + $entity::class, + $entityClass, + $this->getRegistryAlias(), + )); } /** @@ -816,18 +848,48 @@ public function entityClass($name = null) * Behaviors are generally loaded during Table::initialize(). * * @param string $name The name of the behavior. Can be a short class reference. - * @param array $options The options for the behavior to use. + * @param array $options The options for the behavior to use. * @return $this * @throws \RuntimeException If a behavior is being reloaded. * @see \Cake\ORM\Behavior */ - public function addBehavior($name, array $options = []) + public function addBehavior(string $name, array $options = []) { $this->_behaviors->load($name, $options); return $this; } + /** + * Adds an array of behaviors to the table's behavior collection. + * + * Example: + * + * ``` + * $this->addBehaviors([ + * 'Timestamp', + * 'Tree' => ['level' => 'level'], + * ]); + * ``` + * + * @param array $behaviors All the behaviors to load. + * @return $this + * @throws \RuntimeException If a behavior is being reloaded. + */ + public function addBehaviors(array $behaviors) + { + foreach ($behaviors as $name => $options) { + if (is_int($name)) { + $name = $options; + $options = []; + } + + $this->addBehavior($name, $options); + } + + return $this; + } + /** * Removes a behavior from this table's behavior registry. * @@ -843,7 +905,7 @@ public function addBehavior($name, array $options = []) * @return $this * @see \Cake\ORM\Behavior */ - public function removeBehavior($name) + public function removeBehavior(string $name) { $this->_behaviors->unload($name); @@ -855,39 +917,131 @@ public function removeBehavior($name) * * @return \Cake\ORM\BehaviorRegistry The BehaviorRegistry instance. */ - public function behaviors() + public function behaviors(): BehaviorRegistry { return $this->_behaviors; } + /** + * Get a behavior from the registry. + * + * @param TName $name The behavior alias to get from the registry. + * @return (TName is key-of ? TBehaviors[TName] : \Cake\ORM\Behavior) + * @template TName of string + * @throws \InvalidArgumentException If the behavior does not exist. + */ + public function getBehavior(string $name): Behavior + { + if (!$this->_behaviors->has($name)) { + throw new InvalidArgumentException(sprintf( + 'The `%s` behavior is not defined on `%s`.', + $name, + static::class, + )); + } + + /** @var \Cake\ORM\Behavior */ + return $this->_behaviors->get($name); + } + /** * Check if a behavior with the given alias has been loaded. * * @param string $name The behavior alias to check. - * @return bool Whether or not the behavior exists. + * @return bool Whether the behavior exists. */ - public function hasBehavior($name) + public function hasBehavior(string $name): bool { return $this->_behaviors->has($name); } /** - * Returns an association object configured for the specified alias if any + * Returns an association object configured for the specified alias. + * + * The name argument also supports dot syntax to access deeper associations. + * + * ``` + * $users = $this->getAssociation('Articles.Comments.Users'); + * ``` + * + * Note that this method requires the association to be present or otherwise + * throws an exception. + * If you are not sure, use hasAssociation() before calling this method. + * + * @param string $name The alias used for the association. + * @return \Cake\ORM\Association The association. + * @throws \InvalidArgumentException + */ + public function getAssociation(string $name): Association + { + $association = $this->findAssociation($name); + if (!$association) { + $associations = $this->associations()->keys(); + + $message = "The `{$name}` association is not defined on `{$this->getAlias()}`."; + if ($associations) { + $message .= "\nValid associations are: " . implode(', ', $associations); + } + throw new InvalidArgumentException($message); + } + + return $association; + } + + /** + * Checks whether a specific association exists on this Table instance. + * + * The name argument also supports dot syntax to access deeper associations. + * + * ``` + * $hasUsers = $this->hasAssociation('Articles.Comments.Users'); + * ``` + * + * @param string $name The alias used for the association. + * @return bool + */ + public function hasAssociation(string $name): bool + { + return $this->findAssociation($name) !== null; + } + + /** + * Returns an association object configured for the specified alias if any. + * + * The name argument also supports dot syntax to access deeper associations. + * + * ``` + * $users = $this->getAssociation('Articles.Comments.Users'); + * ``` * - * @param string $name the alias used for the association. + * @param string $name The alias used for the association. * @return \Cake\ORM\Association|null Either the association or null. */ - public function association($name) + protected function findAssociation(string $name): ?Association { - return $this->_associations->get($name); + if (!str_contains($name, '.')) { + return $this->_associations->get($name); + } + + $result = null; + [$name, $next] = array_pad(explode('.', $name, 2), 2, null); + if ($name !== null) { + $result = $this->_associations->get($name); + } + + if ($result !== null && $next !== null) { + return $result->getTarget()->getAssociation($next); + } + + return $result; } /** * Get the associations collection for this table. * - * @return \Cake\ORM\AssociationCollection|\Cake\ORM\Association[] The collection of association objects. + * @return \Cake\ORM\AssociationCollection The collection of association objects. */ - public function associations() + public function associations(): AssociationCollection { return $this->_associations; } @@ -912,7 +1066,7 @@ public function associations() * are the aliases, and the values are association config data. If numeric * keys are used the values will be treated as association aliases. * - * @param array $params Set of associations to bind (indexed by association type) + * @param array> $params Set of associations to bind (indexed by association type) * @return $this * @see \Cake\ORM\Table::belongsTo() * @see \Cake\ORM\Table::hasOne() @@ -923,7 +1077,7 @@ public function addAssociations(array $params) { foreach ($params as $assocType => $tables) { foreach ($tables as $associated => $options) { - if (is_numeric($associated)) { + if (is_int($associated)) { $associated = $options; $options = []; } @@ -961,16 +1115,15 @@ public function addAssociations(array $params) * * @param string $associated the alias for the target table. This is used to * uniquely identify the association - * @param array $options list of options to configure the association definition - * @return \Cake\ORM\Association\BelongsTo + * @param array $options list of options to configure the association definition + * @return \Cake\ORM\Association\BelongsTo<\Cake\ORM\Table> */ - public function belongsTo($associated, array $options = []) + public function belongsTo(string $associated, array $options = []): BelongsTo { $options += ['sourceTable' => $this]; - $association = new BelongsTo($associated, $options); - $this->_associations->add($association->getName(), $association); - return $association; + /** @var \Cake\ORM\Association\BelongsTo<\Cake\ORM\Table> */ + return $this->_associations->load(BelongsTo::class, $associated, $options); } /** @@ -1006,16 +1159,15 @@ public function belongsTo($associated, array $options = []) * * @param string $associated the alias for the target table. This is used to * uniquely identify the association - * @param array $options list of options to configure the association definition - * @return \Cake\ORM\Association\HasOne + * @param array $options list of options to configure the association definition + * @return \Cake\ORM\Association\HasOne<\Cake\ORM\Table> */ - public function hasOne($associated, array $options = []) + public function hasOne(string $associated, array $options = []): HasOne { $options += ['sourceTable' => $this]; - $association = new HasOne($associated, $options); - $this->_associations->add($association->getName(), $association); - return $association; + /** @var \Cake\ORM\Association\HasOne<\Cake\ORM\Table> */ + return $this->_associations->load(HasOne::class, $associated, $options); } /** @@ -1057,16 +1209,15 @@ public function hasOne($associated, array $options = []) * * @param string $associated the alias for the target table. This is used to * uniquely identify the association - * @param array $options list of options to configure the association definition - * @return \Cake\ORM\Association\HasMany + * @param array $options list of options to configure the association definition + * @return \Cake\ORM\Association\HasMany<\Cake\ORM\Table> */ - public function hasMany($associated, array $options = []) + public function hasMany(string $associated, array $options = []): HasMany { $options += ['sourceTable' => $this]; - $association = new HasMany($associated, $options); - $this->_associations->add($association->getName(), $association); - return $association; + /** @var \Cake\ORM\Association\HasMany<\Cake\ORM\Table> */ + return $this->_associations->load(HasMany::class, $associated, $options); } /** @@ -1110,27 +1261,28 @@ public function hasMany($associated, array $options = []) * * @param string $associated the alias for the target table. This is used to * uniquely identify the association - * @param array $options list of options to configure the association definition - * @return \Cake\ORM\Association\BelongsToMany + * @param array $options list of options to configure the association definition + * @return \Cake\ORM\Association\BelongsToMany<\Cake\ORM\Table> */ - public function belongsToMany($associated, array $options = []) + public function belongsToMany(string $associated, array $options = []): BelongsToMany { $options += ['sourceTable' => $this]; - $association = new BelongsToMany($associated, $options); - $this->_associations->add($association->getName(), $association); - return $association; + /** @var \Cake\ORM\Association\BelongsToMany<\Cake\ORM\Table> */ + return $this->_associations->load(BelongsToMany::class, $associated, $options); } /** - * {@inheritDoc} + * Creates a new Query for this repository and applies some defaults based on the + * type of search that was selected. * * ### Model.beforeFind event * * Each find() will trigger a `Model.beforeFind` event for all attached * listeners. Any listener can set a valid result set using $query * - * By default, `$options` will recognize the following keys: + * By default, following special named arguments are recognized which are + * used as select query options: * * - fields * - conditions @@ -1145,14 +1297,12 @@ public function belongsToMany($associated, array $options = []) * * ### Usage * - * Using the options array: - * * ``` - * $query = $articles->find('all', [ - * 'conditions' => ['published' => 1], - * 'limit' => 10, - * 'contain' => ['Users', 'Comments'] - * ]); + * $query = $articles->find('all', + * conditions: ['published' => 1], + * limit: 10, + * contain: ['Users', 'Comments'] + * ); * ``` * * Using the builder interface: @@ -1167,35 +1317,105 @@ public function belongsToMany($associated, array $options = []) * ### Calling finders * * The find() method is the entry point for custom finder methods. - * You can invoke a finder by specifying the type: + * You can invoke a finder by specifying the type. + * + * This will invoke the `findPublished` method: * * ``` * $query = $articles->find('published'); * ``` * - * Would invoke the `findPublished` method. + * ## Typed finder arguments + * + * Finders must have a `SelectQuery` instance as their 1st argument and any + * additional parameters as needed. + * + * Here, the finder "findByCategory" has an integer `$category` parameter: + * + * ``` + * function findByCategory(SelectQuery $query, int $category): SelectQuery + * { + * return $query; + * } + * ``` + * + * This finder can be called as: + * + * ``` + * $query = $articles->find('byCategory', $category); + * ``` + * + * or using named arguments as: + * ``` + * $query = $articles->find(type: 'byCategory', category: $category); + * ``` * - * @return \Cake\ORM\Query The query builder + * @param string $type the type of query to perform + * @param mixed ...$args Arguments that match up to finder-specific parameters + * @return \Cake\ORM\Query\SelectQuery The query builder */ - public function find($type = 'all', $options = []) + public function find(string $type = 'all', mixed ...$args): SelectQuery { - $query = $this->query(); - $query->select(); + /** @var \Cake\ORM\Query\SelectQuery $query */ + $query = $this->callFinder($type, $this->selectQuery(), ...$args); + + return $query; + } + + /** + * Type-safe non-hydrated read. Equivalent in behavior to + * `find($type, ...)->disableHydration()` but the type system knows the + * results are arrays rather than entities. + * + * Construction methods (where/join/order/contain/finders) behave the same + * as on a regular {@see SelectQuery}; only the result-fetch methods + * (first/firstOrFail/all/toArray/iteration) differ in shape. + * + * ``` + * $rows = $articlesTable->unhydratedFind()->where(['published' => true])->all(); + * // $rows: iterable> + * ``` + * + * Only finders that mutate and return the query they were given are + * supported here (the overwhelming majority). A finder that discards the + * passed query and returns a freshly built one (e.g. by delegating to + * `find()`) cannot preserve the non-hydrating contract and triggers an + * exception rather than a silent hydrated result. + * + * @param string $type The type of finder to call. + * @param mixed ...$args Arguments matching the finder's parameters. + * @return \Cake\ORM\Query\UnhydratedSelectQuery + * @throws \Cake\Core\Exception\CakeException When the finder does not return the passed query. + * @since 5.4.0 + */ + public function unhydratedFind(string $type = 'all', mixed ...$args): UnhydratedSelectQuery + { + $query = $this->unhydratedSelectQuery(); + $result = $this->callFinder($type, $query, ...$args); + + if (!$result instanceof UnhydratedSelectQuery) { + throw new CakeException(sprintf( + 'The `%s` finder must return the query it was given when called via unhydratedFind(); ' + . 'got `%s` instead. Finders that build a fresh query cannot preserve the ' + . 'non-hydrating contract — use find() for those.', + $type, + get_debug_type($result), + )); + } - return $this->callFinder($type, $query, $options); + return $result; } /** * Returns the query as passed. * - * By default findAll() applies no conditions, you - * can override this method in subclasses to modify how `find('all')` works. + * By default findAll() applies no query clauses, you can override this + * method in subclasses to modify how `find('all')` works. * - * @param \Cake\ORM\Query $query The query to find with - * @param array $options The options to use for the find - * @return \Cake\ORM\Query The query builder + * @param \Cake\ORM\Query\SelectQuery $query The query to find with + * @return \Cake\ORM\Query\SelectQuery The query builder */ - public function findAll(Query $query, array $options) + public function findAll(SelectQuery $query): SelectQuery { return $query; } @@ -1206,7 +1426,7 @@ public function findAll(Query $query, array $options) * * When calling this finder, the fields passed are used to determine what should * be used as the array key, value and optionally what to group the results by. - * By default the primary key for the model is used for the key, and the display + * By default, the primary key for the model is used for the key, and the display * field as value. * * The results of this finder will be in the following form: @@ -1219,24 +1439,35 @@ public function findAll(Query $query, array $options) * ] * ``` * - * You can specify which property will be used as the key and which as value - * by using the `$options` array, when not specified, it will use the results - * of calling `primaryKey` and `displayField` respectively in this table: + * You can specify which property will be used as the key and which as value, + * when not specified, it will use the results of calling `primaryKey` and + * `displayField` respectively in this table: * * ``` - * $table->find('list', [ - * 'keyField' => 'name', - * 'valueField' => 'age' - * ]); + * $table->find('list', keyField: 'name', valueField: 'age'); + * ``` + * + * The `valueField` can also be an array, in which case you can also specify + * the `valueSeparator` option to control how the values will be concatenated: + * + * ``` + * $table->find('list', valueField: ['first_name', 'last_name'], valueSeparator: ' | '); + * ``` + * + * The results of this finder will be in the following form: + * + * ``` + * [ + * 1 => 'John | Doe', + * 2 => 'Steve | Smith' + * ] * ``` * * Results can be put together in bigger groups when they share a property, you * can customize the property to use for grouping by setting `groupField`: * * ``` - * $table->find('list', [ - * 'groupField' => 'category_id', - * ]); + * $table->find('list', groupField: 'category_id'); * ``` * * When using a `groupField` results will be returned in this format: @@ -1253,33 +1484,29 @@ public function findAll(Query $query, array $options) * ] * ``` * - * @param \Cake\ORM\Query $query The query to find with - * @param array $options The options for the find - * @return \Cake\ORM\Query The query builder - */ - public function findList(Query $query, array $options) - { - $options += [ - 'keyField' => $this->getPrimaryKey(), - 'valueField' => $this->getDisplayField(), - 'groupField' => null - ]; - - if (isset($options['idField'])) { - $options['keyField'] = $options['idField']; - unset($options['idField']); - trigger_error('Option "idField" is deprecated, use "keyField" instead.', E_USER_DEPRECATED); - } - - if (!$query->clause('select') && - !is_object($options['keyField']) && - !is_object($options['valueField']) && - !is_object($options['groupField']) + * @param \Cake\ORM\Query\SelectQuery $query The query to find with + * @return \Cake\ORM\Query\SelectQuery The query builder + */ + public function findList( + SelectQuery $query, + Closure|array|string|null $keyField = null, + Closure|array|string|null $valueField = null, + Closure|array|string|null $groupField = null, + string $valueSeparator = ' ', + ): SelectQuery { + $keyField ??= $this->getPrimaryKey(); + $valueField ??= $this->getDisplayField(); + + if ( + !$query->clause('select') && + !$keyField instanceof Closure && + !$valueField instanceof Closure && + !$groupField instanceof Closure ) { $fields = array_merge( - (array)$options['keyField'], - (array)$options['valueField'], - (array)$options['groupField'] + (array)$keyField, + (array)$valueField, + (array)$groupField, ); $columns = $this->getSchema()->columns(); if (count($fields) === count(array_intersect($fields, $columns))) { @@ -1288,18 +1515,15 @@ public function findList(Query $query, array $options) } $options = $this->_setFieldMatchers( - $options, - ['keyField', 'valueField', 'groupField'] + compact('keyField', 'valueField', 'groupField', 'valueSeparator'), + ['keyField', 'valueField', 'groupField'], ); - return $query->formatResults(function ($results) use ($options) { - /** @var \Cake\Collection\CollectionInterface $results */ - return $results->combine( - $options['keyField'], - $options['valueField'], - $options['groupField'] - ); - }); + return $query->formatResults(fn(CollectionInterface $results) => $results->combine( + $options['keyField'], + $options['valueField'], + $options['groupField'], + )); } /** @@ -1311,41 +1535,34 @@ public function findList(Query $query, array $options) * * You can customize what fields are used for nesting results, by default the * primary key and the `parent_id` fields are used. If you wish to change - * these defaults you need to provide the keys `keyField`, `parentField` or `nestingKey` in - * `$options`: + * these defaults you need to provide the `keyField`, `parentField` or `nestingKey` + * arguments: * * ``` - * $table->find('threaded', [ - * 'keyField' => 'id', - * 'parentField' => 'ancestor_id' - * 'nestingKey' => 'children' - * ]); + * $table->find('threaded', keyField: 'id', parentField: 'ancestor_id', nestingKey: 'children'); * ``` * - * @param \Cake\ORM\Query $query The query to find with - * @param array $options The options to find with - * @return \Cake\ORM\Query The query builder + * @param \Cake\ORM\Query\SelectQuery $query The query to find with + * @param \Closure|array|string|null $keyField The path to the key field. + * @param \Closure|array|string $parentField The path to the parent field. + * @param string $nestingKey The key to nest children under. + * @return \Cake\ORM\Query\SelectQuery The query builder */ - public function findThreaded(Query $query, array $options) - { - $options += [ - 'keyField' => $this->getPrimaryKey(), - 'parentField' => 'parent_id', - 'nestingKey' => 'children' - ]; - - if (isset($options['idField'])) { - $options['keyField'] = $options['idField']; - unset($options['idField']); - trigger_error('Option "idField" is deprecated, use "keyField" instead.', E_USER_DEPRECATED); - } + public function findThreaded( + SelectQuery $query, + Closure|array|string|null $keyField = null, + Closure|array|string $parentField = 'parent_id', + string $nestingKey = 'children', + ): SelectQuery { + $keyField ??= $this->getPrimaryKey(); - $options = $this->_setFieldMatchers($options, ['keyField', 'parentField']); + $options = $this->_setFieldMatchers(compact('keyField', 'parentField'), ['keyField', 'parentField']); - return $query->formatResults(function ($results) use ($options) { - /** @var \Cake\Collection\CollectionInterface $results */ - return $results->nest($options['keyField'], $options['parentField'], $options['nestingKey']); - }); + return $query->formatResults(fn(CollectionInterface $results) => $results->nest( + $options['keyField'], + $options['parentField'], + $nestingKey, + )); } /** @@ -1356,12 +1573,12 @@ public function findThreaded(Query $query, array $options) * This is an auxiliary function used for result formatters that can accept * composite keys when comparing values. * - * @param array $options the original options passed to a finder - * @param array $keys the keys to check in $options to build matchers from + * @param array $options the original options passed to a finder + * @param array $keys the keys to check in $options to build matchers from * the associated value - * @return array + * @return array */ - protected function _setFieldMatchers($options, $keys) + protected function _setFieldMatchers(array $options, array $keys): array { foreach ($keys as $field) { if (!is_array($options[$field])) { @@ -1374,13 +1591,14 @@ protected function _setFieldMatchers($options, $keys) } $fields = $options[$field]; - $options[$field] = function ($row) use ($fields) { + $glue = in_array($field, ['keyField', 'parentField'], true) ? ';' : $options['valueSeparator']; + $options[$field] = function ($row) use ($fields, $glue): string { $matches = []; foreach ($fields as $field) { $matches[] = $row[$field]; } - return implode(';', $matches); + return implode($glue, $matches); }; } @@ -1395,20 +1613,45 @@ protected function _setFieldMatchers($options, $keys) * Get an article and some relationships: * * ``` - * $article = $articles->get(1, ['contain' => ['Users', 'Comments']]); + * $article = $articles->get(1, contain: ['Users', 'Comments']); * ``` * + * @param mixed $primaryKey primary key value to find + * @param array|string $finder The finder to use. Passing an options array is deprecated. + * @param \Psr\SimpleCache\CacheInterface|string|null $cache The cache config to use. + * Defaults to `null`, i.e. no caching. + * @param \Closure|string|null $cacheKey The cache key to use. If not provided + * one will be autogenerated if `$cache` is not null. + * @param mixed ...$args Arguments that query options or finder specific parameters. + * @return TEntity + * @throws \Cake\Datasource\Exception\RecordNotFoundException if the record with such id + * could not be found * @throws \Cake\Datasource\Exception\InvalidPrimaryKeyException When $primaryKey has an * incorrect number of elements. - */ - public function get($primaryKey, $options = []) - { + * @see \Cake\Datasource\RepositoryInterface::find() + */ + public function get( + mixed $primaryKey, + array|string $finder = 'all', + CacheInterface|string|null $cache = null, + Closure|string|null $cacheKey = null, + mixed ...$args, + ): EntityInterface { + if ($primaryKey === null) { + throw new InvalidPrimaryKeyException(sprintf( + 'Record not found in table `%s` with primary key `[NULL]`.', + $this->getTable(), + )); + } + $key = (array)$this->getPrimaryKey(); $alias = $this->getAlias(); foreach ($key as $index => $keyname) { $key[$index] = $alias . '.' . $keyname; } - $primaryKey = (array)$primaryKey; + if (!is_array($primaryKey)) { + $primaryKey = [$primaryKey]; + } if (count($key) !== count($primaryKey)) { $primaryKey = $primaryKey ?: [null]; $primaryKey = array_map(function ($key) { @@ -1416,33 +1659,49 @@ public function get($primaryKey, $options = []) }, $primaryKey); throw new InvalidPrimaryKeyException(sprintf( - 'Record not found in table "%s" with primary key [%s]', + 'Record not found in table `%s` with primary key `[%s]`.', $this->getTable(), - implode($primaryKey, ', ') + implode(', ', $primaryKey), )); } $conditions = array_combine($key, $primaryKey); - $cacheConfig = isset($options['cache']) ? $options['cache'] : false; - $cacheKey = isset($options['key']) ? $options['key'] : false; - $finder = isset($options['finder']) ? $options['finder'] : 'all'; - unset($options['key'], $options['cache'], $options['finder']); + if (is_array($finder)) { + deprecationWarning( + '5.0.0', + 'Calling Table::get() with options array is deprecated.' + . ' Use named arguments instead.', + ); + + $args += $finder; + $finder = $args['finder'] ?? 'all'; + if (isset($args['cache'])) { + $cache = $args['cache']; + } + if (isset($args['key'])) { + $cacheKey = $args['key']; + } + unset($args['key'], $args['cache'], $args['finder']); + } - $query = $this->find($finder, $options)->where($conditions); + $query = $this->find($finder, ...$args)->where($conditions); - if ($cacheConfig) { + if ($cache) { if (!$cacheKey) { $cacheKey = sprintf( - 'get:%s.%s%s', + 'get-%s-%s-%s', $this->getConnection()->configName(), $this->getTable(), - json_encode($primaryKey) + json_encode($primaryKey, JSON_THROW_ON_ERROR), ); } - $query->cache($cacheKey, $cacheConfig); + $query->cache($cacheKey, $cache); } - return $query->firstOrFail(); + /** @var TEntity $entity */ + $entity = $query->firstOrFail(); + + return $entity; } /** @@ -1452,12 +1711,10 @@ public function get($primaryKey, $options = []) * @param bool $atomic Whether to execute the worker inside a database transaction. * @return mixed */ - protected function _executeTransaction(callable $worker, $atomic = true) + protected function _executeTransaction(callable $worker, bool $atomic = true): mixed { if ($atomic) { - return $this->getConnection()->transactional(function () use ($worker) { - return $worker(); - }); + return $this->getConnection()->transactional($worker(...)); } return $worker(); @@ -1470,9 +1727,9 @@ protected function _executeTransaction(callable $worker, $atomic = true) * @param bool $primary True if a primary was used. * @return bool Returns true if a transaction was committed. */ - protected function _transactionCommitted($atomic, $primary) + protected function _transactionCommitted(bool $atomic, bool $primary): bool { - return !$this->getConnection()->inTransaction() && ($atomic || (!$atomic && $primary)); + return !$this->getConnection()->inTransaction() && ($atomic || $primary); } /** @@ -1488,7 +1745,7 @@ protected function _transactionCommitted($atomic, $primary) * entity will be saved and returned. * * If your find conditions require custom order, associations or conditions, then the $search - * parameter can be a callable that takes the Query as the argument, or a \Cake\ORM\Query object passed + * parameter can be a callable that takes the Query as the argument, or a \Cake\ORM\Query\SelectQuery object passed * as the $search parameter. Allowing you to customize the find results. * * ### Options @@ -1499,130 +1756,246 @@ protected function _transactionCommitted($atomic, $primary) * transaction (default: true) * - defaults: Whether to use the search criteria as default values for the new entity (default: true) * - * @param array|\Cake\ORM\Query $search The criteria to find existing + * @param \Cake\ORM\Query\SelectQuery|callable|array $search The criteria to find existing * records by. Note that when you pass a query object you'll have to use * the 2nd arg of the method to modify the entity data before saving. - * @param callable|null $callback A callback that will be invoked for newly + * @param callable|array|null $callback An array of data key/value pairs or a callback that will + * be invoked for newly created entities. This callback will be called *before* the entity + * is persisted. + * @param array $options The options to use when saving. + * @return TEntity An entity. + * @throws \Cake\ORM\Exception\PersistenceFailedException When the entity couldn't be saved + */ + public function findOrCreate( + SelectQuery|callable|array $search, + callable|array|null $callback = null, + array $options = [], + ): EntityInterface { + $options = new ArrayObject($options + [ + 'atomic' => true, + 'defaults' => true, + ]); + + $entity = $this->_executeTransaction( + fn() => $this->_processFindOrCreate($search, $callback, $options->getArrayCopy()), + $options['atomic'], + ); + + if ($entity && $this->_transactionCommitted($options['atomic'], true)) { + $this->dispatchEvent('Model.afterSaveCommit', compact('entity', 'options')); + } + + return $entity; + } + + /** + * Performs the actual find and/or create of an entity based on the passed options. + * + * @param \Cake\ORM\Query\SelectQuery|callable|array $search The criteria to find an existing record by, or a callable that will + * customize the find query. + * @param callable|array|null $callback Data or a callback that will be invoked for newly * created entities. This callback will be called *before* the entity * is persisted. - * @param array $options The options to use when saving. - * @return \Cake\Datasource\EntityInterface An entity. + * @param array $options The options to use when saving. + * @return TEntity|array An entity. + * @throws \Cake\ORM\Exception\PersistenceFailedException When the entity couldn't be saved + * @throws \InvalidArgumentException + */ + protected function _processFindOrCreate( + SelectQuery|callable|array $search, + callable|array|null $callback = null, + array $options = [], + ): EntityInterface|array { + $query = $this->_getFindOrCreateQuery($search); + + $row = $query->first(); + if ($row !== null) { + return $row; + } + + $data = $search; + if (is_array($callback) && !is_callable($callback)) { + $data = $callback + $search; + $callback = null; + } + + $entity = $this->newEmptyEntity(); + if ($options['defaults'] && is_array($data)) { + $accessibleFields = array_combine(array_keys($data), array_fill(0, count($data), true)); + $entity = $this->patchEntity($entity, $data, ['accessibleFields' => $accessibleFields]); + } + if ($callback !== null) { + /** @var TEntity $entity */ + $entity = $callback($entity) ?: $entity; + } + unset($options['defaults']); + + $result = $this->save($entity, $options); + + if ($result === false) { + throw new PersistenceFailedException($entity, ['findOrCreate']); + } + + return $entity; + } + + /** + * Gets the query object for findOrCreate(). + * + * @param \Cake\ORM\Query\SelectQuery|callable|array $search The criteria to find existing records by. + * @return \Cake\ORM\Query\SelectQuery + */ + protected function _getFindOrCreateQuery(SelectQuery|callable|array $search): SelectQuery + { + if (is_callable($search)) { + $query = $this->find(); + $search($query); + } elseif (is_array($search)) { + $query = $this->find()->where($search); + } else { + $query = $search; + } + + return $query; + } + + /** + * Creates a new SelectQuery instance for a table. + * + * @return \Cake\ORM\Query\SelectQuery + */ + public function query(): SelectQuery + { + return $this->selectQuery(); + } + + /** + * Creates a new select query + * + * @return \Cake\ORM\Query\SelectQuery + */ + public function selectQuery(): SelectQuery + { + /** @var \Cake\ORM\Query\SelectQuery $query */ + $query = $this->queryFactory->select($this); + + return $query; + } + + /** + * Creates a new non-hydrating select query. + * + * @return \Cake\ORM\Query\UnhydratedSelectQuery + * @since 5.4.0 + */ + public function unhydratedSelectQuery(): UnhydratedSelectQuery + { + return $this->queryFactory->unhydratedSelect($this); + } + + /** + * Creates a new insert query + * + * @return \Cake\ORM\Query\InsertQuery */ - public function findOrCreate($search, callable $callback = null, $options = []) + public function insertQuery(): InsertQuery { - $options += [ - 'atomic' => true, - 'defaults' => true - ]; - - return $this->_executeTransaction(function () use ($search, $callback, $options) { - return $this->_processFindOrCreate($search, $callback, $options); - }, $options['atomic']); + return $this->queryFactory->insert($this); } /** - * Performs the actual find and/or create of an entity based on the passed options. + * Creates a new update query * - * @param array|callable $search The criteria to find an existing record by, or a callable tha will - * customize the find query. - * @param callable|null $callback A callback that will be invoked for newly - * created entities. This callback will be called *before* the entity - * is persisted. - * @param array $options The options to use when saving. - * @return \Cake\Datasource\EntityInterface An entity. + * @return \Cake\ORM\Query\UpdateQuery */ - protected function _processFindOrCreate($search, callable $callback = null, $options = []) + public function updateQuery(): UpdateQuery { - if (is_callable($search)) { - $query = $this->find(); - $search($query); - } elseif (is_array($search)) { - $query = $this->find()->where($search); - } elseif ($search instanceof Query) { - $query = $search; - } else { - throw new InvalidArgumentException('Search criteria must be an array, callable or Query'); - } - $row = $query->first(); - if ($row !== null) { - return $row; - } - $entity = $this->newEntity(); - if ($options['defaults'] && is_array($search)) { - $entity->set($search, ['guard' => false]); - } - if ($callback !== null) { - $entity = $callback($entity) ?: $entity; - } - unset($options['defaults']); - - return $this->save($entity, $options) ?: $entity; + return $this->queryFactory->update($this); } /** - * Gets the query object for findOrCreate(). + * Creates a new delete query * - * @param array|\Cake\ORM\Query|string $search The criteria to find existing records by. - * @return \Cake\ORM\Query + * @return \Cake\ORM\Query\DeleteQuery */ - protected function _getFindOrCreateQuery($search) + public function deleteQuery(): DeleteQuery { - if ($search instanceof Query) { - return $search; - } - - return $this->find()->where($search); + return $this->queryFactory->delete($this); } /** - * {@inheritDoc} + * Creates a new Query instance with field auto aliasing disabled. + * + * This is useful for subqueries. + * + * @return \Cake\ORM\Query\SelectQuery */ - public function query() + public function subquery(): SelectQuery { - return new Query($this->getConnection(), $this); + /** @var \Cake\ORM\Query\SelectQuery $query */ + $query = $this->queryFactory->select($this)->disableAutoAliasing(); + + return $query; } /** - * {@inheritDoc} + * Update all matching records. + * + * Sets the $fields to the provided values based on $conditions. + * This method will *not* trigger beforeSave/afterSave events. If you need those + * first load a collection of records and update them. + * + * @param \Cake\Database\Expression\QueryExpression|\Closure|array|string $fields A hash of field => new value. + * @param \Cake\Database\Expression\QueryExpression|\Closure|array|string|null $conditions Conditions to be used, accepts anything Query::where() + * @return int Count Returns the affected rows. */ - public function updateAll($fields, $conditions) - { - $query = $this->query(); - $query->update() + public function updateAll( + QueryExpression|Closure|array|string $fields, + QueryExpression|Closure|array|string|null $conditions, + ): int { + $statement = $this->updateQuery() ->set($fields) - ->where($conditions); - $statement = $query->execute(); - $statement->closeCursor(); + ->where($conditions) + ->execute(); return $statement->rowCount(); } /** - * {@inheritDoc} + * Deletes all records matching the provided conditions. + * + * This method will *not* trigger beforeDelete/afterDelete events. If you + * need those first load a collection of records and delete them. + * + * This method will *not* execute on associations' `cascade` attribute. You should + * use database foreign keys + ON CASCADE rules if you need cascading deletes combined + * with this method. + * + * @param \Cake\Database\Expression\QueryExpression|\Closure|array|string|null $conditions Conditions to be used, accepts anything Query::where() + * can take. + * @return int Returns the number of affected rows. */ - public function deleteAll($conditions) + public function deleteAll(QueryExpression|Closure|array|string|null $conditions): int { - $query = $this->query() - ->delete() - ->where($conditions); - $statement = $query->execute(); - $statement->closeCursor(); + $statement = $this->deleteQuery() + ->where($conditions) + ->execute(); return $statement->rowCount(); } /** - * {@inheritDoc} + * @inheritDoc */ - public function exists($conditions) + public function exists(QueryExpression|Closure|array|string|null $conditions): bool { return (bool)count( $this->find('all') ->select(['existing' => 1]) ->where($conditions) ->limit(1) - ->enableHydration(false) - ->toArray() + ->disableHydration() + ->toArray(), ); } @@ -1635,7 +2008,7 @@ public function exists($conditions) * * - atomic: Whether to execute the save and callbacks inside a database * transaction (default: true) - * - checkRules: Whether or not to check the rules on entity before saving, if the checking + * - checkRules: Whether to check the rules on entity before saving, if the checking * fails, it will abort the save operation. (default:true) * - associated: If `true` it will save 1st level associated entities as they are found * in the passed `$entity` whenever the property defined for the association @@ -1643,7 +2016,7 @@ public function exists($conditions) * to be saved. It is possible to provide different options for saving on associated * table objects using this key by making the custom options the array value. * If `false` no associated records will be saved. (default: `true`) - * - checkExisting: Whether or not to check if the entity already exists, assuming that the + * - checkExisting: Whether to check if the entity already exists, assuming that the * entity is marked as not new, and the primary key has been set. * * ### Events @@ -1672,7 +2045,7 @@ public function exists($conditions) * listeners will receive the entity and the options array as arguments. The type * of operation performed (insert or update) can be determined by checking the * entity's method `isNew`, true meaning an insert and false an update. - * - Model.afterSaveCommit: Will be triggered after the transaction is commited + * - Model.afterSaveCommit: Will be triggered after the transaction is committed * for atomic save, listeners will receive the entity and the options array * as arguments. * @@ -1690,7 +2063,7 @@ public function exists($conditions) * * ``` * // Only save the comments association - * $articles->save($entity, ['associated' => ['Comments']); + * $articles->save($entity, ['associated' => ['Comments']]); * * // Save the company, the employees and related addresses for each of them. * // For employees do not check the entity rules @@ -1707,24 +2080,28 @@ public function exists($conditions) * $articles->save($entity, ['associated' => false]); * ``` * - * @throws \Cake\ORM\Exception\RolledbackTransactionException If the transaction - * is aborted in the afterSave event. - */ - public function save(EntityInterface $entity, $options = []) - { - if ($options instanceof SaveOptionsBuilder) { - $options = $options->toArray(); - } - + * @template TSavedEntity of \Cake\Datasource\EntityInterface + * @param TSavedEntity $entity the entity to be saved + * @param array $options The options to use when saving. + * @return TSavedEntity|false Returns the entity on success. Returns false when the entity has errors, + * validation fails, rules checking fails, or the save operation fails. If the entity is not new + * and has no dirty fields, the entity is returned without performing any database operation. + * @throws \Cake\ORM\Exception\RolledbackTransactionException If the transaction is aborted in the afterSave event. + */ + public function save( + EntityInterface $entity, + array $options = [], + ): EntityInterface|false { $options = new ArrayObject($options + [ 'atomic' => true, 'associated' => true, 'checkRules' => true, 'checkExisting' => true, - '_primary' => true + '_primary' => true, + '_cleanOnSuccess' => true, ]); - if ($entity->getErrors()) { + if ($entity->hasErrors((bool)$options['associated'])) { return false; } @@ -1732,17 +2109,20 @@ public function save(EntityInterface $entity, $options = []) return $entity; } - $success = $this->_executeTransaction(function () use ($entity, $options) { - return $this->_processSave($entity, $options); - }, $options['atomic']); + $success = $this->_executeTransaction( + fn() => $this->_processSave($entity, $options), + $options['atomic'], + ); if ($success) { if ($this->_transactionCommitted($options['atomic'], $options['_primary'])) { $this->dispatchEvent('Model.afterSaveCommit', compact('entity', 'options')); } if ($options['atomic'] || $options['_primary']) { - $entity->clean(); - $entity->isNew(false); + if ($options['_cleanOnSuccess']) { + $entity->clean(); + $entity->setNew(false); + } $entity->setSource($this->getRegistryAlias()); } } @@ -1754,13 +2134,14 @@ public function save(EntityInterface $entity, $options = []) * Try to save an entity or throw a PersistenceFailedException if the application rules checks failed, * the entity contains errors or the save was aborted by a callback. * - * @param \Cake\Datasource\EntityInterface $entity the entity to be saved - * @param array|\ArrayAccess $options The options to use when saving. - * @return \Cake\Datasource\EntityInterface + * @template TSavedEntity of \Cake\Datasource\EntityInterface + * @param TSavedEntity $entity the entity to be saved + * @param array $options The options to use when saving. + * @return TSavedEntity * @throws \Cake\ORM\Exception\PersistenceFailedException When the entity couldn't be saved * @see \Cake\ORM\Table::save() */ - public function saveOrFail(EntityInterface $entity, $options = []) + public function saveOrFail(EntityInterface $entity, array $options = []): EntityInterface { $saved = $this->save($entity, $options); if ($saved === false) { @@ -1774,23 +2155,25 @@ public function saveOrFail(EntityInterface $entity, $options = []) * Performs the actual saving of an entity based on the passed options. * * @param \Cake\Datasource\EntityInterface $entity the entity to be saved - * @param \ArrayObject $options the options to use for the save operation - * @return \Cake\Datasource\EntityInterface|bool - * @throws \RuntimeException When an entity is missing some of the primary keys. + * @param \ArrayObject $options the options to use for the save operation + * @return \Cake\Datasource\EntityInterface|false + * @throws \Cake\Database\Exception\DatabaseException When an entity is missing some of the primary keys. * @throws \Cake\ORM\Exception\RolledbackTransactionException If the transaction * is aborted in the afterSave event. */ - protected function _processSave($entity, $options) + protected function _processSave(EntityInterface $entity, ArrayObject $options): EntityInterface|false { + $this->assertEntityClass($entity); + $primaryColumns = (array)$this->getPrimaryKey(); if ($options['checkExisting'] && $primaryColumns && $entity->isNew() && $entity->has($primaryColumns)) { $alias = $this->getAlias(); $conditions = []; foreach ($entity->extract($primaryColumns) as $k => $v) { - $conditions["$alias.$k"] = $v; + $conditions["{$alias}.{$k}"] = $v; } - $entity->isNew(!$this->exists($conditions)); + $entity->setNew(!$this->exists($conditions)); } $mode = $entity->isNew() ? RulesChecker::CREATE : RulesChecker::UPDATE; @@ -1802,14 +2185,30 @@ protected function _processSave($entity, $options) $event = $this->dispatchEvent('Model.beforeSave', compact('entity', 'options')); if ($event->isStopped()) { - return $event->getResult(); + $result = $event->getResult(); + if ($result === null) { + return false; + } + + if ($result !== false) { + assert( + $result instanceof EntityInterface, + sprintf( + 'The result for the `Model.beforeSave` event must be `false` or `EntityInterface` instance.' + . ' Got `%s` instead.', + get_debug_type($result), + ), + ); + } + + return $result; } $saved = $this->_associations->saveParents( $this, $entity, $options['associated'], - ['_primary' => false] + $options->getArrayCopy() + ['_primary' => false] + $options->getArrayCopy(), ); if (!$saved && $options['atomic']) { @@ -1830,8 +2229,8 @@ protected function _processSave($entity, $options) } if (!$success && $isNew) { - $entity->unsetProperty($this->getPrimaryKey()); - $entity->isNew(true); + $entity->unset($this->getPrimaryKey()); + $entity->setNew(true); } return $success ? $entity : false; @@ -1842,18 +2241,18 @@ protected function _processSave($entity, $options) * once the entity for this table has been saved successfully. * * @param \Cake\Datasource\EntityInterface $entity the entity to be saved - * @param \ArrayObject $options the options to use for the save operation + * @param \ArrayObject $options the options to use for the save operation * @return bool True on success * @throws \Cake\ORM\Exception\RolledbackTransactionException If the transaction * is aborted in the afterSave event. */ - protected function _onSaveSuccess($entity, $options) + protected function _onSaveSuccess(EntityInterface $entity, ArrayObject $options): bool { $success = $this->_associations->saveChildren( $this, $entity, $options['associated'], - ['_primary' => false] + $options->getArrayCopy() + ['_primary' => false] + $options->getArrayCopy(), ); if (!$success && $options['atomic']) { @@ -1863,12 +2262,12 @@ protected function _onSaveSuccess($entity, $options) $this->dispatchEvent('Model.afterSave', compact('entity', 'options')); if ($options['atomic'] && !$this->getConnection()->inTransaction()) { - throw new RolledbackTransactionException(['table' => get_class($this)]); + throw new RolledbackTransactionException(['table' => static::class]); } if (!$options['atomic'] && !$options['_primary']) { $entity->clean(); - $entity->isNew(false); + $entity->setNew(false); $entity->setSource($this->getRegistryAlias()); } @@ -1880,19 +2279,19 @@ protected function _onSaveSuccess($entity, $options) * * @param \Cake\Datasource\EntityInterface $entity the subject entity from were $data was extracted * @param array $data The actual data that needs to be saved - * @return \Cake\Datasource\EntityInterface|bool - * @throws \RuntimeException if not all the primary keys where supplied or could + * @return \Cake\Datasource\EntityInterface|false + * @throws \Cake\Database\Exception\DatabaseException if not all the primary keys where supplied or could * be generated when the table has composite primary keys. Or when the table has no primary key. */ - protected function _insert($entity, $data) + protected function _insert(EntityInterface $entity, array $data): EntityInterface|false { $primary = (array)$this->getPrimaryKey(); - if (empty($primary)) { + if (!$primary) { $msg = sprintf( - 'Cannot insert row in "%s" table, it has no primary key.', - $this->getTable() + 'Cannot insert row in `%s` table, it has no primary key.', + $this->getTable(), ); - throw new RuntimeException($msg); + throw new DatabaseException($msg); } $keys = array_fill(0, count($primary), null); $id = (array)$this->_newId($primary) + $keys; @@ -1901,7 +2300,9 @@ protected function _insert($entity, $data) $primary = array_combine($primary, $id); $primary = array_intersect_key($data, $primary) + $primary; - $filteredKeys = array_filter($primary, 'strlen'); + $filteredKeys = array_filter($primary, function ($v) { + return $v !== null; + }); $data += $filteredKeys; if (count($primary) > 1) { @@ -1912,37 +2313,43 @@ protected function _insert($entity, $data) $msg .= sprintf( 'Got (%s), expecting (%s)', implode(', ', $filteredKeys + $entity->extract(array_keys($primary))), - implode(', ', array_keys($primary)) + implode(', ', array_keys($primary)), ); - throw new RuntimeException($msg); + throw new DatabaseException($msg); } } } - $success = false; - if (empty($data)) { - return $success; + if (!$data) { + return false; } - $statement = $this->query()->insert(array_keys($data)) + $statement = $this->insertQuery()->insert(array_keys($data)) ->values($data) ->execute(); + $success = false; if ($statement->rowCount() !== 0) { $success = $entity; - $entity->set($filteredKeys, ['guard' => false]); + + if (method_exists($entity, 'patch')) { + $entity = $entity->patch($filteredKeys, ['guard' => false]); + } else { + $entity->set($filteredKeys, ['guard' => false]); + } + $schema = $this->getSchema(); - $driver = $this->getConnection()->getDriver(); + $driver = $this->getConnection()->getWriteDriver(); foreach ($primary as $key => $v) { if (!isset($data[$key])) { $id = $statement->lastInsertId($this->getTable(), $key); $type = $schema->getColumnType($key); - $entity->set($key, Type::build($type)->toPHP($id, $driver)); + assert($type !== null); + $entity->set($key, TypeFactory::build($type)->toPHP($id, $driver)); break; } } } - $statement->closeCursor(); return $success; } @@ -1957,16 +2364,17 @@ protected function _insert($entity, $data) * Note: The ORM will not generate primary key values for composite primary keys. * You can overwrite _newId() in your table class. * - * @param array $primary The primary key columns to get a new ID for. - * @return null|string|array Either null or the primary key value or a list of primary key values. + * @param array $primary The primary key columns to get a new ID for. + * @return string|null The primary key value when a single primary key is available, or null. */ - protected function _newId($primary) + protected function _newId(array $primary): ?string { - if (!$primary || count((array)$primary) > 1) { + if (!$primary || count($primary) > 1) { return null; } $typeName = $this->getSchema()->getColumnType($primary[0]); - $type = Type::build($typeName); + assert($typeName !== null); + $type = TypeFactory::build($typeName); return $type->newId(); } @@ -1976,38 +2384,62 @@ protected function _newId($primary) * * @param \Cake\Datasource\EntityInterface $entity the subject entity from were $data was extracted * @param array $data The actual data that needs to be saved - * @return \Cake\Datasource\EntityInterface|bool + * @return \Cake\Datasource\EntityInterface|false * @throws \InvalidArgumentException When primary key data is missing. */ - protected function _update($entity, $data) + protected function _update(EntityInterface $entity, array $data): EntityInterface|false { $primaryColumns = (array)$this->getPrimaryKey(); $primaryKey = $entity->extract($primaryColumns); $data = array_diff_key($data, $primaryKey); - if (empty($data)) { + if (!$data) { return $entity; } + if ($primaryColumns === []) { + $entityClass = $entity::class; + $table = $this->getTable(); + $message = "Cannot update `{$entityClass}`. The `{$table}` has no primary key."; + throw new InvalidArgumentException($message); + } + if (!$entity->has($primaryColumns)) { $message = 'All primary key value(s) are needed for updating, '; - $message .= get_class($entity) . ' is missing ' . implode(', ', $primaryColumns); + $message .= $entity::class . ' is missing ' . implode(', ', $primaryColumns); throw new InvalidArgumentException($message); } - $query = $this->query(); - $statement = $query->update() + $statement = $this->updateQuery() ->set($data) ->where($primaryKey) ->execute(); - $success = false; - if ($statement->errorCode() === '00000') { - $success = $entity; - } - $statement->closeCursor(); + return $statement->errorCode() === '00000' ? $entity : false; + } - return $success; + /** + * Persists multiple entities of a table. + * + * The records will be saved in a transaction which will be rolled back if + * any one of the records fails to save due to failed validation or database + * error. + * + * @template TSavedEntity of \Cake\Datasource\EntityInterface + * @param iterable $entities Entities to save. + * @param array $options Options used when calling Table::save() for each entity. + * @return iterable|false False on failure, entities list on success. + * @throws \Exception + */ + public function saveMany( + iterable $entities, + array $options = [], + ): iterable|false { + try { + return $this->_saveMany($entities, $options); + } catch (PersistenceFailedException) { + return false; + } } /** @@ -2017,34 +2449,103 @@ protected function _update($entity, $data) * any one of the records fails to save due to failed validation or database * error. * - * @param array|\Cake\ORM\ResultSet $entities Entities to save. - * @param array|\ArrayAccess $options Options used when calling Table::save() for each entity. - * @return bool|array|\Cake\ORM\ResultSet False on failure, entities list on success. + * @template TSavedEntity of \Cake\Datasource\EntityInterface + * @param iterable $entities Entities to save. + * @param array $options Options used when calling Table::save() for each entity. + * @return iterable Entities list. + * @throws \Exception + * @throws \Cake\ORM\Exception\PersistenceFailedException If an entity couldn't be saved. */ - public function saveMany($entities, $options = []) + public function saveManyOrFail(iterable $entities, array $options = []): iterable { - $isNew = []; + return $this->_saveMany($entities, $options); + } - $return = $this->getConnection()->transactional( - function () use ($entities, $options, &$isNew) { - foreach ($entities as $key => $entity) { - $isNew[$key] = $entity->isNew(); - if ($this->save($entity, $options) === false) { - return false; - } - } - } + /** + * @template TSavedEntity of \Cake\Datasource\EntityInterface + * @param iterable $entities Entities to save. + * @param array $options Options used when calling Table::save() for each entity. + * @throws \Cake\ORM\Exception\PersistenceFailedException If an entity couldn't be saved. + * @throws \Exception If an entity couldn't be saved. + * @return iterable Entities list. + */ + protected function _saveMany( + iterable $entities, + array $options = [], + ): iterable { + $options = new ArrayObject( + $options + [ + 'atomic' => true, + 'checkRules' => true, + '_primary' => true, + ], ); + $options['_cleanOnSuccess'] = false; - if ($return === false) { + /** @var array $isNew */ + $isNew = []; + $cleanupOnFailure = function ($entities) use (&$isNew): void { + /** @var iterable<\Cake\Datasource\EntityInterface> $entities */ foreach ($entities as $key => $entity) { if (isset($isNew[$key]) && $isNew[$key]) { - $entity->unsetProperty($this->getPrimaryKey()); - $entity->isNew(true); + $entity->unset($this->getPrimaryKey()); + $entity->setNew(true); } } + }; - return false; + /** @var \Cake\Datasource\EntityInterface|null $failed */ + $failed = null; + try { + $this->getConnection() + ->transactional(function () use ($entities, $options, &$isNew, &$failed) { + // Cache array cast since options are the same for each entity + $options = (array)$options; + foreach ($entities as $key => $entity) { + $isNew[$key] = $entity->isNew(); + if ($this->save($entity, $options) === false) { + $failed = $entity; + + return false; + } + } + }); + } catch (Exception $e) { + $cleanupOnFailure($entities); + + throw $e; + } + + if ($failed !== null) { + $cleanupOnFailure($entities); + + throw new PersistenceFailedException($failed, ['saveMany']); + } + + $cleanupOnSuccess = function (EntityInterface $entity) use (&$cleanupOnSuccess): void { + $entity->clean(); + $entity->setNew(false); + + foreach (array_keys($entity->toArray()) as $field) { + $value = $entity->get($field); + + if ($value instanceof EntityInterface) { + $cleanupOnSuccess($value); + } elseif (is_array($value) && current($value) instanceof EntityInterface) { + foreach ($value as $associated) { + $cleanupOnSuccess($associated); + } + } + } + }; + + if ($this->_transactionCommitted($options['atomic'], $options['_primary'])) { + foreach ($entities as $entity) { + $this->dispatchEvent('Model.afterSaveCommit', compact('entity', 'options')); + if ($options['atomic'] || $options['_primary']) { + $cleanupOnSuccess($entity); + } + } } return $entities; @@ -2076,8 +2577,11 @@ function () use ($entities, $options, &$isNew) { * for the duration of the callbacks, this allows listeners to modify * the options used in the delete operation. * + * @param \Cake\Datasource\EntityInterface $entity The entity to remove. + * @param array $options The options for the delete. + * @return bool success */ - public function delete(EntityInterface $entity, $options = []) + public function delete(EntityInterface $entity, array $options = []): bool { $options = new ArrayObject($options + [ 'atomic' => true, @@ -2085,38 +2589,124 @@ public function delete(EntityInterface $entity, $options = []) '_primary' => true, ]); - $success = $this->_executeTransaction(function () use ($entity, $options) { - return $this->_processDelete($entity, $options); - }, $options['atomic']); + $success = $this->_executeTransaction( + fn() => $this->_processDelete($entity, $options), + $options['atomic'], + ); if ($success && $this->_transactionCommitted($options['atomic'], $options['_primary'])) { $this->dispatchEvent('Model.afterDeleteCommit', [ 'entity' => $entity, - 'options' => $options + 'options' => $options, ]); } return $success; } + /** + * Deletes multiple entities of a table. + * + * The records will be deleted in a transaction which will be rolled back if + * any one of the records fails to delete due to failed validation or database + * error. + * + * @template TDeletedEntity of \Cake\Datasource\EntityInterface + * @param iterable $entities Entities to delete. + * @param array $options Options used when calling Table::save() for each entity. + * @return iterable|false Entities list + * on success, false on failure. + * @see \Cake\ORM\Table::delete() for options and events related to this method. + */ + public function deleteMany(iterable $entities, array $options = []): iterable|false + { + $failed = $this->_deleteMany($entities, $options); + + if ($failed !== null) { + return false; + } + + return $entities; + } + + /** + * Deletes multiple entities of a table. + * + * The records will be deleted in a transaction which will be rolled back if + * any one of the records fails to delete due to failed validation or database + * error. + * + * @template TDeletedEntity of \Cake\Datasource\EntityInterface + * @param iterable $entities Entities to delete. + * @param array $options Options used when calling Table::save() for each entity. + * @return iterable Entities list. + * @throws \Cake\ORM\Exception\PersistenceFailedException + * @see \Cake\ORM\Table::delete() for options and events related to this method. + */ + public function deleteManyOrFail(iterable $entities, array $options = []): iterable + { + $failed = $this->_deleteMany($entities, $options); + + if ($failed !== null) { + throw new PersistenceFailedException($failed, ['deleteMany']); + } + + return $entities; + } + + /** + * @param iterable<\Cake\Datasource\EntityInterface> $entities Entities to delete. + * @param array $options Options used. + * @return \Cake\Datasource\EntityInterface|null + */ + protected function _deleteMany(iterable $entities, array $options = []): ?EntityInterface + { + $options = new ArrayObject($options + [ + 'atomic' => true, + 'checkRules' => true, + '_primary' => true, + ]); + + $failed = $this->_executeTransaction(function () use ($entities, $options) { + foreach ($entities as $entity) { + if (!$this->_processDelete($entity, $options)) { + return $entity; + } + } + + return null; + }, $options['atomic']); + + if ($failed === null && $this->_transactionCommitted($options['atomic'], $options['_primary'])) { + foreach ($entities as $entity) { + $this->dispatchEvent('Model.afterDeleteCommit', [ + 'entity' => $entity, + 'options' => $options, + ]); + } + } + + return $failed; + } + /** * Try to delete an entity or throw a PersistenceFailedException if the entity is new, * has no primary key value, application rules checks failed or the delete was aborted by a callback. * * @param \Cake\Datasource\EntityInterface $entity The entity to remove. - * @param array|\ArrayAccess $options The options for the delete. - * @return bool success + * @param array $options The options for the delete. + * @return true * @throws \Cake\ORM\Exception\PersistenceFailedException * @see \Cake\ORM\Table::delete() */ - public function deleteOrFail(EntityInterface $entity, $options = []) + public function deleteOrFail(EntityInterface $entity, array $options = []): bool { $deleted = $this->delete($entity, $options); if ($deleted === false) { throw new PersistenceFailedException($entity, ['delete']); } - return $deleted; + return true; } /** @@ -2126,13 +2716,15 @@ public function deleteOrFail(EntityInterface $entity, $options = []) * dependent associations, and clear out join tables for BelongsToMany associations. * * @param \Cake\Datasource\EntityInterface $entity The entity to delete. - * @param \ArrayObject $options The options for the delete. + * @param \ArrayObject $options The options for the delete. * @throws \InvalidArgumentException if there are no primary key values of the * passed entity * @return bool success */ - protected function _processDelete($entity, $options) + protected function _processDelete(EntityInterface $entity, ArrayObject $options): bool { + $this->assertEntityClass($entity); + if ($entity->isNew()) { return false; } @@ -2149,93 +2741,195 @@ protected function _processDelete($entity, $options) $event = $this->dispatchEvent('Model.beforeDelete', [ 'entity' => $entity, - 'options' => $options + 'options' => $options, ]); if ($event->isStopped()) { - return $event->getResult(); + return (bool)$event->getResult(); } - $this->_associations->cascadeDelete( + $success = $this->_associations->cascadeDelete( $entity, - ['_primary' => false] + $options->getArrayCopy() + ['_primary' => false] + $options->getArrayCopy(), ); + if (!$success) { + return $success; + } - $query = $this->query(); - $conditions = (array)$entity->extract($primaryKey); - $statement = $query->delete() - ->where($conditions) + $statement = $this->deleteQuery() + ->where($entity->extract($primaryKey)) ->execute(); - $success = $statement->rowCount() > 0; - if (!$success) { - return $success; + if ($statement->rowCount() < 1) { + return false; } $this->dispatchEvent('Model.afterDelete', [ 'entity' => $entity, - 'options' => $options + 'options' => $options, ]); - return $success; + return true; } /** * Returns true if the finder exists for the table * * @param string $type name of finder to check - * * @return bool */ - public function hasFinder($type) + public function hasFinder(string $type): bool { $finder = 'find' . $type; - return method_exists($this, $finder) || ($this->_behaviors && $this->_behaviors->hasFinder($type)); + return method_exists($this, $finder) || $this->_behaviors->hasFinder($type); } /** - * Calls a finder method directly and applies it to the passed query, - * if no query is passed a new one will be created and returned + * Calls a finder method and applies it to the passed query. * - * @param string $type name of the finder to be called - * @param \Cake\ORM\Query $query The query object to apply the finder options to - * @param array $options List of options to pass to the finder - * @return \Cake\ORM\Query + * @internal + * @template TSubject of \Cake\Datasource\EntityInterface|array + * @param string $type Name of the finder to be called. + * @param \Cake\ORM\Query\SelectQuery $query The query object to apply the finder options to. + * @param mixed ...$args Arguments that match up to finder-specific parameters + * @return \Cake\ORM\Query\SelectQuery * @throws \BadMethodCallException + * @uses findAll() + * @uses findList() + * @uses findThreaded() */ - public function callFinder($type, Query $query, array $options = []) + public function callFinder(string $type, SelectQuery $query, mixed ...$args): SelectQuery { - $query->applyOptions($options); - $options = $query->getOptions(); $finder = 'find' . $type; if (method_exists($this, $finder)) { - return $this->{$finder}($query, $options); + return $this->invokeFinder($this->{$finder}(...), $query, $args); } - if ($this->_behaviors && $this->_behaviors->hasFinder($type)) { - return $this->_behaviors->callFinder($type, [$query, $options]); + if ($this->_behaviors->hasFinder($type)) { + return $this->_behaviors->callFinder($type, $query, ...$args); } - throw new BadMethodCallException( - sprintf('Unknown finder method "%s"', $type) + throw new BadMethodCallException(sprintf( + 'Unknown finder method `%s` on `%s`.', + $type, + static::class, + )); + } + + /** + * @internal + * @template TSubject of \Cake\Datasource\EntityInterface|array + * @param \Closure $callable Callable. + * @param \Cake\ORM\Query\SelectQuery $query The query object. + * @param array $args Arguments for the callable. + * @return \Cake\ORM\Query\SelectQuery + */ + public function invokeFinder(Closure $callable, SelectQuery $query, array $args): SelectQuery + { + $reflected = new ReflectionFunction($callable); + $params = $reflected->getParameters(); + $secondParam = $params[1] ?? null; + + $secondParamType = $secondParam?->getType(); + $secondParamTypeName = $secondParamType instanceof ReflectionNamedType ? $secondParamType->getName() : null; + + $secondParamIsOptions = ( + count($params) === 2 && + $secondParam?->name === 'options' && + !$secondParam->isVariadic() && + ($secondParamType === null || $secondParamTypeName === 'array') ); + + if (($args === [] || isset($args[0])) && $secondParamIsOptions) { + // Backwards compatibility of 4.x style finders + // with signature `findFoo(SelectQuery $query, array $options)` + // called as `find('foo')` or `find('foo', [..])` + if (isset($args[0])) { + deprecationWarning( + '5.0.0', + 'Calling finders with options arrays is deprecated.' + . ' Update your finder methods to used named arguments instead.', + ); + $args = $args[0]; + } + $query->applyOptions($args); + + return $callable($query, $query->getOptions()); + } + + // Backwards compatibility for 4.x style finders with signatures like + // `findFoo(SelectQuery $query, array $options)` called as + // `find('foo', key: $value)`. + if (!isset($args[0]) && $secondParamIsOptions) { + $query->applyOptions($args); + + return $callable($query, $query->getOptions()); + } + + // Backwards compatibility for core finders like `findList()` called in 4.x + // style with an array `find('list', ['valueField' => 'foo'])` instead of + // `find('list', valueField: 'foo')` + if (isset($args[0]) && is_array($args[0]) && $secondParamTypeName !== 'array') { + deprecationWarning( + '5.0.0', + "Calling `{$reflected->getName()}` finder with options array is deprecated." + . ' Use named arguments instead.', + ); + + $args = $args[0]; + } + + if ($args) { + $unNamedArgs = []; + $namedArgs = []; + foreach ($args as $key => $value) { + if (is_int($key)) { + $unNamedArgs[$key] = $value; + } else { + $namedArgs[$key] = $value; + } + } + + $query->applyOptions($namedArgs); + // Fetch custom args without the query options. + $args = $unNamedArgs + array_intersect_key($args, $query->getOptions()); + + unset($params[0]); + $lastParam = end($params); + reset($params); + + if ($lastParam === false || !$lastParam->isVariadic()) { + $paramNames = []; + foreach ($params as $param) { + $paramNames[] = $param->getName(); + } + + foreach ($args as $key => $value) { + if (is_string($key) && !in_array($key, $paramNames, true)) { + unset($args[$key]); + } + } + } + } + + return $callable($query, ...$args); } /** - * Provides the dynamic findBy and findByAll methods. + * Provides the dynamic findBy and findAllBy methods. * * @param string $method The method name that was fired. * @param array $args List of arguments passed to the function. - * @return mixed + * @return \Cake\ORM\Query\SelectQuery * @throws \BadMethodCallException when there are missing arguments, or when * and & or are combined. */ - protected function _dynamicFinder($method, $args) + protected function _dynamicFinder(string $method, array $args): SelectQuery { $method = Inflector::underscore($method); preg_match('/^find_([\w]+)_by_/', $method, $matches); - if (empty($matches)) { + if (!$matches) { // find_by_ is 8 characters. $fields = substr($method, 8); $findType = 'all'; @@ -2243,16 +2937,16 @@ protected function _dynamicFinder($method, $args) $fields = substr($method, strlen($matches[0])); $findType = Inflector::variable($matches[1]); } - $hasOr = strpos($fields, '_or_'); - $hasAnd = strpos($fields, '_and_'); + $hasOr = str_contains($fields, '_or_'); + $hasAnd = str_contains($fields, '_and_'); - $makeConditions = function ($fields, $args) { + $makeConditions = function ($fields, $args): array { $conditions = []; if (count($args) < count($fields)) { throw new BadMethodCallException(sprintf( 'Not enough arguments for magic finder. Got %s required %s', count($args), - count($fields) + count($fields), )); } foreach ($fields as $field) { @@ -2262,28 +2956,25 @@ protected function _dynamicFinder($method, $args) return $conditions; }; - if ($hasOr !== false && $hasAnd !== false) { + if ($hasOr && $hasAnd) { throw new BadMethodCallException( - 'Cannot mix "and" & "or" in a magic finder. Use find() instead.' + 'Cannot mix "and" & "or" in a magic finder. Use find() instead.', ); } - $conditions = []; if ($hasOr === false && $hasAnd === false) { $conditions = $makeConditions([$fields], $args); - } elseif ($hasOr !== false) { + } elseif ($hasOr) { $fields = explode('_or_', $fields); $conditions = [ - 'OR' => $makeConditions($fields, $args) + 'OR' => $makeConditions($fields, $args), ]; - } elseif ($hasAnd !== false) { + } else { $fields = explode('_and_', $fields); $conditions = $makeConditions($fields, $args); } - return $this->find($findType, [ - 'conditions' => $conditions, - ]); + return $this->find($findType, conditions: $conditions); } /** @@ -2297,9 +2988,9 @@ protected function _dynamicFinder($method, $args) * @return mixed * @throws \BadMethodCallException */ - public function __call($method, $args) + public function __call(string $method, array $args): mixed { - if ($this->_behaviors && $this->_behaviors->hasMethod($method)) { + if ($this->_behaviors->hasMethod($method)) { return $this->_behaviors->call($method, $args); } if (preg_match('/^find(?:\w+)?By/', $method) > 0) { @@ -2307,7 +2998,7 @@ public function __call($method, $args) } throw new BadMethodCallException( - sprintf('Unknown method "%s"', $method) + sprintf('Unknown method `%s` called on `%s`', $method, static::class), ); } @@ -2317,16 +3008,18 @@ public function __call($method, $args) * * @param string $property the association name * @return \Cake\ORM\Association - * @throws \RuntimeException if no association with such name exists + * @throws \Cake\Database\Exception\DatabaseException if no association with such name exists */ - public function __get($property) + public function __get(string $property): Association { $association = $this->_associations->get($property); if (!$association) { - throw new RuntimeException(sprintf( - 'Table "%s" is not associated with "%s"', - get_class($this), - $property + throw new DatabaseException(sprintf( + 'Undefined property `%s`. ' . + 'You have not defined the `%s` association on `%s`.', + $property, + $property, + static::class, )); } @@ -2340,7 +3033,7 @@ public function __get($property) * @param string $property the association name * @return bool */ - public function __isset($property) + public function __isset(string $property): bool { return $this->_associations->has($property); } @@ -2349,16 +3042,30 @@ public function __isset($property) * Get the object used to marshal/convert array data into objects. * * Override this method if you want a table object to use custom - * marshalling logic. + * marshaling logic. * - * @return \Cake\ORM\Marshaller + * @return \Cake\ORM\Marshaller * @see \Cake\ORM\Marshaller */ - public function marshaller() + public function marshaller(): Marshaller { return new Marshaller($this); } + /** + * {@inheritDoc} + * + * @return TEntity + */ + public function newEmptyEntity(): EntityInterface + { + $class = $this->getEntityClass(); + /** @var TEntity $entity */ + $entity = new $class([], ['source' => $this->getRegistryAlias()]); + + return $entity; + } + /** * {@inheritDoc} * @@ -2374,17 +3081,17 @@ public function marshaller() * ``` * * You can limit fields that will be present in the constructed entity by - * passing the `fieldList` option, which is also accepted for associations: + * passing the `fields` option, which is also accepted for associations: * * ``` * $article = $this->Articles->newEntity($this->request->getData(), [ - * 'fieldList' => ['title', 'body', 'tags', 'comments'], - * 'associated' => ['Tags', 'Comments.Users' => ['fieldList' => 'username']] + * 'fields' => ['title', 'body', 'tags', 'comments'], + * 'associated' => ['Tags', 'Comments.Users' => ['fields' => 'username']] * ] * ); * ``` * - * The `fieldList` option lets remove or restrict input data from ending up in + * The `fields` option lets remove or restrict input data from ending up in * the entity. If you'd like to relax the entity's default accessible fields, * you can use the `accessibleFields` option: * @@ -2412,20 +3119,17 @@ public function marshaller() * * You can use the `Model.beforeMarshal` event to modify request data * before it is converted into entities. + * + * @param array $data The data to build an entity with. + * @param array $options A list of options for the object hydration. + * @return TEntity + * @see \Cake\ORM\Marshaller::one() */ - public function newEntity($data = null, array $options = []) + public function newEntity(array $data, array $options = []): EntityInterface { - if ($data === null) { - $class = $this->getEntityClass(); - - return new $class([], ['source' => $this->getRegistryAlias()]); - } - if (!isset($options['associated'])) { - $options['associated'] = $this->_associations->keys(); - } - $marshaller = $this->marshaller(); + $options['associated'] ??= $this->_associations->keys(); - return $marshaller->one($data, $options); + return $this->marshaller()->one($data, $options); } /** @@ -2443,27 +3147,28 @@ public function newEntity($data = null, array $options = []) * ``` * * You can limit fields that will be present in the constructed entities by - * passing the `fieldList` option, which is also accepted for associations: + * passing the `fields` option, which is also accepted for associations: * * ``` * $articles = $this->Articles->newEntities($this->request->getData(), [ - * 'fieldList' => ['title', 'body', 'tags', 'comments'], - * 'associated' => ['Tags', 'Comments.Users' => ['fieldList' => 'username']] + * 'fields' => ['title', 'body', 'tags', 'comments'], + * 'associated' => ['Tags', 'Comments.Users' => ['fields' => 'username']] * ] * ); * ``` * * You can use the `Model.beforeMarshal` event to modify request data * before it is converted into entities. + * + * @param array $data The data to build an entity with. + * @param array $options A list of options for the objects hydration. + * @return array An array of hydrated records. */ - public function newEntities(array $data, array $options = []) + public function newEntities(array $data, array $options = []): array { - if (!isset($options['associated'])) { - $options['associated'] = $this->_associations->keys(); - } - $marshaller = $this->marshaller(); + $options['associated'] ??= $this->_associations->keys(); - return $marshaller->many($data, $options); + return $this->marshaller()->many($data, $options); } /** @@ -2474,16 +3179,24 @@ public function newEntities(array $data, array $options = []) * the data merged, but those that cannot, will be discarded. * * You can limit fields that will be present in the merged entity by - * passing the `fieldList` option, which is also accepted for associations: + * passing the `fields` option, which is also accepted for associations: * * ``` * $article = $this->Articles->patchEntity($article, $this->request->getData(), [ - * 'fieldList' => ['title', 'body', 'tags', 'comments'], - * 'associated' => ['Tags', 'Comments.Users' => ['fieldList' => 'username']] + * 'fields' => ['title', 'body', 'tags', 'comments'], + * 'associated' => ['Tags', 'Comments.Users' => ['fields' => 'username']] * ] * ); * ``` * + * ``` + * $article = $this->Articles->patchEntity($article, $this->request->getData(), [ + * 'associated' => [ + * 'Tags' => ['accessibleFields' => ['*' => true]] + * ] + * ]); + * ``` + * * By default, the data is validated before being passed to the entity. In * the case of invalid fields, those will not be assigned to the entity. * The `validate` option can be used to disable validation on the passed data: @@ -2501,15 +3214,22 @@ public function newEntities(array $data, array $options = []) * presently has an identical value, the setter will not be called, and the * property will not be marked as dirty. This is an optimization to prevent unnecessary field * updates when persisting entities. + * + * @template TPatchedEntity of \Cake\Datasource\EntityInterface + * @param TPatchedEntity $entity the entity that will get the + * data merged in + * @param array $data key value list of fields to be merged into the entity + * @param array $options A list of options for the object hydration. + * @return TPatchedEntity + * @see \Cake\ORM\Marshaller::merge() */ - public function patchEntity(EntityInterface $entity, array $data, array $options = []) + public function patchEntity(EntityInterface $entity, array $data, array $options = []): EntityInterface { - if (!isset($options['associated'])) { - $options['associated'] = $this->_associations->keys(); - } - $marshaller = $this->marshaller(); + $this->assertEntityClass($entity); + + $options['associated'] ??= $this->_associations->keys(); - return $marshaller->merge($entity, $data, $options); + return $this->marshaller()->merge($entity, $data, $options); } /** @@ -2517,34 +3237,42 @@ public function patchEntity(EntityInterface $entity, array $data, array $options * * Those entries in `$entities` that cannot be matched to any record in * `$data` will be discarded. Records in `$data` that could not be matched will - * be marshalled as a new entity. + * be marshaled as a new entity. * * When merging HasMany or BelongsToMany associations, all the entities in the * `$data` array will appear, those that can be matched by primary key will get * the data merged, but those that cannot, will be discarded. * * You can limit fields that will be present in the merged entities by - * passing the `fieldList` option, which is also accepted for associations: + * passing the `fields` option, which is also accepted for associations: * * ``` * $articles = $this->Articles->patchEntities($articles, $this->request->getData(), [ - * 'fieldList' => ['title', 'body', 'tags', 'comments'], - * 'associated' => ['Tags', 'Comments.Users' => ['fieldList' => 'username']] + * 'fields' => ['title', 'body', 'tags', 'comments'], + * 'associated' => ['Tags', 'Comments.Users' => ['fields' => 'username']] * ] * ); * ``` * * You can use the `Model.beforeMarshal` event to modify request data * before it is converted into entities. + * + * @template TPatchedEntity of \Cake\Datasource\EntityInterface + * @param iterable $entities the entities that will get the + * data merged in + * @param array $data list of arrays to be merged into the entities + * @param array $options A list of options for the objects hydration. + * @return array */ - public function patchEntities($entities, array $data, array $options = []) + public function patchEntities(iterable $entities, array $data, array $options = []): array { - if (!isset($options['associated'])) { - $options['associated'] = $this->_associations->keys(); + foreach ($entities as $entity) { + $this->assertEntityClass($entity); } - $marshaller = $this->marshaller(); - return $marshaller->mergeMany($entities, $data, $options); + $options['associated'] ??= $this->_associations->keys(); + + return $this->marshaller()->mergeMany($entities, $data, $options); } /** @@ -2576,27 +3304,27 @@ public function patchEntities($entities, array $data, array $options = []) * the data to be validated. * * @param mixed $value The value of column to be checked for uniqueness. - * @param array $options The options array, optionally containing the 'scope' key. + * @param array $options The options array, optionally containing the 'scope' key. * May also be the validation context, if there are no options. * @param array|null $context Either the validation context or null. * @return bool True if the value is unique, or false if a non-scalar, non-unique value was given. */ - public function validateUnique($value, array $options, array $context = null) + public function validateUnique(mixed $value, array $options = [], ?array $context = null): bool { if ($context === null) { $context = $options; } - $entity = new Entity( + $entity = new ($this->getEntityClass())( $context['data'], [ 'useSetters' => false, 'markNew' => $context['newRecord'], - 'source' => $this->getRegistryAlias() - ] + 'source' => $this->getRegistryAlias(), + ], ); $fields = array_merge( [$context['field']], - isset($options['scope']) ? (array)$options['scope'] : [] + isset($options['scope']) ? (array)$options['scope'] : [], ); $values = $entity->extract($fields); foreach ($values as $field) { @@ -2604,7 +3332,8 @@ public function validateUnique($value, array $options, array $context = null) return false; } } - $rule = new IsUnique($fields, $options); + $class = static::IS_UNIQUE_CLASS; + $rule = new $class($fields, $options); return $rule($entity, ['repository' => $this]); } @@ -2621,6 +3350,7 @@ public function validateUnique($value, array $options, array $context = null) * The conventional method map is: * * - Model.beforeMarshal => beforeMarshal + * - Model.afterMarshal => afterMarshal * - Model.buildValidator => buildValidator * - Model.beforeFind => beforeFind * - Model.beforeSave => beforeSave @@ -2632,12 +3362,13 @@ public function validateUnique($value, array $options, array $context = null) * - Model.beforeRules => beforeRules * - Model.afterRules => afterRules * - * @return array + * @return array */ - public function implementedEvents() + public function implementedEvents(): array { $eventMap = [ 'Model.beforeMarshal' => 'beforeMarshal', + 'Model.afterMarshal' => 'afterMarshal', 'Model.buildValidator' => 'buildValidator', 'Model.beforeFind' => 'beforeFind', 'Model.beforeSave' => 'beforeSave', @@ -2667,22 +3398,11 @@ public function implementedEvents() * @param \Cake\ORM\RulesChecker $rules The rules object to be modified. * @return \Cake\ORM\RulesChecker */ - public function buildRules(RulesChecker $rules) + public function buildRules(RulesChecker $rules): RulesChecker { return $rules; } - /** - * Gets a SaveOptionsBuilder instance. - * - * @param array $options Options to parse by the builder. - * @return \Cake\ORM\SaveOptionsBuilder - */ - public function getSaveOptionsBuilder(array $options = []) - { - return new SaveOptionsBuilder($this, $options); - } - /** * Loads the specified associations in the passed entity or list of entities * by executing extra queries in the database and merging the results in the @@ -2708,45 +3428,56 @@ public function getSaveOptionsBuilder(array $options = []) * * The properties for the associations to be loaded will be overwritten on each entity. * - * @param \Cake\Datasource\EntityInterface|array $entities a single entity or list of entities + * @param TEntity|array $entities a single entity or list of entities * @param array $contain A `contain()` compatible array. - * @see \Cake\ORM\Query::contain() - * @return \Cake\Datasource\EntityInterface|array + * @see \Cake\ORM\Query\SelectQuery::contain() + * @return TEntity|array */ - public function loadInto($entities, array $contain) + public function loadInto(EntityInterface|array $entities, array $contain): EntityInterface|array { - return (new LazyEagerLoader)->loadInto($entities, $contain, $this); + if ($entities instanceof EntityInterface) { + $this->assertEntityClass($entities); + } else { + foreach ($entities as $entity) { + $this->assertEntityClass($entity); + } + } + + /** @var TEntity|array $result */ + $result = (new LazyEagerLoader())->loadInto($entities, $contain, $this); + + return $result; } /** - * {@inheritDoc} + * @inheritDoc */ - protected function validationMethodExists($method) + protected function validationMethodExists(string $name): bool { - return method_exists($this, $method) || $this->behaviors()->hasMethod($method); + return method_exists($this, $name) || $this->behaviors()->hasMethod($name); } /** * Returns an array that can be used to describe the internal state of this * object. * - * @return array + * @return array */ - public function __debugInfo() + public function __debugInfo(): array { $conn = $this->getConnection(); - $associations = $this->_associations; - $behaviors = $this->_behaviors; return [ 'registryAlias' => $this->getRegistryAlias(), 'table' => $this->getTable(), 'alias' => $this->getAlias(), 'entityClass' => $this->getEntityClass(), - 'associations' => $associations ? $associations->keys() : false, - 'behaviors' => $behaviors ? $behaviors->loaded() : false, + /** @phpstan-ignore isset.initializedProperty */ + 'associations' => isset($this->_associations) ? $this->_associations->keys() : [], + /** @phpstan-ignore isset.initializedProperty */ + 'behaviors' => isset($this->_behaviors) ? $this->_behaviors->loaded() : [], 'defaultConnection' => static::defaultConnectionName(), - 'connectionName' => $conn ? $conn->configName() : null + 'connectionName' => $conn->configName(), ]; } } diff --git a/src/ORM/TableEventsTrait.php b/src/ORM/TableEventsTrait.php new file mode 100644 index 00000000000..95a5b162ee6 --- /dev/null +++ b/src/ORM/TableEventsTrait.php @@ -0,0 +1,208 @@ + $event Model event. + * @param \ArrayObject $data Data to be saved. + * @param \ArrayObject $options Options. + * @return void + */ + public function beforeMarshal(EventInterface $event, ArrayObject $data, ArrayObject $options): void + { + } + + /** + * The Model.afterMarshal event is fired after request data is converted into entities. + * Event handlers will get the converted entities, original request data and the options provided + * to the patchEntity() or newEntity() call. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event Model event. + * @param \Cake\Datasource\EntityInterface $entity The entity to be saved. + * @param \ArrayObject $data Data to be saved. + * @param \ArrayObject $options Options. + * @return void + */ + public function afterMarshal( + EventInterface $event, + EntityInterface $entity, + ArrayObject $data, + ArrayObject $options, + ): void { + } + + /** + * The Model.buildValidator event is fired when $name validator is created. + * Behaviors, can use this hook to add in validation methods. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event Model event. + * @param \Cake\Validation\Validator $validator Validator. + * @param string $name Name. + * @return void + */ + public function buildValidator(EventInterface $event, Validator $validator, string $name): void + { + } + + /** + * The Model.beforeFind event is fired before each find operation. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event Model event. + * @param \Cake\ORM\Query\SelectQuery $query Query. + * @param \ArrayObject $options Options. + * @param bool $primary `true` if it is the root query, `false` if it is the associated query. + * @return void + */ + public function beforeFind(EventInterface $event, SelectQuery $query, ArrayObject $options, bool $primary): void + { + } + + /** + * The Model.beforeSave event is fired before each entity is saved. + * Stopping this event will abort the save operation. + * When the event is stopped the result of the event will be returned. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event Model event. + * @param \Cake\Datasource\EntityInterface $entity The entity to be saved. + * @param \ArrayObject $options Options. + * @return void + */ + public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void + { + } + + /** + * The Model.afterSave event is fired after an entity is saved. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event Model event. + * @param \Cake\Datasource\EntityInterface $entity Saved entity. + * @param \ArrayObject $options Options. + * @return void + */ + public function afterSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void + { + } + + /** + * The Model.afterSaveCommit event is fired after the transaction in which the save operation is wrapped has been + * committed. It’s also triggered for non atomic saves where database operations are implicitly committed. The event + * is triggered only for the primary table on which save() is directly called. The event is not triggered if a + * transaction is started before calling save. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event Model event. + * @param \Cake\Datasource\EntityInterface $entity Saved entity. + * @param \ArrayObject $options Options. + * @return void + */ + public function afterSaveCommit(EventInterface $event, EntityInterface $entity, ArrayObject $options): void + { + } + + /** + * The Model.beforeDelete event is fired before an entity is deleted. + * By stopping this event you will abort the delete operation. + * When the event is stopped the result of the event will be returned. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event Model event. + * @param \Cake\Datasource\EntityInterface $entity Entity to be deleted. + * @param \ArrayObject $options Options. + * @return void + */ + public function beforeDelete(EventInterface $event, EntityInterface $entity, ArrayObject $options): void + { + } + + /** + * The Model.afterDelete event is fired after an entity has been deleted. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event Model event. + * @param \Cake\Datasource\EntityInterface $entity Deleted entity. + * @param \ArrayObject $options Options. + * @return void + */ + public function afterDelete(EventInterface $event, EntityInterface $entity, ArrayObject $options): void + { + } + + /** + * The Model.afterDeleteCommit event is fired after the transaction in which the delete operation is wrapped has + * been committed. It's also triggered for non atomic deletes where database operations are implicitly committed. + * The event is triggered only for the primary table on which delete() is directly called. The event is not + * triggered if a transaction is started before calling delete. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event Model event. + * @param \Cake\Datasource\EntityInterface $entity Deleted entity. + * @param \ArrayObject $options Options. + * @return void + */ + public function afterDeleteCommit(EventInterface $event, EntityInterface $entity, ArrayObject $options): void + { + } + + /** + * The Model.beforeRules event is fired before an entity has had rules applied. + * By stopping this event, you can halt the rules checking and set the result of applying rules. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event Model event. + * @param \Cake\Datasource\EntityInterface $entity The entity to be saved. + * @param \ArrayObject $options Options. + * @param string $operation Operation. + * @return void + */ + public function beforeRules( + EventInterface $event, + EntityInterface $entity, + ArrayObject $options, + string $operation, + ): void { + } + + /** + * The Model.afterRules event is fired after an entity has rules applied. + * By stopping this event, you can return the final value of the rules checking operation. + * + * @param \Cake\Event\EventInterface<\Cake\ORM\Table> $event Model event. + * @param \Cake\Datasource\EntityInterface $entity The entity to be saved. + * @param \ArrayObject $options Options. + * @param bool $result Result. + * @param string $operation Operation. + * @return void + */ + public function afterRules( + EventInterface $event, + EntityInterface $entity, + ArrayObject $options, + bool $result, + string $operation, + ): void { + } +} diff --git a/src/ORM/TableRegistry.php b/src/ORM/TableRegistry.php index 38d589e5408..1c6671e1bdb 100644 --- a/src/ORM/TableRegistry.php +++ b/src/ORM/TableRegistry.php @@ -1,4 +1,6 @@ 'my_users']); + * TableRegistry::getTableLocator()->setConfig('Users', ['table' => 'my_users']); * ``` * * Configuration data is stored *per alias* if you use the same table with @@ -38,60 +41,26 @@ * * ### Getting instances * - * You can fetch instances out of the registry using get(). One instance is stored - * per alias. Once an alias is populated the same instance will always be returned. - * This is used to make the ORM use less memory and help make cyclic references easier - * to solve. + * You can fetch instances out of the registry through `TableLocator::get()`. + * One instance is stored per alias. Once an alias is populated the same + * instance will always be returned. This reduces the ORM memory cost and + * helps make cyclic references easier to solve. * * ``` - * $table = TableRegistry::get('Users', $config); + * $table = TableRegistry::getTableLocator()->get('Users', $config); * ``` */ class TableRegistry { - - /** - * LocatorInterface implementation instance. - * - * @var \Cake\ORM\Locator\LocatorInterface - */ - protected static $_locator; - - /** - * Default LocatorInterface implementation class. - * - * @var string - */ - protected static $_defaultLocatorClass = 'Cake\ORM\Locator\TableLocator'; - - /** - * Sets and returns a singleton instance of LocatorInterface implementation. - * - * @param \Cake\ORM\Locator\LocatorInterface|null $locator Instance of a locator to use. - * @return \Cake\ORM\Locator\LocatorInterface - * @deprecated 3.5.0 Use getTableLocator()/setTableLocator() instead. - */ - public static function locator(LocatorInterface $locator = null) - { - if ($locator) { - static::setTableLocator($locator); - } - - return static::getTableLocator(); - } - /** * Returns a singleton instance of LocatorInterface implementation. * * @return \Cake\ORM\Locator\LocatorInterface */ - public static function getTableLocator() + public static function getTableLocator(): LocatorInterface { - if (!static::$_locator) { - static::$_locator = new static::$_defaultLocatorClass(); - } - - return static::$_locator; + /** @var \Cake\ORM\Locator\LocatorInterface */ + return FactoryLocator::get('Table'); } /** @@ -100,91 +69,8 @@ public static function getTableLocator() * @param \Cake\ORM\Locator\LocatorInterface $tableLocator Instance of a locator to use. * @return void */ - public static function setTableLocator(LocatorInterface $tableLocator) - { - static::$_locator = $tableLocator; - } - - /** - * Stores a list of options to be used when instantiating an object - * with a matching alias. - * - * @param string|null $alias Name of the alias - * @param array|null $options list of options for the alias - * @return array The config data. - */ - public static function config($alias = null, $options = null) - { - return static::getTableLocator()->config($alias, $options); - } - - /** - * Get a table instance from the registry. - * - * See options specification in {@link TableLocator::get()}. - * - * @param string $alias The alias name you want to get. - * @param array $options The options you want to build the table with. - * @return \Cake\ORM\Table - */ - public static function get($alias, array $options = []) - { - return static::getTableLocator()->get($alias, $options); - } - - /** - * Check to see if an instance exists in the registry. - * - * @param string $alias The alias to check for. - * @return bool - */ - public static function exists($alias) - { - return static::getTableLocator()->exists($alias); - } - - /** - * Set an instance. - * - * @param string $alias The alias to set. - * @param \Cake\ORM\Table $object The table to set. - * @return \Cake\ORM\Table - */ - public static function set($alias, Table $object) - { - return static::getTableLocator()->set($alias, $object); - } - - /** - * Removes an instance from the registry. - * - * @param string $alias The alias to remove. - * @return void - */ - public static function remove($alias) - { - static::getTableLocator()->remove($alias); - } - - /** - * Clears the registry of configuration and instances. - * - * @return void - */ - public static function clear() - { - static::getTableLocator()->clear(); - } - - /** - * Proxy for static calls on a locator. - * - * @param string $name Method name. - * @param array $arguments Method arguments. - * @return mixed - */ - public static function __callStatic($name, $arguments) + public static function setTableLocator(LocatorInterface $tableLocator): void { - return static::getTableLocator()->$name(...$arguments); + FactoryLocator::add('Table', $tableLocator); } } diff --git a/src/ORM/bootstrap.php b/src/ORM/bootstrap.php new file mode 100644 index 00000000000..8b23a2d4e6a --- /dev/null +++ b/src/ORM/bootstrap.php @@ -0,0 +1,21 @@ +=5.6.0", - "cakephp/collection": "^3.0.0", - "cakephp/core": "^3.0.0", - "cakephp/datasource": "^3.1.2", - "cakephp/database": "^3.1.4", - "cakephp/event": "^3.0.0", - "cakephp/utility": "^3.0.0", - "cakephp/validation": "^3.0.0" + "php": ">=8.2", + "cakephp/collection": "^5.4.0", + "cakephp/core": "^5.4.0", + "cakephp/datasource": "^5.4.0", + "cakephp/database": "^5.4.0", + "cakephp/event": "^5.4.0", + "cakephp/utility": "^5.4.0", + "cakephp/validation": "^5.4.0" }, - "suggest": { - "cakephp/i18n": "If you are using Translate / Timestamp Behavior." + "require-dev": { + "cakephp/cache": "^5.4.0", + "cakephp/i18n": "^5.4.0" }, "autoload": { "psr-4": { "Cake\\ORM\\": "." + }, + "files": [ + "bootstrap.php" + ] + }, + "suggest": { + "cakephp/cache": "If you decide to use Query caching.", + "cakephp/i18n": "If you are using Translate/TimestampBehavior or Chronos types." + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" } } } diff --git a/src/ORM/phpstan.neon.dist b/src/ORM/phpstan.neon.dist new file mode 100644 index 00000000000..4760815188f --- /dev/null +++ b/src/ORM/phpstan.neon.dist @@ -0,0 +1,23 @@ +parameters: + level: 8 + treatPhpDocTypesAsCertain: false + bootstrapFiles: + - tests/phpstan-bootstrap.php + paths: + - ./ + excludePaths: + - vendor/ + ignoreErrors: + - + identifier: trait.unused + - + identifier: missingType.iterableValue + - '#Unsafe usage of new static\(\).#' + - "#^Method Cake\\\\ORM\\\\Query\\\\SelectQuery\\:\\:find\\(\\) should return static\\(Cake\\\\ORM\\\\Query\\\\SelectQuery\\\\) but returns Cake\\\\ORM\\\\Query\\\\SelectQuery\\\\.$#" + - '#^PHPDoc tag @var with type callable\(\): mixed is not subtype of native type Closure\(string\): string\.$#' + + - + message: '#^Call to function assert\(\) with false and string will always evaluate to false\.$#' + identifier: function.impossibleType + count: 1 + path: EagerLoader.php diff --git a/src/ORM/tests/phpstan-bootstrap.php b/src/ORM/tests/phpstan-bootstrap.php new file mode 100644 index 00000000000..0e60e7fbe4e --- /dev/null +++ b/src/ORM/tests/phpstan-bootstrap.php @@ -0,0 +1,60 @@ + 'App', + 'encoding' => 'UTF-8', +]); + +ini_set('intl.default_locale', 'en_US'); +ini_set('session.gc_divisor', '1'); +ini_set('assert.exception', '1'); diff --git a/src/Routing/Asset.php b/src/Routing/Asset.php new file mode 100644 index 00000000000..16708c63adf --- /dev/null +++ b/src/Routing/Asset.php @@ -0,0 +1,371 @@ + $options Options array. Possible keys: + * `fullBase` Return full URL with domain name + * `pathPrefix` Path prefix for relative URLs + * `plugin` False value will prevent parsing path as a plugin + * `timestamp` Overrides the value of `Asset.timestamp` in Configure. + * Set to false to skip timestamp generation. + * Set to true to apply timestamps when debug is true. Set to 'force' to always + * enable timestamping regardless of debug value. + * @return string Generated URL + */ + public static function imageUrl(string $path, array $options = []): string + { + $pathPrefix = Configure::read('App.imageBaseUrl'); + + return static::url($path, $options + compact('pathPrefix')); + } + + /** + * Generates URL for given CSS file. + * + * Depending on options passed provides full URL with domain name. Also calls + * `Asset::assetTimestamp()` to add timestamp to local files. + * + * @param string $path Path string. + * @param array $options Options array. Possible keys: + * `fullBase` Return full URL with domain name + * `pathPrefix` Path prefix for relative URLs + * `ext` Asset extension to append + * `plugin` False value will prevent parsing path as a plugin + * `timestamp` Overrides the value of `Asset.timestamp` in Configure. + * Set to false to skip timestamp generation. + * Set to true to apply timestamps when debug is true. Set to 'force' to always + * enable timestamping regardless of debug value. + * @return string Generated URL + */ + public static function cssUrl(string $path, array $options = []): string + { + $pathPrefix = Configure::read('App.cssBaseUrl'); + $ext = '.css'; + + return static::url($path, $options + compact('pathPrefix', 'ext')); + } + + /** + * Generates URL for given javascript file. + * + * Depending on options passed provides full URL with domain name. Also calls + * `Asset::assetTimestamp()` to add timestamp to local files. + * + * @param string $path Path string. + * @param array $options Options array. Possible keys: + * `fullBase` Return full URL with domain name + * `pathPrefix` Path prefix for relative URLs + * `ext` Asset extension to append + * `plugin` False value will prevent parsing path as a plugin + * `timestamp` Overrides the value of `Asset.timestamp` in Configure. + * Set to false to skip timestamp generation. + * Set to true to apply timestamps when debug is true. Set to 'force' to always + * enable timestamping regardless of debug value. + * @return string Generated URL + */ + public static function scriptUrl(string $path, array $options = []): string + { + $pathPrefix = Configure::read('App.jsBaseUrl'); + $ext = '.js'; + + return static::url($path, $options + compact('pathPrefix', 'ext')); + } + + /** + * Generates URL for given asset file. + * + * Depending on options passed provides full URL with domain name. Also calls + * `Asset::assetTimestamp()` to add timestamp to local files. + * + * ### Options: + * + * - `fullBase` Boolean true or a string (e.g. https://example) to + * return full URL with protocol and domain name. + * - `pathPrefix` Path prefix for relative URLs + * - `ext` Asset extension to append + * - `plugin` False value will prevent parsing path as a plugin + * - `theme` Optional theme name + * - `timestamp` Overrides the value of `Asset.timestamp` in Configure. + * Set to false to skip timestamp generation. + * Set to true to apply timestamps when debug is true. Set to 'force' to always + * enable timestamping regardless of debug value. + * + * @param string $path Path string or URL array + * @param array $options Options array. + * @return string Generated URL + */ + public static function url(string $path, array $options = []): string + { + if (preg_match('/^data:[a-z]+\/[a-z]+;/', $path)) { + return $path; + } + + if (str_contains($path, '://') || preg_match('/^[a-z]+:/i', $path)) { + return ltrim(Router::url($path), '/'); + } + + $plugin = null; + if (!array_key_exists('plugin', $options) || $options['plugin'] !== false) { + [$plugin, $path] = static::pluginSplit($path); + } + if (!empty($options['pathPrefix']) && !str_starts_with($path, '/')) { + $pathPrefix = $options['pathPrefix']; + $placeHolderVal = ''; + if (!empty($options['theme'])) { + $placeHolderVal = static::inflectString($options['theme']) . '/'; + } elseif ($plugin !== null) { + $placeHolderVal = static::inflectString($plugin) . '/'; + } + + $path = str_replace('{plugin}', $placeHolderVal, $pathPrefix) . $path; + } + if ( + !empty($options['ext']) && + !str_contains($path, '?') && + !str_ends_with($path, $options['ext']) + ) { + $path .= $options['ext']; + } + + // Check again if path has protocol as `pathPrefix` could be for CDNs. + if (preg_match('|^([a-z0-9]+:)?//|', $path)) { + return Router::url($path); + } + + if ($plugin !== null) { + $path = static::inflectString($plugin) . '/' . $path; + } + + $optionTimestamp = null; + if (array_key_exists('timestamp', $options)) { + $optionTimestamp = $options['timestamp']; + } + $webPath = static::assetTimestamp( + static::webroot($path, $options), + $optionTimestamp, + ); + + $path = static::encodeUrl($webPath); + + if (!empty($options['fullBase'])) { + $fullBaseUrl = is_string($options['fullBase']) + ? $options['fullBase'] + : Router::fullBaseUrl(); + $path = rtrim($fullBaseUrl, '/') . '/' . ltrim($path, '/'); + } + + return $path; + } + + /** + * Encodes URL parts using rawurlencode(). + * + * @param string $url The URL to encode. + * @return string + */ + protected static function encodeUrl(string $url): string + { + $path = parse_url($url, PHP_URL_PATH); + if ($path === false || $path === null) { + $path = $url; + } + + $parts = array_map('rawurldecode', explode('/', $path)); + $parts = array_map('rawurlencode', $parts); + $encoded = implode('/', $parts); + + return str_replace($path, $encoded, $url); + } + + /** + * Adds a timestamp to a file based resource based on the value of `Asset.timestamp` in + * Configure. If Asset.timestamp is true and debug is true, or Asset.timestamp === 'force' + * a timestamp will be added. + * + * @param string $path The file path to timestamp, the path must be inside `App.wwwRoot` in Configure. + * @param string|bool|null $timestamp If set will overrule the value of `Asset.timestamp` in Configure. + * @return string Path with a timestamp added, or not. + */ + public static function assetTimestamp(string $path, string|bool|null $timestamp = null): string + { + if (str_contains($path, '?')) { + return $path; + } + + $timestamp ??= Configure::read('Asset.timestamp'); + $timestampEnabled = $timestamp === 'force' || ($timestamp === true && Configure::read('debug')); + if ($timestampEnabled) { + $filepath = (string)preg_replace( + '/^' . preg_quote(static::requestWebroot(), '/') . '/', + '', + urldecode($path), + ); + $webrootPath = Configure::read('App.wwwRoot') . str_replace('/', DIRECTORY_SEPARATOR, $filepath); + if (is_file($webrootPath)) { + return $path . '?' . filemtime($webrootPath); + } + // Check for plugins and org prefixed plugins. + $segments = explode('/', ltrim($filepath, '/')); + $plugin = Inflector::camelize($segments[0]); + if (!Plugin::isLoaded($plugin) && count($segments) > 1) { + $plugin = implode('/', [$plugin, Inflector::camelize($segments[1])]); + unset($segments[1]); + } + if (Plugin::isLoaded($plugin)) { + unset($segments[0]); + $pluginPath = Plugin::path($plugin) + . 'webroot' + . DIRECTORY_SEPARATOR + . implode(DIRECTORY_SEPARATOR, $segments); + if (is_file($pluginPath)) { + return $path . '?' . filemtime($pluginPath); + } + } + } + + return $path; + } + + /** + * Checks if a file exists when theme is used, if no file is found default location is returned. + * + * ### Options: + * + * - `theme` Optional theme name + * + * @param string $file The file to create a webroot path to. + * @param array $options Options array. + * @return string Web accessible path to file. + */ + public static function webroot(string $file, array $options = []): string + { + $options += ['theme' => null]; + $requestWebroot = static::requestWebroot(); + + $asset = explode('?', $file); + $asset[1] = isset($asset[1]) ? '?' . $asset[1] : ''; + $webPath = $requestWebroot . $asset[0]; + $file = $asset[0]; + + $themeName = $options['theme']; + if ($themeName) { + $file = trim($file, '/'); + $theme = static::inflectString($themeName) . '/'; + + if (DIRECTORY_SEPARATOR === '\\') { + $file = str_replace('/', '\\', $file); + } + + if (is_file(Configure::read('App.wwwRoot') . $theme . $file)) { + $webPath = $requestWebroot . $theme . $asset[0]; + } else { + $themePath = Plugin::path($themeName); + $path = $themePath . 'webroot/' . $file; + if (is_file($path)) { + $webPath = $requestWebroot . $theme . $asset[0]; + } + } + } + if (str_contains($webPath, '//')) { + return str_replace('//', '/', $webPath . $asset[1]); + } + + return $webPath . $asset[1]; + } + + /** + * Inflect the theme/plugin name to type set using `Asset::setInflectionType()`. + * + * @param string $string String inflected. + * @return string Inflected name of the theme + */ + protected static function inflectString(string $string): string + { + return Inflector::{static::$inflectionType}($string); + } + + /** + * Get webroot from request. + * + * @return string + */ + protected static function requestWebroot(): string + { + $request = Router::getRequest(); + if ($request === null) { + return '/'; + } + + return $request->getAttribute('webroot'); + } + + /** + * Splits a dot syntax plugin name into its plugin and filename. + * If $name does not have a dot, then index 0 will be null. + * It checks if the plugin is loaded, else filename will stay unchanged for filenames containing dot. + * + * @param string $name The name you want to plugin split. + * @return array{0: string|null, 1: string} Array with 2 indexes. 0 => plugin name, 1 => filename. + */ + protected static function pluginSplit(string $name): array + { + $plugin = null; + [$first, $second] = pluginSplit($name); + if ($first && Plugin::isLoaded($first)) { + $name = $second; + $plugin = $first; + } + + return [$plugin, $name]; + } +} diff --git a/src/Routing/Dispatcher.php b/src/Routing/Dispatcher.php deleted file mode 100644 index 2e9a969ebca..00000000000 --- a/src/Routing/Dispatcher.php +++ /dev/null @@ -1,92 +0,0 @@ -getEventManager(), $this->_filters); - $response = $actionDispatcher->dispatch($request, $response); - if (isset($request->params['return'])) { - return $response->body(); - } - - return $response->send(); - } - - /** - * Add a filter to this dispatcher. - * - * The added filter will be attached to the event manager used - * by this dispatcher. - * - * @param \Cake\Event\EventListenerInterface $filter The filter to connect. Can be - * any EventListenerInterface. Typically an instance of \Cake\Routing\DispatcherFilter. - * @return void - */ - public function addFilter(EventListenerInterface $filter) - { - $this->_filters[] = $filter; - } - - /** - * Get the list of connected filters. - * - * @return \Cake\Event\EventListenerInterface[] - */ - public function filters() - { - return $this->_filters; - } -} diff --git a/src/Routing/DispatcherFactory.php b/src/Routing/DispatcherFactory.php deleted file mode 100644 index c6baae5abba..00000000000 --- a/src/Routing/DispatcherFactory.php +++ /dev/null @@ -1,110 +0,0 @@ -addFilter($middleware); - } - - return $dispatcher; - } - - /** - * Get the connected dispatcher filters. - * - * @return \Cake\Routing\DispatcherFilter[] - */ - public static function filters() - { - return static::$_stack; - } - - /** - * Clear the middleware stack. - * - * @return void - */ - public static function clear() - { - static::$_stack = []; - } -} diff --git a/src/Routing/DispatcherFilter.php b/src/Routing/DispatcherFilter.php deleted file mode 100644 index f9466ec75c4..00000000000 --- a/src/Routing/DispatcherFilter.php +++ /dev/null @@ -1,217 +0,0 @@ - '/blog']); - * ``` - * - * When the above filter is connected to a dispatcher it will only fire - * its `beforeDispatch` and `afterDispatch` methods on requests that start with `/blog`. - * - * The for condition can also be a regular expression by using the `preg:` prefix: - * - * ``` - * $filter = new BlogFilter(['for' => 'preg:#^/blog/\d+$#']); - * ``` - * - * ### Limiting filters based on conditions - * - * In addition to simple path based matching you can use a closure to match on arbitrary request - * or response conditions. For example: - * - * ``` - * $cookieMonster = new CookieFilter([ - * 'when' => function ($req, $res) { - * // Custom code goes here. - * } - * ]); - * ``` - * - * If your when condition returns `true` the before/after methods will be called. - * - * When using the `for` or `when` matchers, conditions will be re-checked on the before and after - * callback as the conditions could change during the dispatch cycle. - * - * @mixin \Cake\Core\InstanceConfigTrait - */ -class DispatcherFilter implements EventListenerInterface -{ - - use InstanceConfigTrait; - - /** - * Default priority for all methods in this filter - * - * @var int - */ - protected $_priority = 10; - - /** - * Default config - * - * These are merged with user-provided config when the class is used. - * The when and for options allow you to define conditions that are checked before - * your filter is called. - * - * @var array - */ - protected $_defaultConfig = [ - 'when' => null, - 'for' => null, - 'priority' => null, - ]; - - /** - * Constructor. - * - * @param array $config Settings for the filter. - * @throws \InvalidArgumentException When 'when' conditions are not callable. - */ - public function __construct($config = []) - { - if (!isset($config['priority'])) { - $config['priority'] = $this->_priority; - } - $this->setConfig($config); - if (isset($config['when']) && !is_callable($config['when'])) { - throw new InvalidArgumentException('"when" conditions must be a callable.'); - } - } - - /** - * Returns the list of events this filter listens to. - * Dispatcher notifies 2 different events `Dispatcher.before` and `Dispatcher.after`. - * By default this class will attach `preDispatch` and `postDispatch` method respectively. - * - * Override this method at will to only listen to the events you are interested in. - * - * @return array - */ - public function implementedEvents() - { - return [ - 'Dispatcher.beforeDispatch' => [ - 'callable' => 'handle', - 'priority' => $this->_config['priority'] - ], - 'Dispatcher.afterDispatch' => [ - 'callable' => 'handle', - 'priority' => $this->_config['priority'] - ], - ]; - } - - /** - * Handler method that applies conditions and resolves the correct method to call. - * - * @param \Cake\Event\Event $event The event instance. - * @return mixed - */ - public function handle(Event $event) - { - $name = $event->getName(); - list(, $method) = explode('.', $name); - if (empty($this->_config['for']) && empty($this->_config['when'])) { - return $this->{$method}($event); - } - if ($this->matches($event)) { - return $this->{$method}($event); - } - } - - /** - * Check to see if the incoming request matches this filter's criteria. - * - * @param \Cake\Event\Event $event The event to match. - * @return bool - */ - public function matches(Event $event) - { - /* @var \Cake\Http\ServerRequest $request */ - $request = $event->getData('request'); - $pass = true; - if (!empty($this->_config['for'])) { - $len = strlen('preg:'); - $for = $this->_config['for']; - $url = $request->here(false); - if (substr($for, 0, $len) === 'preg:') { - $pass = (bool)preg_match(substr($for, $len), $url); - } else { - $pass = strpos($url, $for) === 0; - } - } - if ($pass && !empty($this->_config['when'])) { - $response = $event->getData('response'); - $pass = $this->_config['when']($request, $response); - } - - return $pass; - } - - /** - * Method called before the controller is instantiated and called to serve a request. - * If used with default priority, it will be called after the Router has parsed the - * URL and set the routing params into the request object. - * - * If a Cake\Http\Response object instance is returned, it will be served at the end of the - * event cycle, not calling any controller as a result. This will also have the effect of - * not calling the after event in the dispatcher. - * - * If false is returned, the event will be stopped and no more listeners will be notified. - * Alternatively you can call `$event->stopPropagation()` to achieve the same result. - * - * @param \Cake\Event\Event $event container object having the `request`, `response` and `additionalParams` - * keys in the data property. - * @return void - */ - public function beforeDispatch(Event $event) - { - } - - /** - * Method called after the controller served a request and generated a response. - * It is possible to alter the response object at this point as it is not sent to the - * client yet. - * - * If false is returned, the event will be stopped and no more listeners will be notified. - * Alternatively you can call `$event->stopPropagation()` to achieve the same result. - * - * @param \Cake\Event\Event $event container object having the `request` and `response` - * keys in the data property. - * @return void - */ - public function afterDispatch(Event $event) - { - } -} diff --git a/src/Routing/Exception/DuplicateNamedRouteException.php b/src/Routing/Exception/DuplicateNamedRouteException.php index 4fbcbe83d84..9c20616bc3a 100644 --- a/src/Routing/Exception/DuplicateNamedRouteException.php +++ b/src/Routing/Exception/DuplicateNamedRouteException.php @@ -1,4 +1,6 @@ |string $message Either the string of the error message, or an array of attributes + * that are made available in the view, and sprintf()'d into Exception::$_messageTemplate + * @param int|null $code The code of the error, is also the HTTP status code for the error. Defaults to 404. + * @param \Throwable|null $previous the previous exception. */ - public function __construct($message, $code = 404, $previous = null) + public function __construct(array|string $message, ?int $code = 404, ?Throwable $previous = null) { if (is_array($message) && isset($message['message'])) { $this->_messageTemplate = $message['message']; diff --git a/src/Routing/Exception/MissingControllerException.php b/src/Routing/Exception/MissingControllerException.php deleted file mode 100644 index 5bd79f49a86..00000000000 --- a/src/Routing/Exception/MissingControllerException.php +++ /dev/null @@ -1,36 +0,0 @@ -|string $message Either the string of the error message, or an array of attributes + * that are made available in the view, and sprintf()'d into Exception::$_messageTemplate + * @param int|null $code The code of the error, is also the HTTP status code for the error. Defaults to 404. + * @param \Throwable|null $previous the previous exception. */ - public function __construct($message, $code = 404, $previous = null) + public function __construct(array|string $message, ?int $code = 404, ?Throwable $previous = null) { if (is_array($message)) { if (isset($message['message'])) { diff --git a/src/Routing/Exception/RedirectException.php b/src/Routing/Exception/RedirectException.php deleted file mode 100644 index 2a392b95a62..00000000000 --- a/src/Routing/Exception/RedirectException.php +++ /dev/null @@ -1,36 +0,0 @@ -_cacheTime = $config['cacheTime']; - } - parent::__construct($config); - } - - /** - * Checks if a requested asset exists and sends it to the browser - * - * @param \Cake\Event\Event $event Event containing the request and response object - * @return \Cake\Http\Response|null If the client is requesting a recognized asset, null otherwise - * @throws \Cake\Network\Exception\NotFoundException When asset not found - */ - public function beforeDispatch(Event $event) - { - /* @var \Cake\Http\ServerRequest $request */ - $request = $event->getData('request'); - - $url = urldecode($request->url); - if (strpos($url, '..') !== false || strpos($url, '.') === false) { - return null; - } - - $assetFile = $this->_getAssetFile($url); - if ($assetFile === null || !file_exists($assetFile)) { - return null; - } - /* @var \Cake\Http\Response $response */ - $response = $event->getData('response'); - $event->stopPropagation(); - - $response->modified(filemtime($assetFile)); - if ($response->checkNotModified($request)) { - return $response; - } - - $pathSegments = explode('.', $url); - $ext = array_pop($pathSegments); - - return $this->_deliverAsset($request, $response, $assetFile, $ext); - } - - /** - * Builds asset file path based off url - * - * @param string $url Asset URL - * @return string Absolute path for asset file - */ - protected function _getAssetFile($url) - { - $parts = explode('/', $url); - $pluginPart = []; - for ($i = 0; $i < 2; $i++) { - if (!isset($parts[$i])) { - break; - } - $pluginPart[] = Inflector::camelize($parts[$i]); - $plugin = implode('/', $pluginPart); - if ($plugin && Plugin::loaded($plugin)) { - $parts = array_slice($parts, $i + 1); - $fileFragment = implode(DIRECTORY_SEPARATOR, $parts); - $pluginWebroot = Plugin::path($plugin) . 'webroot' . DIRECTORY_SEPARATOR; - - return $pluginWebroot . $fileFragment; - } - } - } - - /** - * Sends an asset file to the client - * - * @param \Cake\Http\ServerRequest $request The request object to use. - * @param \Cake\Http\Response $response The response object to use. - * @param string $assetFile Path to the asset file in the file system - * @param string $ext The extension of the file to determine its mime type - * @return \Cake\Http\Response The updated response. - */ - protected function _deliverAsset(ServerRequest $request, Response $response, $assetFile, $ext) - { - $compressionEnabled = $response->compress(); - if ($response->type($ext) === $ext) { - $contentType = 'application/octet-stream'; - $agent = $request->getEnv('HTTP_USER_AGENT'); - if (preg_match('%Opera(/| )([0-9].[0-9]{1,2})%', $agent) || preg_match('/MSIE ([0-9].[0-9]{1,2})/', $agent)) { - $contentType = 'application/octetstream'; - } - $response->type($contentType); - } - if (!$compressionEnabled) { - $response->header('Content-Length', filesize($assetFile)); - } - $response->cache(filemtime($assetFile), $this->_cacheTime); - $response->file($assetFile); - - return $response; - } -} diff --git a/src/Routing/Filter/ControllerFactoryFilter.php b/src/Routing/Filter/ControllerFactoryFilter.php deleted file mode 100644 index 4aed9071b99..00000000000 --- a/src/Routing/Filter/ControllerFactoryFilter.php +++ /dev/null @@ -1,65 +0,0 @@ -getData('request'); - $response = $event->getData('response'); - $event->setData('controller', $this->_getController($request, $response)); - } - - /** - * Gets controller to use, either plugin or application controller. - * - * @param \Cake\Http\ServerRequest $request Request object - * @param \Cake\Http\Response $response Response for the controller. - * @return \Cake\Controller\Controller - */ - protected function _getController($request, $response) - { - $factory = new ControllerFactory(); - - return $factory->create($request, $response); - } -} diff --git a/src/Routing/Filter/LocaleSelectorFilter.php b/src/Routing/Filter/LocaleSelectorFilter.php deleted file mode 100644 index 63a9ada1f35..00000000000 --- a/src/Routing/Filter/LocaleSelectorFilter.php +++ /dev/null @@ -1,71 +0,0 @@ -_locales = $config['locales']; - } - } - - /** - * Inspects the request for the Accept-Language header and sets the - * Locale for the current runtime if it matches the list of valid locales - * as passed in the configuration. - * - * @param \Cake\Event\Event $event The event instance. - * @return void - */ - public function beforeDispatch(Event $event) - { - /* @var \Cake\Http\ServerRequest $request */ - $request = $event->getData('request'); - $locale = Locale::acceptFromHttp($request->getHeaderLine('Accept-Language')); - - if (!$locale || (!empty($this->_locales) && !in_array($locale, $this->_locales))) { - return; - } - - I18n::setLocale($locale); - } -} diff --git a/src/Routing/Filter/RoutingFilter.php b/src/Routing/Filter/RoutingFilter.php deleted file mode 100644 index 754293e50a1..00000000000 --- a/src/Routing/Filter/RoutingFilter.php +++ /dev/null @@ -1,73 +0,0 @@ -getData('request'); - if (Router::getRequest(true) !== $request) { - Router::setRequestInfo($request); - } - - try { - if (!$request->getParam('controller')) { - $params = Router::parseRequest($request); - $request->addParams($params); - } - - return null; - } catch (RedirectException $e) { - $event->stopPropagation(); - /* @var \Cake\Http\Response $response */ - $response = $event->getData('response'); - $response->statusCode($e->getCode()); - $response->header('Location', $e->getMessage()); - - return $response; - } - } -} diff --git a/src/Routing/Middleware/AssetMiddleware.php b/src/Routing/Middleware/AssetMiddleware.php index 50f83960aa3..068c16552f6 100644 --- a/src/Routing/Middleware/AssetMiddleware.php +++ b/src/Routing/Middleware/AssetMiddleware.php @@ -1,4 +1,6 @@ 'text/css', - 'json' => 'application/json', - 'js' => 'application/javascript', - 'ico' => 'image/x-icon', - 'eot' => 'application/vnd.ms-fontobject', - 'svg' => 'image/svg+xml', - 'html' => 'text/html', - 'rss' => 'application/rss+xml', - 'xml' => 'application/xml', - ]; + protected string $cacheTime = '+1 day'; /** * Constructor. * - * @param array $options The options to use + * @param array $options The options to use */ public function __construct(array $options = []) { if (!empty($options['cacheTime'])) { $this->cacheTime = $options['cacheTime']; } - if (!empty($options['types'])) { - $this->typeMap = array_merge($this->typeMap, $options['types']); - } } /** * Serve assets if the path matches one. * * @param \Psr\Http\Message\ServerRequestInterface $request The request. - * @param \Psr\Http\Message\ResponseInterface $response The response. - * @param callable $next Callback to invoke the next middleware. - * @return \Psr\Http\Message\ResponseInterface A response + * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler. + * @return \Psr\Http\Message\ResponseInterface A response. */ - public function __invoke($request, $response, $next) + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $url = $request->getUri()->getPath(); - if (strpos($url, '..') !== false || strpos($url, '.') === false) { - return $next($request, $response); + if (str_contains($url, '..') || !str_contains($url, '.')) { + return $handler->handle($request); } - if (strpos($url, '/.') !== false) { - return $next($request, $response); + if (str_contains($url, '/.')) { + return $handler->handle($request); } $assetFile = $this->_getAssetFile($url); - if ($assetFile === null || !file_exists($assetFile)) { - return $next($request, $response); + if ($assetFile === null || !is_file($assetFile)) { + return $handler->handle($request); } - $file = new File($assetFile); - $modifiedTime = $file->lastChange(); + $file = new SplFileInfo($assetFile); + $modifiedTime = $file->getMTime(); if ($this->isNotModified($request, $file)) { - $headers = $response->getHeaders(); - $headers['Last-Modified'] = date(DATE_RFC850, $modifiedTime); - - return new Response('php://memory', 304, $headers); + return (new Response()) + ->withStringBody('') + ->withStatus(304) + ->withHeader( + 'Last-Modified', + date(DATE_RFC850, $modifiedTime), + ); } - return $this->deliverAsset($request, $response, $file); + return $this->deliverAsset($request, $file); } /** * Check the not modified header. * * @param \Psr\Http\Message\ServerRequestInterface $request The request to check. - * @param \Cake\Filesystem\File $file The file object to compare. + * @param \SplFileInfo $file The file object to compare. * @return bool */ - protected function isNotModified($request, $file) + protected function isNotModified(ServerRequestInterface $request, SplFileInfo $file): bool { $modifiedSince = $request->getHeaderLine('If-Modified-Since'); if (!$modifiedSince) { return false; } - return strtotime($modifiedSince) === $file->lastChange(); + return strtotime($modifiedSince) === $file->getMTime(); } /** * Builds asset file path based off url * * @param string $url Asset URL - * @return string Absolute path for asset file + * @return string|null Absolute path for asset file, null on failure */ - protected function _getAssetFile($url) + protected function _getAssetFile(string $url): ?string { - $parts = explode('/', ltrim($url, '/')); + $parts = explode('/', ltrim($url, '/'), 3); $pluginPart = []; for ($i = 0; $i < 2; $i++) { if (!isset($parts[$i])) { @@ -142,7 +128,7 @@ protected function _getAssetFile($url) } $pluginPart[] = Inflector::camelize($parts[$i]); $plugin = implode('/', $pluginPart); - if ($plugin && Plugin::loaded($plugin)) { + if (Plugin::isLoaded($plugin)) { $parts = array_slice($parts, $i + 1); $fileFragment = implode(DIRECTORY_SEPARATOR, $parts); $pluginWebroot = Plugin::path($plugin) . 'webroot' . DIRECTORY_SEPARATOR; @@ -151,47 +137,41 @@ protected function _getAssetFile($url) } } - return ''; + return null; } /** * Sends an asset file to the client * * @param \Psr\Http\Message\ServerRequestInterface $request The request object to use. - * @param \Psr\Http\Message\ResponseInterface $response The response object to use. - * @param \Cake\Filesystem\File $file The file wrapper for the file. - * @return \Psr\Http\Message\ResponseInterface The response with the file & headers. + * @param \SplFileInfo $file The file wrapper for the file. + * @return \Cake\Http\Response The response with the file & headers. */ - protected function deliverAsset(ServerRequestInterface $request, ResponseInterface $response, $file) + protected function deliverAsset(ServerRequestInterface $request, SplFileInfo $file): Response { - $contentType = $this->getType($file); - $modified = $file->lastChange(); + $resource = fopen($file->getPathname(), 'rb'); + if ($resource === false) { + throw new CakeException(sprintf('Cannot open resource `%s`', $file->getPathname())); + } + $stream = new Stream($resource); + + $response = new Response(['stream' => $stream]); + + $contentType = MimeType::getMimeTypeForFile($file->getRealPath()); + $modified = $file->getMTime(); $expire = strtotime($this->cacheTime); - $maxAge = $expire - time(); + if ($expire === false) { + throw new CakeException(sprintf('Invalid cache time value `%s`', $this->cacheTime)); + } - $stream = new Stream(fopen($file->path, 'rb')); + $now = time(); + $maxAge = $expire - $now; - return $response->withBody($stream) + return $response ->withHeader('Content-Type', $contentType) ->withHeader('Cache-Control', 'public,max-age=' . $maxAge) - ->withHeader('Date', gmdate('D, j M Y G:i:s \G\M\T', time())) - ->withHeader('Last-Modified', gmdate('D, j M Y G:i:s \G\M\T', $modified)) - ->withHeader('Expires', gmdate('D, j M Y G:i:s \G\M\T', $expire)); - } - - /** - * Return the type from a File object - * - * @param File $file The file from which you get the type - * @return string - */ - protected function getType($file) - { - $extension = $file->ext(); - if (isset($this->typeMap[$extension])) { - return $this->typeMap[$extension]; - } - - return $file->mime() ?: 'application/octet-stream'; + ->withHeader('Date', DateTime::parse($now)->toRfc7231String()) + ->withHeader('Last-Modified', DateTime::parse($modified)->toRfc7231String()) + ->withHeader('Expires', DateTime::parse($expire)->toRfc7231String()); } } diff --git a/src/Routing/Middleware/RoutingMiddleware.php b/src/Routing/Middleware/RoutingMiddleware.php index fe384265d9b..939ebc4ea13 100644 --- a/src/Routing/Middleware/RoutingMiddleware.php +++ b/src/Routing/Middleware/RoutingMiddleware.php @@ -1,4 +1,6 @@ app = $app; } /** - * Trigger the application's routes() hook if the application exists and Router isn't initialized. - * - * If the middleware is created without an Application, routes will be - * loaded via the automatic route loading that pre-dates the routes() hook. + * Trigger the application's and plugin's routes() hook. * * @return void */ - protected function loadRoutes() + protected function loadRoutes(): void { - if ($this->app) { - $builder = Router::createRouteBuilder('/'); - $this->app->routes($builder); + $builder = Router::createRouteBuilder('/'); + $this->app->routes($builder); + if ($this->app instanceof PluginApplicationInterface) { + $this->app->pluginRoutes($builder); } } @@ -69,44 +82,48 @@ protected function loadRoutes() * invoked. * * @param \Psr\Http\Message\ServerRequestInterface $request The request. - * @param \Psr\Http\Message\ResponseInterface $response The response. - * @param callable $next The next middleware to call. + * @param \Psr\Http\Server\RequestHandlerInterface $handler The request handler. * @return \Psr\Http\Message\ResponseInterface A response. */ - public function __invoke(ServerRequestInterface $request, ResponseInterface $response, $next) + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $this->loadRoutes(); try { - Router::setRequestContext($request); + assert($request instanceof ServerRequest); + Router::setRequest($request); $params = (array)$request->getAttribute('params', []); $middleware = []; if (empty($params['controller'])) { - $parsedBody = $request->getParsedBody(); - if (is_array($parsedBody) && isset($parsedBody['_method'])) { - $request = $request->withMethod($parsedBody['_method']); - } $params = Router::parseRequest($request) + $params; if (isset($params['_middleware'])) { $middleware = $params['_middleware']; - unset($params['_middleware']); } + $route = $params['_route']; + unset($params['_middleware'], $params['_route']); + + $request = $request->withAttribute('route', $route); $request = $request->withAttribute('params', $params); + + Router::setRequest($request); } } catch (RedirectException $e) { return new RedirectResponse( $e->getMessage(), $e->getCode(), - $response->getHeaders() + $e->getHeaders(), ); } $matching = Router::getRouteCollection()->getMiddleware($middleware); if (!$matching) { - return $next($request, $response); + return $handler->handle($request); } - $matching[] = $next; - $middleware = new MiddlewareQueue($matching); + + $container = $this->app instanceof ContainerApplicationInterface + ? $this->app->getContainer() + : null; + $middleware = new MiddlewareQueue($matching, $container); $runner = new Runner(); - return $runner->run($middleware, $request, $response); + return $runner->run($middleware, $request, $handler); } } diff --git a/src/Routing/RequestActionTrait.php b/src/Routing/RequestActionTrait.php deleted file mode 100644 index 99721cee11a..00000000000 --- a/src/Routing/RequestActionTrait.php +++ /dev/null @@ -1,182 +0,0 @@ -requestAction('/articles/popular'); - * ``` - * - * A basic example of request action to fetch a rendered page without the layout. - * - * ``` - * $viewHtml = $this->requestAction('/articles/popular', ['return']); - * ``` - * - * You can also pass the URL as an array: - * - * ``` - * $vars = $this->requestAction(['controller' => 'articles', 'action' => 'popular']); - * ``` - * - * ### Passing other request data - * - * You can pass POST, GET, COOKIE and other data into the request using the appropriate keys. - * Cookies can be passed using the `cookies` key. Get parameters can be set with `query` and post - * data can be sent using the `post` key. - * - * ``` - * $vars = $this->requestAction('/articles/popular', [ - * 'query' => ['page' => 1], - * 'cookies' => ['remember_me' => 1], - * ]); - * ``` - * - * ### Sending environment or header values - * - * By default actions dispatched with this method will use the global $_SERVER and $_ENV - * values. If you want to override those values for a request action, you can specify the values: - * - * ``` - * $vars = $this->requestAction('/articles/popular', [ - * 'environment' => ['CONTENT_TYPE' => 'application/json'] - * ]); - * ``` - * - * ### Transmitting the session - * - * By default actions dispatched with this method will use the standard session object. - * If you want a particular session instance to be used, you need to specify it. - * - * ``` - * $vars = $this->requestAction('/articles/popular', [ - * 'session' => new Session($someSessionConfig) - * ]); - * ``` - * - * @param string|array $url String or array-based url. Unlike other url arrays in CakePHP, this - * url will not automatically handle passed arguments in the $url parameter. - * @param array $extra if array includes the key "return" it sets the autoRender to true. Can - * also be used to submit GET/POST data, and passed arguments. - * @return mixed Boolean true or false on success/failure, or contents - * of rendered action if 'return' is set in $extra. - * @deprecated 3.3.0 You should refactor your code to use View Cells instead of this method. - */ - public function requestAction($url, array $extra = []) - { - if (empty($url)) { - return false; - } - if (($index = array_search('return', $extra)) !== false) { - $extra['return'] = 0; - $extra['autoRender'] = 1; - unset($extra[$index]); - } - $extra += ['autoRender' => 0, 'return' => 1, 'bare' => 1, 'requested' => 1]; - - $baseUrl = Configure::read('App.fullBaseUrl'); - if (is_string($url) && strpos($url, $baseUrl) === 0) { - $url = Router::normalize(str_replace($baseUrl, '', $url)); - } - if (is_string($url)) { - $params = [ - 'url' => $url - ]; - } elseif (is_array($url)) { - $defaultParams = ['plugin' => null, 'controller' => null, 'action' => null]; - $params = [ - 'params' => $url + $defaultParams, - 'base' => false, - 'url' => Router::reverse($url) - ]; - if (empty($params['params']['pass'])) { - $params['params']['pass'] = []; - } - } - $current = Router::getRequest(); - if ($current) { - $params['base'] = $current->base; - $params['webroot'] = $current->webroot; - } - - $params['post'] = $params['query'] = []; - if (isset($extra['post'])) { - $params['post'] = $extra['post']; - } - if (isset($extra['query'])) { - $params['query'] = $extra['query']; - } - if (isset($extra['cookies'])) { - $params['cookies'] = $extra['cookies']; - } - if (isset($extra['environment'])) { - $params['environment'] = $extra['environment'] + $_SERVER + $_ENV; - } - unset($extra['environment'], $extra['post'], $extra['query']); - - $params['session'] = isset($extra['session']) ? $extra['session'] : new Session(); - - $request = new ServerRequest($params); - $request->addParams($extra); - $dispatcher = DispatcherFactory::create(); - - // If an application is using PSR7 middleware, - // we need to 'fix' their missing dispatcher filters. - $needed = [ - 'routing' => RoutingFilter::class, - 'controller' => ControllerFactoryFilter::class - ]; - foreach ($dispatcher->filters() as $filter) { - if ($filter instanceof RoutingFilter) { - unset($needed['routing']); - } - if ($filter instanceof ControllerFactoryFilter) { - unset($needed['controller']); - } - } - foreach ($needed as $class) { - $dispatcher->addFilter(new $class); - } - $result = $dispatcher->dispatch($request, new Response()); - Router::popRequest(); - - return $result; - } -} diff --git a/src/Routing/Route/DashedRoute.php b/src/Routing/Route/DashedRoute.php index 71ff7f43654..c406e32b373 100644 --- a/src/Routing/Route/DashedRoute.php +++ b/src/Routing/Route/DashedRoute.php @@ -1,4 +1,6 @@ _dasherize($url); - if (!$this->_inflectedDefaults) { - $this->_inflectedDefaults = true; - $this->defaults = $this->_dasherize($this->defaults); + if ($this->_inflectedDefaults === null) { + $this->compile(); + $this->_inflectedDefaults = $this->_dasherize($this->defaults); } + $restore = $this->defaults; + try { + $this->defaults = $this->_inflectedDefaults; - return parent::match($url, $context); + return parent::match($url, $context); + } finally { + $this->defaults = $restore; + } } /** @@ -110,7 +117,7 @@ public function match(array $url, array $context = []) * @param array $url An array of URL keys. * @return array */ - protected function _dasherize($url) + protected function _dasherize(array $url): array { foreach (['controller', 'plugin', 'action'] as $element) { if (!empty($url[$element])) { diff --git a/src/Routing/Route/EntityRoute.php b/src/Routing/Route/EntityRoute.php new file mode 100644 index 00000000000..6e8d7680865 --- /dev/null +++ b/src/Routing/Route/EntityRoute.php @@ -0,0 +1,82 @@ +_compiledRoute) { + $this->compile(); + } + + if (isset($url['_entity'])) { + $entity = $url['_entity']; + $this->_checkEntity($entity); + + foreach ($this->keys as $field) { + if (!isset($url[$field]) && isset($entity[$field])) { + $url[$field] = $entity[$field]; + } + } + } + + return parent::match($url, $context); + } + + /** + * Checks that we really deal with an entity object + * + * @throws \RuntimeException + * @param mixed $entity Entity value from the URL options + * @return void + */ + protected function _checkEntity(mixed $entity): void + { + if (!$entity instanceof ArrayAccess && !is_array($entity)) { + throw new CakeException(sprintf( + 'Route `%s` expects the URL option `_entity` to be an array or object implementing \ArrayAccess, ' + . 'but `%s` passed.', + $this->template, + get_debug_type($entity), + )); + } + } +} diff --git a/src/Routing/Route/InflectedRoute.php b/src/Routing/Route/InflectedRoute.php index face5d18813..fa53bdf3685 100644 --- a/src/Routing/Route/InflectedRoute.php +++ b/src/Routing/Route/InflectedRoute.php @@ -1,4 +1,6 @@ _underscore($url); - if (!$this->_inflectedDefaults) { - $this->_inflectedDefaults = true; - $this->defaults = $this->_underscore($this->defaults); + if ($this->_inflectedDefaults === null) { + $this->compile(); + $this->_inflectedDefaults = $this->_underscore($this->defaults); } + $restore = $this->defaults; + try { + $this->defaults = $this->_inflectedDefaults; - return parent::match($url, $context); + return parent::match($url, $context); + } finally { + $this->defaults = $restore; + } } /** @@ -89,7 +96,7 @@ public function match(array $url, array $context = []) * @param array $url An array of URL keys. * @return array */ - protected function _underscore($url) + protected function _underscore(array $url): array { if (!empty($url['controller'])) { $url['controller'] = Inflector::underscore($url['controller']); diff --git a/src/Routing/Route/PluginShortRoute.php b/src/Routing/Route/PluginShortRoute.php index 2e7cf7d50d1..bf9347c2251 100644 --- a/src/Routing/Route/PluginShortRoute.php +++ b/src/Routing/Route/PluginShortRoute.php @@ -1,4 +1,6 @@ defaults['controller'] = $url['controller']; $result = parent::match($url, $context); diff --git a/src/Routing/Route/RedirectRoute.php b/src/Routing/Route/RedirectRoute.php index 170c56f206c..9f51611b3ee 100644 --- a/src/Routing/Route/RedirectRoute.php +++ b/src/Routing/Route/RedirectRoute.php @@ -1,4 +1,6 @@ redirect = (array)$defaults; - } - - /** - * Parses a string URL into an array. Parsed URLs will result in an automatic - * redirection. - * - * @param string $url The URL to parse. - * @param string $method The HTTP method being used. - * @return bool|null False on failure. An exception is raised on a successful match. - * @throws \Cake\Routing\Exception\RedirectException An exception is raised on successful match. - * This is used to halt route matching and signal to the middleware that a redirect should happen. - */ - public function parse($url, $method = '') - { - $params = parent::parse($url, $method); - if (!$params) { - return false; - } - $redirect = $this->redirect; - if (count($this->redirect) === 1 && !isset($this->redirect['controller'])) { - $redirect = $this->redirect[0]; - } - if (isset($this->options['persist']) && is_array($redirect)) { - $redirect += ['pass' => $params['pass'], 'url' => []]; - if (is_array($this->options['persist'])) { - foreach ($this->options['persist'] as $elem) { - if (isset($params[$elem])) { - $redirect[$elem] = $params[$elem]; - } - } - } - $redirect = Router::reverse($redirect); - } - $status = 301; - if (isset($this->options['status']) && ($this->options['status'] >= 300 && $this->options['status'] < 400)) { - $status = $this->options['status']; - } - throw new RedirectException(Router::url($redirect, true), $status); - } - - /** - * There is no reverse routing redirection routes. - * - * @param array $url Array of parameters to convert to a string. - * @param array $context Array of request context parameters. - * @return bool Always false. - */ - public function match(array $url, array $context = []) - { - return false; - } + use RedirectTrait; } diff --git a/src/Routing/Route/RedirectTrait.php b/src/Routing/Route/RedirectTrait.php new file mode 100644 index 00000000000..1d6368f7e94 --- /dev/null +++ b/src/Routing/Route/RedirectTrait.php @@ -0,0 +1,121 @@ +value array or a CakePHP array URL. + * @param array $options Array of additional options for the Route + */ + public function __construct(string $template, array $defaults = [], array $options = []) + { + parent::__construct($template, $defaults, $options); + if (isset($defaults['redirect'])) { + $defaults = (array)$defaults['redirect']; + } + $this->redirect = $defaults; + } + + /** + * Parses a string URL into an array. Parsed URLs will result in an automatic + * redirection. + * + * @param string $url The URL to parse. + * @param string $method The HTTP method being used. + * @return array|null Null on failure. An exception is raised on a successful match. Array return type is unused. + * @throws \Cake\Http\Exception\RedirectException An exception is raised on successful match. + * This is used to halt route matching and signal to the middleware that a redirect should happen. + */ + public function parse(string $url, string $method = ''): ?array + { + $params = parent::parse($url, $method); + if (!$params) { + return null; + } + $redirect = $this->redirect; + if (count($redirect) === 1 && !isset($redirect['controller'])) { + $redirect = $redirect[0]; + } + if (isset($this->options['persist']) && is_array($redirect)) { + $redirect += ['pass' => $params['pass'], 'url' => []]; + if (is_array($this->options['persist'])) { + foreach ($this->options['persist'] as $elem) { + if (isset($params[$elem])) { + $redirect[$elem] = $params[$elem]; + } + } + } + $redirect = Router::reverseToArray($redirect); + } + $status = 301; + if (isset($this->options['status']) && ($this->options['status'] >= 300 && $this->options['status'] < 400)) { + $status = $this->options['status']; + } + throw new RedirectException(Router::url($redirect, true), $status); + } + + /** + * There is no reverse routing redirection routes. + * + * @param array $url Array of parameters to convert to a string. + * @param array $context Array of request context parameters. + * @return string|null Always null, string return result unused. + */ + public function match(array $url, array $context = []): ?string + { + return null; + } + + /** + * Sets the HTTP status + * + * @param int $status The status code for this route + * @return $this + */ + public function setStatus(int $status) + { + $this->options['status'] = $status; + + return $this; + } +} diff --git a/src/Routing/Route/Route.php b/src/Routing/Route/Route.php index e4cc8c5bb73..25ace3da42c 100644 --- a/src/Routing/Route/Route.php +++ b/src/Routing/Route/Route.php @@ -1,4 +1,6 @@ */ - public $options = []; + public array $options = []; /** * Default parameters for a Route * * @var array */ - public $defaults = []; + public array $defaults = []; /** * The routes template string. * - * @var string|null + * @var string */ - public $template; + public string $template; /** * Is this route a greedy route? Greedy routes have a `/*` in their @@ -64,42 +68,49 @@ class Route * * @var bool */ - protected $_greedy = false; + protected bool $_greedy = false; /** * The compiled route regular expression * * @var string|null */ - protected $_compiledRoute; + protected ?string $_compiledRoute = null; /** * The name for a route. Fetch with Route::getName(); * * @var string|null */ - protected $_name; + protected ?string $_name = null; /** * List of connected extensions for this route. * - * @var array + * @var array */ - protected $_extensions = []; + protected array $_extensions = []; /** * List of middleware that should be applied. * * @var array */ - protected $middleware = []; + protected array $middleware = []; /** * Valid HTTP methods. * - * @var array + * @var array */ - const VALID_METHODS = ['GET', 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']; + public const VALID_METHODS = ['GET', 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']; + + /** + * Regex for matching braced placeholders in route template. + * + * @var string + */ + protected const PLACEHOLDER_REGEX = '#\{([a-z][a-z0-9-_]*)\}#i'; /** * Constructor for a Route @@ -109,56 +120,57 @@ class Route * - `_ext` - Defines the extensions used for this route. * - `_middleware` - Define the middleware names for this route. * - `pass` - Copies the listed parameters into params['pass']. + * - `_method` - Defines the HTTP method(s) the route applies to. It can be + * a string or array of valid HTTP method name. * - `_host` - Define the host name pattern if you want this route to only match * specific host names. You can use `.*` and to create wildcard subdomains/hosts * e.g. `*.example.com` matches all subdomains on `example.com`. + * - '_port` - Define the port if you want this route to only match specific port number. + * - '_urldecode' - Set to `false` to disable URL decoding before route parsing. * * @param string $template Template string with parameter placeholders - * @param array|string $defaults Defaults for the route. - * @param array $options Array of additional options for the Route + * @param array $defaults Defaults for the route. + * @param array $options Array of additional options for the Route + * @throws \InvalidArgumentException When `$options['_method']` are not in `VALID_METHODS` list. */ - public function __construct($template, $defaults = [], array $options = []) + public function __construct(string $template, array $defaults = [], array $options = []) { + $checker = function () use ($defaults): bool { + foreach (['plugin', 'prefix', 'controller', 'action'] as $key) { + if (isset($defaults[$key]) && !is_string($defaults[$key])) { + throw new CakeException( + 'Value for `' . $key . '` in $defaults when connecting routes' + . ' must be of type `string` or `null`', + ); + } + } + + return true; + }; + + assert($checker()); + $this->template = $template; - // @deprecated The `[method]` format should be removed in 4.0.0 - if (isset($defaults['[method]'])) { - $defaults['_method'] = $defaults['[method]']; - unset($defaults['[method]']); - } - $this->defaults = (array)$defaults; + $this->defaults = $defaults; $this->options = $options + ['_ext' => [], '_middleware' => []]; $this->setExtensions((array)$this->options['_ext']); $this->setMiddleware((array)$this->options['_middleware']); unset($this->options['_middleware']); - } - /** - * Get/Set the supported extensions for this route. - * - * @deprecated 3.3.9 Use getExtensions/setExtensions instead. - * @param null|string|array $extensions The extensions to set. Use null to get. - * @return array|null The extensions or null. - */ - public function extensions($extensions = null) - { - if ($extensions === null) { - return $this->_extensions; + if (isset($this->defaults['_method'])) { + $this->defaults['_method'] = $this->normalizeAndValidateMethods($this->defaults['_method']); } - $this->_extensions = (array)$extensions; } /** * Set the supported extensions for this route. * - * @param array $extensions The extensions to set. + * @param array $extensions The extensions to set. * @return $this */ public function setExtensions(array $extensions) { - $this->_extensions = []; - foreach ($extensions as $ext) { - $this->_extensions[] = strtolower($ext); - } + $this->_extensions = array_map('strtolower', $extensions); return $this; } @@ -166,9 +178,9 @@ public function setExtensions(array $extensions) /** * Get the supported extensions for this route. * - * @return array + * @return array */ - public function getExtensions() + public function getExtensions(): array { return $this->_extensions; } @@ -176,22 +188,38 @@ public function getExtensions() /** * Set the accepted HTTP methods for this route. * - * @param array $methods The HTTP methods to accept. + * @param array $methods The HTTP methods to accept. * @return $this - * @throws \InvalidArgumentException + * @throws \InvalidArgumentException When methods are not in `VALID_METHODS` list. */ public function setMethods(array $methods) { - $methods = array_map('strtoupper', $methods); - $diff = array_diff($methods, static::VALID_METHODS); + $this->defaults['_method'] = $this->normalizeAndValidateMethods($methods); + + return $this; + } + + /** + * Normalize method names to upper case and validate that they are valid HTTP methods. + * + * @param array|string $methods Methods. + * @return array|string + * @throws \InvalidArgumentException When methods are not in `VALID_METHODS` list. + */ + protected function normalizeAndValidateMethods(array|string $methods): array|string + { + $methods = is_array($methods) + ? array_map('strtoupper', $methods) + : strtoupper($methods); + + $diff = array_diff((array)$methods, static::VALID_METHODS); if ($diff !== []) { throw new InvalidArgumentException( - sprintf('Invalid HTTP method received. %s is invalid.', implode(', ', $diff)) + sprintf('Invalid HTTP method received. `%s` is invalid.', implode(', ', $diff)), ); } - $this->defaults['_method'] = $methods; - return $this; + return $methods; } /** @@ -200,16 +228,16 @@ public function setMethods(array $methods) * If any of your patterns contain multibyte values, the `multibytePattern` * mode will be enabled. * - * @param array $patterns The patterns to apply to routing elements + * @param array $patterns The patterns to apply to routing elements * @return $this */ public function setPatterns(array $patterns) { - $patternValues = implode("", $patterns); + $patternValues = implode('', $patterns); if (mb_strlen($patternValues) < strlen($patternValues)) { $this->options['multibytePattern'] = true; } - $this->options = array_merge($this->options, $patterns); + $this->options = $patterns + $this->options; return $this; } @@ -220,7 +248,7 @@ public function setPatterns(array $patterns) * @param string $host The host name this route is bound to * @return $this */ - public function setHost($host) + public function setHost(string $host) { $this->options['_host'] = $host; @@ -230,7 +258,7 @@ public function setHost($host) /** * Set the names of parameters that will be converted into passed parameters * - * @param array $names The names of the parameters that should be passed. + * @param array $names The names of the parameters that should be passed. * @return $this */ public function setPass(array $names) @@ -241,15 +269,15 @@ public function setPass(array $names) } /** - * Set the names of parameters that will persisted automatically + * Set the names of parameters that will be persisted automatically * - * Persistent parametesr allow you to define which route parameters should be automatically + * Persistent parameters allow you to define which route parameters should be automatically * included when generating new URLs. You can override persistent parameters * by redefining them in a URL or remove them by setting the persistent parameter to `false`. * * ``` * // remove a persistent 'date' parameter - * Router::url(['date' => false', ...]); + * Router::url(['date' => false, ...]); * ``` * * @param array $names The names of the parameters that should be passed. @@ -267,9 +295,9 @@ public function setPersist(array $names) * * @return bool */ - public function compiled() + public function compiled(): bool { - return !empty($this->_compiledRoute); + return $this->_compiledRoute !== null; } /** @@ -280,12 +308,12 @@ public function compiled() * * @return string Returns a string regular expression of the compiled route. */ - public function compile() + public function compile(): string { - if ($this->_compiledRoute) { - return $this->_compiledRoute; + if ($this->_compiledRoute === null) { + $this->_writeRoute(); } - $this->_writeRoute(); + assert($this->_compiledRoute !== null); return $this->_compiledRoute; } @@ -298,7 +326,7 @@ public function compile() * * @return void */ - protected function _writeRoute() + protected function _writeRoute(): void { if (empty($this->template) || ($this->template === '/')) { $this->_compiledRoute = '#^/*$#'; @@ -307,42 +335,46 @@ protected function _writeRoute() return; } $route = $this->template; - $names = $routeParams = []; + $names = []; + $routeParams = []; $parsed = preg_quote($this->template, '#'); - preg_match_all('/:([a-z0-9-_]+(? $name) { - $search = '\\' . $namedElements[0][$i]; + preg_match_all(static::PLACEHOLDER_REGEX, $route, $namedElements, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); + + foreach ($namedElements as $matchArray) { + // Placeholder name, e.g. "foo" + $name = $matchArray[1][0]; + // Placeholder with colon/braces, e.g. "{foo}" + $search = preg_quote($matchArray[0][0]); if (isset($this->options[$name])) { - $option = null; + $option = ''; if ($name !== 'plugin' && array_key_exists($name, $this->defaults)) { $option = '?'; } - $slashParam = '/\\' . $namedElements[0][$i]; - if (strpos($parsed, $slashParam) !== false) { - $routeParams[$slashParam] = '(?:/(?P<' . $name . '>' . $this->options[$name] . ')' . $option . ')' . $option; + // phpcs:disable Generic.Files.LineLength + // Offset of the colon/braced placeholder in the full template string + if ($parsed[$matchArray[0][1] - 1] === '/') { + $routeParams['/' . $search] = '(?:/(?P<' . $name . '>' . $this->options[$name] . ')' . $option . ')' . $option; } else { $routeParams[$search] = '(?:(?P<' . $name . '>' . $this->options[$name] . ')' . $option . ')' . $option; } + // phpcs:enable Generic.Files.LineLength } else { $routeParams[$search] = '(?:(?P<' . $name . '>[^/]+))'; } $names[] = $name; } if (preg_match('#\/\*\*$#', $route)) { - $parsed = preg_replace('#/\\\\\*\\\\\*$#', '(?:/(?P<_trailing_>.*))?', $parsed); + $parsed = (string)preg_replace('#/\\\\\*\\\\\*$#', '(?:/(?P<_trailing_>.*))?', $parsed); $this->_greedy = true; } if (preg_match('#\/\*$#', $route)) { - $parsed = preg_replace('#/\\\\\*$#', '(?:/(?P<_args_>.*))?', $parsed); + $parsed = (string)preg_replace('#/\\\\\*$#', '(?:/(?P<_args_>.*))?', $parsed); $this->_greedy = true; } - $mode = ''; - if (!empty($this->options['multibytePattern'])) { - $mode = 'u'; - } + $mode = empty($this->options['multibytePattern']) ? '' : 'u'; krsort($routeParams); - $parsed = str_replace(array_keys($routeParams), array_values($routeParams), $parsed); + $parsed = str_replace(array_keys($routeParams), $routeParams, $parsed); $this->_compiledRoute = '#^' . $parsed . '[/]*$#' . $mode; $this->keys = $names; @@ -361,9 +393,9 @@ protected function _writeRoute() * * @return string */ - public function getName() + public function getName(): string { - if (!empty($this->_name)) { + if ($this->_name) { return $this->_name; } $name = ''; @@ -371,11 +403,11 @@ public function getName() 'prefix' => ':', 'plugin' => '.', 'controller' => ':', - 'action' => '' + 'action' => '', ]; foreach ($keys as $key => $glue) { $value = null; - if (strpos($this->template, ':' . $key) !== false) { + if (str_contains($this->template, '{' . $key . '}')) { $value = '_' . $key; } elseif (isset($this->defaults[$key])) { $value = $this->defaults[$key]; @@ -397,16 +429,16 @@ public function getName() * Checks to see if the given URL can be parsed by this route. * * If the route can be parsed an array of parameters will be returned; if not - * false will be returned. + * `null` will be returned. * * @param \Psr\Http\Message\ServerRequestInterface $request The URL to attempt to parse. - * @return array|false An array of request parameters, or false on failure. + * @return array|null An array of request parameters, or `null` on failure. */ - public function parseRequest(ServerRequestInterface $request) + public function parseRequest(ServerRequestInterface $request): ?array { $uri = $request->getUri(); if (isset($this->options['_host']) && !$this->hostMatches($uri->getHost())) { - return false; + return null; } return $this->parse($uri->getPath(), $request->getMethod()); @@ -416,33 +448,40 @@ public function parseRequest(ServerRequestInterface $request) * Checks to see if the given URL can be parsed by this route. * * If the route can be parsed an array of parameters will be returned; if not - * false will be returned. String URLs are parsed if they match a routes regular expression. + * `null` will be returned. String URLs are parsed if they match a routes regular expression. * * @param string $url The URL to attempt to parse. * @param string $method The HTTP method of the request being parsed. - * @return array|false An array of request parameters, or false on failure. - * @deprecated 3.4.0 Use/implement parseRequest() instead as it provides more flexibility/control. + * @return array|null An array of request parameters, or `null` on failure. + * @throws \Cake\Http\Exception\BadRequestException When method is not an empty string and not in `VALID_METHODS` list. */ - public function parse($url, $method = '') + public function parse(string $url, string $method): ?array { - if (empty($this->_compiledRoute)) { - $this->compile(); + try { + if ($method !== '') { + $method = $this->normalizeAndValidateMethods($method); + } + } catch (InvalidArgumentException $e) { + throw new BadRequestException($e->getMessage()); } - list($url, $ext) = $this->_parseExtension($url); - if (!preg_match($this->_compiledRoute, urldecode($url), $route)) { - return false; + $compiledRoute = $this->compile(); + [$url, $ext] = $this->_parseExtension($url); + + $urldecode = $this->options['_urldecode'] ?? true; + if ($urldecode && str_contains($url, '%')) { + $url = urldecodeSegments($url); } - if (isset($this->defaults['_method'])) { - if (empty($method)) { - // Deprecated reading the global state is deprecated and will be removed in 4.x - $request = Router::getRequest(true) ?: ServerRequest::createFromGlobals(); - $method = $request->getMethod(); - } - if (!in_array($method, (array)$this->defaults['_method'], true)) { - return false; - } + if (!preg_match($compiledRoute, $url, $route)) { + return null; + } + + if ( + isset($this->defaults['_method']) && + !in_array($method, (array)$this->defaults['_method'], true) + ) { + return null; } array_shift($route); @@ -475,7 +514,7 @@ public function parse($url, $method = '') unset($route['_trailing_']); } - if (!empty($ext)) { + if ($ext) { $route['_ext'] = $ext; } @@ -493,8 +532,10 @@ public function parse($url, $method = '') } } } + + $route['_route'] = $this; $route['_matchedRoute'] = $this->template; - if (count($this->middleware) > 0) { + if ($this->middleware !== []) { $route['_middleware'] = $this->middleware; } @@ -505,9 +546,9 @@ public function parse($url, $method = '') * Check to see if the host matches the route requirements * * @param string $host The request's host name - * @return bool Whether or not the host matches any conditions set in for this route. + * @return bool Whether the host matches any conditions set in for this route. */ - public function hostMatches($host) + public function hostMatches(string $host): bool { $pattern = '@^' . str_replace('\*', '.*', preg_quote($this->options['_host'], '@')) . '$@'; @@ -521,9 +562,9 @@ public function hostMatches($host) * @param string $url The url to parse. * @return array containing url, extension */ - protected function _parseExtension($url) + protected function _parseExtension(string $url): array { - if (count($this->_extensions) && strpos($url, '.') !== false) { + if (count($this->_extensions) && str_contains($url, '.')) { foreach ($this->_extensions as $ext) { $len = strlen($ext) + 1; if (substr($url, -$len) === '.' . $ext) { @@ -542,19 +583,20 @@ protected function _parseExtension($url) * Currently implemented rule types are controller, action and match that can be combined with each other. * * @param string $args A string with the passed params. eg. /1/foo - * @param string $context The current route context, which should contain controller/action keys. - * @return array Array of passed args. + * @param array $context The current route context, which should contain controller/action keys. + * @return array Array of passed args. */ - protected function _parseArgs($args, $context) + protected function _parseArgs(string $args, array $context): array { $pass = []; $args = explode('/', $args); + $urldecode = $this->options['_urldecode'] ?? true; foreach ($args as $param) { - if (empty($param) && $param !== '0' && $param !== 0) { + if (!$param && $param !== '0') { continue; } - $pass[] = rawurldecode($param); + $pass[] = $urldecode ? rawurldecode($param) : $param; } return $pass; @@ -569,7 +611,7 @@ protected function _parseArgs($args, $context) * @param array $params An array of persistent values to replace persistent ones. * @return array An array with persistent parameters applied. */ - protected function _persistParams(array $url, array $params) + protected function _persistParams(array $url, array $params): array { foreach ($this->options['persist'] as $persistKey) { if (array_key_exists($persistKey, $params) && !isset($url[$persistKey])) { @@ -591,17 +633,18 @@ protected function _persistParams(array $url, array $params) * @param array $context An array of the current request context. * Contains information such as the current host, scheme, port, base * directory and other url params. - * @return string|false Either a string URL for the parameters if they match or false. + * @return string|null Either a string URL for the parameters if they match or null. */ - public function match(array $url, array $context = []) + public function match(array $url, array $context = []): ?string { - if (empty($this->_compiledRoute)) { + if (!$this->_compiledRoute) { $this->compile(); } $defaults = $this->defaults; $context += ['params' => [], '_port' => null, '_scheme' => null, '_host' => null]; - if (!empty($this->options['persist']) && + if ( + !empty($this->options['persist']) && is_array($this->options['persist']) ) { $url = $this->_persistParams($url, $context['params']); @@ -609,34 +652,36 @@ public function match(array $url, array $context = []) unset($context['params']); $hostOptions = array_intersect_key($url, $context); + // Apply the _host option if possible + if (isset($this->options['_host'])) { + if (!isset($hostOptions['_host']) && !str_contains($this->options['_host'], '*')) { + $hostOptions['_host'] = $this->options['_host']; + } + $hostOptions['_host'] ??= $context['_host']; + + // The host did not match the route preferences + if (!$this->hostMatches((string)$hostOptions['_host'])) { + return null; + } + } + // Check for properties that will cause an // absolute url. Copy the other properties over. - if (isset($hostOptions['_scheme']) || + if ( + isset($hostOptions['_scheme']) || isset($hostOptions['_port']) || isset($hostOptions['_host']) ) { $hostOptions += $context; - if ($hostOptions['_port'] == $context['_port']) { + if ( + $hostOptions['_scheme'] && + getservbyname($hostOptions['_scheme'], 'tcp') === $hostOptions['_port'] + ) { unset($hostOptions['_port']); } } - // Apply the _host option if possible - if (isset($this->options['_host'])) { - if (!isset($hostOptions['_host']) && strpos($this->options['_host'], '*') === false) { - $hostOptions['_host'] = $this->options['_host']; - } - if (!isset($hostOptions['_host'])) { - $hostOptions['_host'] = $context['_host']; - } - - // The host did not match the route preferences - if (!$this->hostMatches($hostOptions['_host'])) { - return false; - } - } - // If no base is set, copy one in. if (!isset($hostOptions['_base']) && isset($context['_base'])) { $hostOptions['_base'] = $context['_base']; @@ -645,7 +690,7 @@ public function match(array $url, array $context = []) $query = !empty($url['?']) ? (array)$url['?'] : []; unset($url['_host'], $url['_scheme'], $url['_port'], $url['_base'], $url['?']); - // Move extension into the hostOptions so its not part of + // Move extension into the hostOptions so it is not part of // reverse matches. if (isset($url['_ext'])) { $hostOptions['_ext'] = $url['_ext']; @@ -654,18 +699,24 @@ public function match(array $url, array $context = []) // Check the method first as it is special. if (!$this->_matchMethod($url)) { - return false; + return null; } unset($url['_method'], $url['[method]'], $defaults['_method']); - // Missing defaults is a fail. - if (array_diff_key($defaults, $url) !== []) { - return false; - } - // Defaults with different values are a fail. - if (array_intersect_key($url, $defaults) != $defaults) { - return false; + // Check each default value against the URL, but skip null plugin/prefix + // values as they should be treated as "not set" for matching purposes + foreach ($defaults as $key => $val) { + // Skip null plugin/prefix values - they shouldn't affect matching + if (($key === 'plugin' || $key === 'prefix') && $val === null && !isset($url[$key])) { + continue; + } + if (isset($url[$key]) && $url[$key] != $val) { + return null; + } + if (!isset($url[$key]) && $val !== null) { + return null; + } } // If this route uses pass option, and the passed elements are @@ -682,14 +733,11 @@ public function match(array $url, array $context = []) // check that all the key names are in the url $keyNames = array_flip($this->keys); if (array_intersect_key($keyNames, $url) !== $keyNames) { - return false; + return null; } $pass = []; foreach ($url as $key => $value) { - // keys that exist in the defaults and have different values is a match failure. - $defaultExists = array_key_exists($key, $defaults); - // If the key is a routed key, it's not different yet. if (array_key_exists($key, $keyNames)) { continue; @@ -697,61 +745,57 @@ public function match(array $url, array $context = []) // pull out passed args $numeric = is_numeric($key); - if ($numeric && isset($defaults[$key]) && $defaults[$key] == $value) { + if ($numeric && isset($defaults[$key]) && $defaults[$key] === $value) { continue; } if ($numeric) { $pass[] = $value; unset($url[$key]); - continue; - } - - // keys that don't exist are different. - if (!$defaultExists && ($value !== null && $value !== false && $value !== '')) { - $query[$key] = $value; - unset($url[$key]); } } // if not a greedy route, no extra params are allowed. - if (!$this->_greedy && !empty($pass)) { - return false; + if (!$this->_greedy && $pass !== []) { + return null; } // check patterns for routed params - if (!empty($this->options)) { - foreach ($this->options as $key => $pattern) { - if (isset($url[$key]) && !preg_match('#^' . $pattern . '$#', $url[$key])) { - return false; - } + foreach ($this->options as $key => $pattern) { + if (isset($url[$key]) && !preg_match('#^' . $pattern . '$#u', (string)$url[$key])) { + return null; } } $url += $hostOptions; + // Ensure controller/action keys are not null. + if ( + (isset($keyNames['controller']) && !isset($url['controller'])) || + (isset($keyNames['action']) && !isset($url['action'])) + ) { + return null; + } + return $this->_writeUrl($url, $pass, $query); } /** - * Check whether or not the URL's HTTP method matches. + * Check whether the URL's HTTP method matches. * * @param array $url The array for the URL being generated. * @return bool */ - protected function _matchMethod($url) + protected function _matchMethod(array $url): bool { if (empty($this->defaults['_method'])) { return true; } - // @deprecated The `[method]` support should be removed in 4.0.0 - if (isset($url['[method]'])) { - $url['_method'] = $url['[method]']; - } if (empty($url['_method'])) { - return false; + $url['_method'] = 'GET'; } - $methods = array_map('strtoupper', (array)$url['_method']); + $defaults = (array)$this->defaults['_method']; + $methods = (array)$this->normalizeAndValidateMethods($url['_method']); foreach ($methods as $value) { - if (in_array($value, (array)$this->defaults['_method'])) { + if (in_array($value, $defaults, true)) { return true; } } @@ -770,27 +814,39 @@ protected function _matchMethod($url) * @param array $query An array of parameters * @return string Composed route string. */ - protected function _writeUrl($params, $pass = [], $query = []) + protected function _writeUrl(array $params, array $pass = [], array $query = []): string { - $pass = implode('/', array_map('rawurlencode', $pass)); + $pass = array_map(function ($value) { + return rawurlencode((string)$value); + }, $pass); + $pass = implode('/', $pass); $out = $this->template; - - $search = $replace = []; + $search = []; + $replace = []; foreach ($this->keys as $key) { - $string = null; - if (isset($params[$key])) { - $string = $params[$key]; - } elseif (strpos($out, $key) != strlen($out) - strlen($key)) { - $key .= '/'; + if (!array_key_exists($key, $params)) { + throw new InvalidArgumentException(sprintf( + 'Missing required route key `%s`.', + $key, + )); } - $search[] = ':' . $key; + $string = $params[$key]; + if ($string instanceof BackedEnum) { + $string = $string->value; + } elseif ($string instanceof UnitEnum) { + $string = $string->name; + } + + $search[] = "{{$key}}"; $replace[] = $string; } - if (strpos($this->template, '**') !== false) { - array_push($search, '**', '%2F'); - array_push($replace, $pass, '/'); - } elseif (strpos($this->template, '*') !== false) { + if (str_contains($this->template, '**')) { + $search[] = '**'; + $search[] = '%2F'; + $replace[] = $pass; + $replace[] = '/'; + } elseif (str_contains($this->template, '*')) { $search[] = '*'; $replace[] = $pass; } @@ -803,26 +859,27 @@ protected function _writeUrl($params, $pass = [], $query = []) } $out = str_replace('//', '/', $out); - if (isset($params['_scheme']) || + if ( + isset($params['_scheme']) || isset($params['_host']) || isset($params['_port']) ) { $host = $params['_host']; - // append the port & scheme if they exists. + // append the port and scheme if they exist. if (isset($params['_port'])) { $host .= ':' . $params['_port']; } - $scheme = isset($params['_scheme']) ? $params['_scheme'] : 'http'; + $scheme = $params['_scheme'] ?? 'http'; $out = "{$scheme}://{$host}{$out}"; } - if (!empty($params['_ext']) || !empty($query)) { + if (!empty($params['_ext']) || $query !== []) { $out = rtrim($out, '/'); } if (!empty($params['_ext'])) { $out .= '.' . $params['_ext']; } - if (!empty($query)) { + if ($query) { $out .= rtrim('?' . http_build_query($query), '?'); } @@ -834,12 +891,19 @@ protected function _writeUrl($params, $pass = [], $query = []) * * @return string */ - public function staticPath() + public function staticPath(): string { - $routeKey = strpos($this->template, ':'); - if ($routeKey !== false) { - return substr($this->template, 0, $routeKey); + $matched = preg_match( + static::PLACEHOLDER_REGEX, + $this->template, + $namedElements, + PREG_OFFSET_CAPTURE, + ); + + if ($matched) { + return substr($this->template, 0, $namedElements[0][1]); } + $star = strpos($this->template, '*'); if ($star !== false) { $path = rtrim(substr($this->template, 0, $star), '/'); @@ -869,7 +933,7 @@ public function setMiddleware(array $middleware) * * @return array */ - public function getMiddleware() + public function getMiddleware(): array { return $this->middleware; } @@ -880,12 +944,12 @@ public function getMiddleware() * This method helps for applications that want to implement * router caching. * - * @param array $fields Key/Value of object attributes - * @return \Cake\Routing\Route\Route A new instance of the route + * @param array $fields Key/Value of object attributes + * @return static A new instance of the route */ - public static function __set_state($fields) + public static function __set_state(array $fields): static { - $class = get_called_class(); + $class = static::class; $obj = new $class(''); foreach ($fields as $field => $value) { $obj->$field = $value; diff --git a/src/Routing/RouteBuilder.php b/src/Routing/RouteBuilder.php index 96296834925..022b14f04b4 100644 --- a/src/Routing/RouteBuilder.php +++ b/src/Routing/RouteBuilder.php @@ -1,4 +1,6 @@ controller action map. * - * @var array + * @var array */ - protected static $_resourceMap = [ + protected static array $_resourceMap = [ 'index' => ['action' => 'index', 'method' => 'GET', 'path' => ''], 'create' => ['action' => 'add', 'method' => 'POST', 'path' => ''], - 'view' => ['action' => 'view', 'method' => 'GET', 'path' => ':id'], - 'update' => ['action' => 'edit', 'method' => ['PUT', 'PATCH'], 'path' => ':id'], - 'delete' => ['action' => 'delete', 'method' => 'DELETE', 'path' => ':id'], + 'view' => ['action' => 'view', 'method' => 'GET', 'path' => '{id}'], + 'update' => ['action' => 'edit', 'method' => ['PUT', 'PATCH'], 'path' => '{id}'], + 'delete' => ['action' => 'delete', 'method' => 'DELETE', 'path' => '{id}'], ]; /** @@ -64,50 +64,57 @@ class RouteBuilder * * @var string */ - protected $_routeClass = 'Cake\Routing\Route\Route'; + protected string $_routeClass = Route::class; /** * The extensions that should be set into the routes connected. * - * @var array + * @var array */ - protected $_extensions = []; + protected array $_extensions = []; /** * The path prefix scope that this collection uses. * * @var string */ - protected $_path; + protected string $_path; /** * The scope parameters if there are any. * * @var array */ - protected $_params; + protected array $_params; /** * Name prefix for connected routes. * * @var string */ - protected $_namePrefix = ''; + protected string $_namePrefix = ''; /** * The route collection routes should be added to. * * @var \Cake\Routing\RouteCollection */ - protected $_collection; + protected RouteCollection $_collection; /** * The list of middleware that routes in this builder get * added during construction. * - * @var array + * @var array + */ + protected array $middleware = []; + + /** + * Default route options to apply to all routes created in this builder. + * + * @var array */ - protected $middleware = []; + protected array $defaultOptions = []; /** * Constructor @@ -122,9 +129,9 @@ class RouteBuilder * @param \Cake\Routing\RouteCollection $collection The route collection to append routes into. * @param string $path The path prefix the scope is for. * @param array $params The scope's routing parameters. - * @param array $options Options list. + * @param array $options Options list. */ - public function __construct(RouteCollection $collection, $path, array $params = [], array $options = []) + public function __construct(RouteCollection $collection, string $path, array $params = [], array $options = []) { $this->_collection = $collection; $this->_path = $path; @@ -143,28 +150,13 @@ public function __construct(RouteCollection $collection, $path, array $params = } } - /** - * Get or set default route class. - * - * @deprecated 3.5.0 Use getRouteClass/setRouteClass instead. - * @param string|null $routeClass Class name. - * @return string|null - */ - public function routeClass($routeClass = null) - { - if ($routeClass === null) { - return $this->getRouteClass(); - } - $this->setRouteClass($routeClass); - } - /** * Set default route class. * * @param string $routeClass Class name. * @return $this */ - public function setRouteClass($routeClass) + public function setRouteClass(string $routeClass) { $this->_routeClass = $routeClass; @@ -176,39 +168,21 @@ public function setRouteClass($routeClass) * * @return string */ - public function getRouteClass() + public function getRouteClass(): string { return $this->_routeClass; } - /** - * Get or set the extensions in this route builder's scope. - * - * Future routes connected in through this builder will have the connected - * extensions applied. However, setting extensions does not modify existing routes. - * - * @deprecated 3.5.0 Use getExtensions/setExtensions instead. - * @param null|string|array $extensions Either the extensions to use or null. - * @return array|null - */ - public function extensions($extensions = null) - { - if ($extensions === null) { - return $this->getExtensions(); - } - $this->setExtensions($extensions); - } - /** * Set the extensions in this route builder's scope. * * Future routes connected in through this builder will have the connected * extensions applied. However, setting extensions does not modify existing routes. * - * @param string|array $extensions The extensions to set. + * @param array|string $extensions The extensions to set. * @return $this */ - public function setExtensions($extensions) + public function setExtensions(array|string $extensions) { $this->_extensions = (array)$extensions; @@ -218,9 +192,9 @@ public function setExtensions($extensions) /** * Get the extensions in this route builder's scope. * - * @return array + * @return array */ - public function getExtensions() + public function getExtensions(): array { return $this->_extensions; } @@ -228,13 +202,43 @@ public function getExtensions() /** * Add additional extensions to what is already in current scope * - * @param string|array $extensions One or more extensions to add - * @return void + * @param array|string $extensions One or more extensions to add + * @return $this */ - public function addExtensions($extensions) + public function addExtensions(array|string $extensions) { $extensions = array_merge($this->_extensions, (array)$extensions); $this->_extensions = array_unique($extensions); + + return $this; + } + + /** + * Set default options for all routes created in this builder. + * + * These options will be merged with options passed to connect() calls. + * Options passed to connect() will take precedence. + * + * Useful for setting options like `_host`, `_https`, `_port` that should + * apply to all routes within a scope. + * + * Example: + * + * ``` + * $routes->scope('/{org}', function ($routes) { + * $routes->setOptions(['_host' => 'example.com']); + * // All routes here will have _host => 'example.com' + * }); + * ``` + * + * @param array $options Default route options like _host, _https, _port, etc. + * @return $this + */ + public function setOptions(array $options) + { + $this->defaultOptions = $options; + + return $this; } /** @@ -242,8 +246,13 @@ public function addExtensions($extensions) * * @return string */ - public function path() + public function path(): string { + $routeKey = strpos($this->_path, '{'); + if ($routeKey !== false && str_contains($this->_path, '}')) { + return substr($this->_path, 0, $routeKey); + } + $routeKey = strpos($this->_path, ':'); if ($routeKey !== false) { return substr($this->_path, 0, $routeKey); @@ -257,7 +266,7 @@ public function path() * * @return array */ - public function params() + public function params(): array { return $this->_params; } @@ -268,7 +277,7 @@ public function params() * @param string $name Name. * @return bool */ - public function nameExists($name) + public function nameExists(string $name): bool { return array_key_exists($name, $this->_collection->named()); } @@ -282,7 +291,7 @@ public function nameExists($name) * @param string|null $value Either the value to set or null. * @return string */ - public function namePrefix($value = null) + public function namePrefix(?string $value = null): string { if ($value !== null) { $this->_namePrefix = $value; @@ -313,19 +322,19 @@ public function namePrefix($value = null) * }); * ``` * - * Plugins will create lower_case underscored resource routes. e.g + * Plugins will create lowercase dasherized resource routes. e.g * `/comments/comments` * * Connect resource routes for the Articles controller in the * Admin prefix: * * ``` - * Router::prefix('admin', function ($routes) { + * Router::prefix('Admin', function ($routes) { * $routes->resources('Articles'); * }); * ``` * - * Prefixes will create lower_case underscored resource routes. e.g + * Prefixes will create lowercase dasherized resource routes. e.g * `/admin/posts` * * You can create nested resources by passing a callback in: @@ -336,7 +345,7 @@ public function namePrefix($value = null) * }); * ``` * - * The above would generate both resource routes for `/articles`, and `/articles/:article_id/comments`. + * The above would generate both resource routes for `/articles`, and `/articles/{article_id}/comments`. * You can use the `map` option to connect additional resource methods: * * ``` @@ -346,13 +355,13 @@ public function namePrefix($value = null) * ``` * * In addition to the default routes, this would also connect a route for `/articles/delete_all`. - * By default the path segment will match the key name. You can use the 'path' key inside the resource + * By default, the path segment will match the key name. You can use the 'path' key inside the resource * definition to customize the path name. * * You can use the `inflect` option to change how path segments are generated: * * ``` - * $routes->resources('PaymentTypes', ['inflect' => 'dasherize']); + * $routes->resources('PaymentTypes', ['inflect' => 'underscore']); * ``` * * Will generate routes like `/payment-types` instead of `/payment_types` @@ -361,7 +370,7 @@ public function namePrefix($value = null) * * - 'id' - The regular expression fragment to use when matching IDs. By default, matches * integer values and UUIDs. - * - 'inflect' - Choose the inflection method used on the resource name. Defaults to 'underscore'. + * - 'inflect' - Choose the inflection method used on the resource name. Defaults to 'dasherize'. * - 'only' - Only connect the specific list of actions. * - 'actions' - Override the method names used for connecting actions. * - 'map' - Additional resource routes that should be connected. If you define 'only' and 'map', @@ -373,20 +382,20 @@ public function namePrefix($value = null) * is available at `/posts` * * @param string $name A controller name to connect resource routes for. - * @param array|callable $options Options to use when generating REST routes, or a callback. - * @param callable|null $callback An optional callback to be executed in a nested scope. Nested + * @param \Closure|array $options Options to use when generating REST routes, or a callback. + * @param \Closure|null $callback An optional callback to be executed in a nested scope. Nested * scopes inherit the existing path and 'id' parameter. - * @return void + * @return $this */ - public function resources($name, $options = [], $callback = null) + public function resources(string $name, Closure|array $options = [], ?Closure $callback = null) { - if (is_callable($options) && $callback === null) { + if (!is_array($options)) { $callback = $options; $options = []; } $options += [ 'connectOptions' => [], - 'inflect' => 'underscore', + 'inflect' => 'dasherize', 'id' => static::ID . '|' . static::UUID, 'only' => [], 'actions' => [], @@ -412,7 +421,7 @@ public function resources($name, $options = [], $callback = null) $resourceMap = array_merge(static::$_resourceMap, $options['map']); $only = (array)$options['only']; - if (empty($only)) { + if (!$only) { $only = array_keys($resourceMap); } @@ -429,10 +438,7 @@ public function resources($name, $options = [], $callback = null) continue; } - $action = $params['action']; - if (isset($options['actions'][$method])) { - $action = $options['actions'][$method]; - } + $action = $options['actions'][$method] ?? $params['action']; $url = '/' . implode('/', array_filter([$options['path'], $params['path']])); $params = [ @@ -451,23 +457,25 @@ public function resources($name, $options = [], $callback = null) $this->connect($url, $params, $routeOptions); } - if (is_callable($callback)) { + if ($callback !== null) { $idName = Inflector::singularize(Inflector::underscore($name)) . '_id'; - $path = '/' . $options['path'] . '/:' . $idName; + $path = '/' . $options['path'] . '/{' . $idName . '}'; $this->scope($path, [], $callback); } + + return $this; } /** * Create a route that only responds to GET requests. * * @param string $template The URL template to use. - * @param array $target An array describing the target route parameters. These parameters + * @param array|string $target An array describing the target route parameters. These parameters * should indicate the plugin, prefix, controller, and action that this route points to. - * @param string $name The name of the route. + * @param string|null $name The name of the route. * @return \Cake\Routing\Route\Route */ - public function get($template, $target, $name = null) + public function get(string $template, array|string $target, ?string $name = null): Route { return $this->_methodRoute('GET', $template, $target, $name); } @@ -476,12 +484,12 @@ public function get($template, $target, $name = null) * Create a route that only responds to POST requests. * * @param string $template The URL template to use. - * @param array $target An array describing the target route parameters. These parameters + * @param array|string $target An array describing the target route parameters. These parameters * should indicate the plugin, prefix, controller, and action that this route points to. - * @param string $name The name of the route. + * @param string|null $name The name of the route. * @return \Cake\Routing\Route\Route */ - public function post($template, $target, $name = null) + public function post(string $template, array|string $target, ?string $name = null): Route { return $this->_methodRoute('POST', $template, $target, $name); } @@ -490,12 +498,12 @@ public function post($template, $target, $name = null) * Create a route that only responds to PUT requests. * * @param string $template The URL template to use. - * @param array $target An array describing the target route parameters. These parameters + * @param array|string $target An array describing the target route parameters. These parameters * should indicate the plugin, prefix, controller, and action that this route points to. - * @param string $name The name of the route. + * @param string|null $name The name of the route. * @return \Cake\Routing\Route\Route */ - public function put($template, $target, $name = null) + public function put(string $template, array|string $target, ?string $name = null): Route { return $this->_methodRoute('PUT', $template, $target, $name); } @@ -504,12 +512,12 @@ public function put($template, $target, $name = null) * Create a route that only responds to PATCH requests. * * @param string $template The URL template to use. - * @param array $target An array describing the target route parameters. These parameters + * @param array|string $target An array describing the target route parameters. These parameters * should indicate the plugin, prefix, controller, and action that this route points to. - * @param string $name The name of the route. + * @param string|null $name The name of the route. * @return \Cake\Routing\Route\Route */ - public function patch($template, $target, $name = null) + public function patch(string $template, array|string $target, ?string $name = null): Route { return $this->_methodRoute('PATCH', $template, $target, $name); } @@ -518,12 +526,12 @@ public function patch($template, $target, $name = null) * Create a route that only responds to DELETE requests. * * @param string $template The URL template to use. - * @param array $target An array describing the target route parameters. These parameters + * @param array|string $target An array describing the target route parameters. These parameters * should indicate the plugin, prefix, controller, and action that this route points to. - * @param string $name The name of the route. + * @param string|null $name The name of the route. * @return \Cake\Routing\Route\Route */ - public function delete($template, $target, $name = null) + public function delete(string $template, array|string $target, ?string $name = null): Route { return $this->_methodRoute('DELETE', $template, $target, $name); } @@ -532,12 +540,12 @@ public function delete($template, $target, $name = null) * Create a route that only responds to HEAD requests. * * @param string $template The URL template to use. - * @param array $target An array describing the target route parameters. These parameters + * @param array|string $target An array describing the target route parameters. These parameters * should indicate the plugin, prefix, controller, and action that this route points to. - * @param string $name The name of the route. + * @param string|null $name The name of the route. * @return \Cake\Routing\Route\Route */ - public function head($template, $target, $name = null) + public function head(string $template, array|string $target, ?string $name = null): Route { return $this->_methodRoute('HEAD', $template, $target, $name); } @@ -546,12 +554,12 @@ public function head($template, $target, $name = null) * Create a route that only responds to OPTIONS requests. * * @param string $template The URL template to use. - * @param array $target An array describing the target route parameters. These parameters + * @param array|string $target An array describing the target route parameters. These parameters * should indicate the plugin, prefix, controller, and action that this route points to. - * @param string $name The name of the route. + * @param string|null $name The name of the route. * @return \Cake\Routing\Route\Route */ - public function options($template, $target, $name = null) + public function options(string $template, array|string $target, ?string $name = null): Route { return $this->_methodRoute('OPTIONS', $template, $target, $name); } @@ -561,12 +569,12 @@ public function options($template, $target, $name = null) * * @param string $method The HTTP method name to match. * @param string $template The URL template to use. - * @param array $target An array describing the target route parameters. These parameters + * @param array|string $target An array describing the target route parameters. These parameters * should indicate the plugin, prefix, controller, and action that this route points to. - * @param string $name The name of the route. + * @param string|null $name The name of the route. * @return \Cake\Routing\Route\Route */ - protected function _methodRoute($method, $template, $target, $name) + protected function _methodRoute(string $method, string $template, array|string $target, ?string $name): Route { if ($name !== null) { $name = $this->_namePrefix . $name; @@ -576,8 +584,9 @@ protected function _methodRoute($method, $template, $target, $name) '_ext' => $this->_extensions, '_middleware' => $this->middleware, 'routeClass' => $this->_routeClass, - ]; + ] + $this->defaultOptions; + $target = $this->parseDefaults($target); $target['_method'] = $method; $route = $this->_makeRoute($template, $target, $options); @@ -593,28 +602,23 @@ protected function _methodRoute($method, $template, $target, $name) * the current RouteBuilder instance. * * @param string $name The plugin name - * @param string $file The routes file to load. Defaults to `routes.php` - * @return void + * @return $this * @throws \Cake\Core\Exception\MissingPluginException When the plugin has not been loaded. * @throws \InvalidArgumentException When the plugin does not have a routes file. */ - public function loadPlugin($name, $file = 'routes.php') + public function loadPlugin(string $name) { - if (!Plugin::loaded($name)) { + $plugins = Plugin::getCollection(); + if (!$plugins->has($name)) { throw new MissingPluginException(['plugin' => $name]); } + $plugin = $plugins->get($name); + $plugin->routes($this); - $path = Plugin::configPath($name) . DIRECTORY_SEPARATOR . $file; - if (!file_exists($path)) { - throw new InvalidArgumentException(sprintf( - 'Cannot load routes for the plugin named %s. The %s file does not exist.', - $name, - $path - )); - } + // Disable the routes hook to prevent duplicate route issues. + $plugin->disable('routes'); - $routes = $this; - include $path; + return $this; } /** @@ -627,7 +631,7 @@ public function loadPlugin($name, $file = 'routes.php') * Examples: * * ``` - * $routes->connect('/:controller/:action/*'); + * $routes->connect('/{controller}/{action}/*'); * ``` * * The first parameter will be used as a controller name while the second is @@ -644,7 +648,7 @@ public function loadPlugin($name, $file = 'routes.php') * * ``` * $routes->connect( - * '/:lang/:controller/:action/:id', + * '/{lang}/{controller}/{action}/{id}', * [], * ['id' => '[0-9]+', 'lang' => '[a-z]{3}'] * ); @@ -675,6 +679,10 @@ public function loadPlugin($name, $file = 'routes.php') * - `_ext` is an array of filename extensions that will be parsed out of the url if present. * See {@link \Cake\Routing\RouteCollection::setExtensions()}. * - `_method` Only match requests with specific HTTP verbs. + * - `_host` - Define the host name pattern if you want this route to only match + * specific host names. You can use `.*` and to create wildcard subdomains/hosts + * e.g. `*.example.com` matches all subdomains on `example.com`. + * - '_port` - Define the port if you want this route to only match specific port number. * * Example of using the `_method` condition: * @@ -684,10 +692,10 @@ public function loadPlugin($name, $file = 'routes.php') * * The above route will only be matched for GET requests. POST requests will fail to match this route. * - * @param string $route A string describing the template of the route - * @param array $defaults An array describing the default route parameters. These parameters will be used by default - * and can supply routing parameters that are not dynamic. See above. - * @param array $options An array matching the named elements in the route to regular expressions which that + * @param \Cake\Routing\Route\Route|string $route A string describing the template of the route + * @param array|string $defaults An array describing the default route parameters. + * These parameters will be used by default and can supply routing parameters that are not dynamic. See above. + * @param array $options An array matching the named elements in the route to regular expressions which that * element should match. Also contains additional parameters such as which routed parameters should be * shifted into the passed arguments, supplying patterns for routing parameters and supplying the name of a * custom routing class. @@ -695,16 +703,14 @@ public function loadPlugin($name, $file = 'routes.php') * @throws \InvalidArgumentException * @throws \BadMethodCallException */ - public function connect($route, array $defaults = [], array $options = []) + public function connect(Route|string $route, array|string $defaults = [], array $options = []): Route { - if (!isset($options['action']) && !isset($defaults['action'])) { - $defaults['action'] = 'index'; - } + $defaults = $this->parseDefaults($defaults); + $options += $this->defaultOptions; if (empty($options['_ext'])) { $options['_ext'] = $this->_extensions; } - if (empty($options['routeClass'])) { $options['routeClass'] = $this->_routeClass; } @@ -721,24 +727,40 @@ public function connect($route, array $defaults = [], array $options = []) return $route; } + /** + * Parse the defaults if they're a string + * + * @param array|string $defaults Defaults array from the connect() method. + * @return array + */ + protected function parseDefaults(array|string $defaults): array + { + if (is_string($defaults)) { + return Router::parseRoutePath($defaults); + } + + return $defaults; + } + /** * Create a route object, or return the provided object. * - * @param string|\Cake\Routing\Route\Route $route The route template or route object. + * @param \Cake\Routing\Route\Route|string $route The route template or route object. * @param array $defaults Default parameters. - * @param array $options Additional options parameters. + * @param array $options Additional options parameters. * @return \Cake\Routing\Route\Route * @throws \InvalidArgumentException when route class or route object is invalid. * @throws \BadMethodCallException when the route to make conflicts with the current scope */ - protected function _makeRoute($route, $defaults, $options) + protected function _makeRoute(Route|string $route, array $defaults, array $options): Route { if (is_string($route)) { + /** @var class-string<\Cake\Routing\Route\Route>|null $routeClass */ $routeClass = App::className($options['routeClass'], 'Routing/Route'); - if ($routeClass === false) { + if ($routeClass === null) { throw new InvalidArgumentException(sprintf( 'Cannot find route class %s', - $options['routeClass'] + $options['routeClass'], )); } @@ -756,21 +778,19 @@ protected function _makeRoute($route, $defaults, $options) $param, $val, $param, - $defaults[$param] + $defaults[$param], )); } } $defaults += $this->_params + ['plugin' => null]; + if (!isset($defaults['action']) && !isset($options['action'])) { + $defaults['action'] = 'index'; + } $route = new $routeClass($route, $defaults, $options); } - if ($route instanceof Route) { - return $route; - } - throw new InvalidArgumentException( - 'Route class not found, or route class is not a subclass of Cake\Routing\Route\Route' - ); + return $route; } /** @@ -783,7 +803,7 @@ protected function _makeRoute($route, $defaults, $options) * Examples: * * ``` - * $routes->redirect('/home/*', ['controller' => 'posts', 'action' => 'view']); + * $routes->redirect('/home/*', ['controller' => 'Posts', 'action' => 'view']); * ``` * * Redirects /home/* to /posts/view and passes the parameters to /posts/view. Using an array as the @@ -803,20 +823,19 @@ protected function _makeRoute($route, $defaults, $options) * * @param string $route A string describing the template of the route * @param array|string $url A URL to redirect to. Can be a string or a Cake array-based URL - * @param array $options An array matching the named elements in the route to regular expressions which that + * @param array $options An array matching the named elements in the route to regular expressions which that * element should match. Also contains additional parameters such as which routed parameters should be * shifted into the passed arguments. As well as supplying patterns for routing parameters. - * @return void + * @return \Cake\Routing\Route\Route */ - public function redirect($route, $url, array $options = []) + public function redirect(string $route, array|string $url, array $options = []): Route { - if (!isset($options['routeClass'])) { - $options['routeClass'] = 'Cake\Routing\Route\RedirectRoute'; - } + $options['routeClass'] ??= RedirectRoute::class; if (is_string($url)) { $url = ['redirect' => $url]; } - $this->connect($route, $url, $options); + + return $this->connect($route, $url, $options); } /** @@ -837,31 +856,28 @@ public function redirect($route, $url, array $options = []) * for $params argument: * * ``` - * $route->prefix('api', function($route) { - * $route->prefix('v10', ['path' => '/v1.0'], function($route) { + * $route->prefix('Api', function($route) { + * $route->prefix('V10', ['path' => '/v1.0'], function($route) { * // Translates to `Controller\Api\V10\` namespace * }); * }); * ``` * * @param string $name The prefix name to use. - * @param array|callable $params An array of routing defaults to add to each connected route. - * If you have no parameters, this argument can be a callable. - * @param callable|null $callback The callback to invoke that builds the prefixed routes. - * @return void + * @param \Closure|array $params An array of routing defaults to add to each connected route. + * If you have no parameters, this argument can be a Closure. + * @param \Closure|null $callback The callback to invoke that builds the prefixed routes. + * @return $this * @throws \InvalidArgumentException If a valid callback is not passed */ - public function prefix($name, $params = [], callable $callback = null) + public function prefix(string $name, Closure|array $params = [], ?Closure $callback = null) { - if ($callback === null) { - if (!is_callable($params)) { - throw new InvalidArgumentException('A valid callback is expected'); - } + if (!is_array($params)) { $callback = $params; $params = []; } - $name = Inflector::underscore($name); - $path = '/' . $name; + $path = '/' . Inflector::dasherize($name); + $name = Inflector::camelize($name); if (isset($params['path'])) { $path = $params['path']; unset($params['path']); @@ -871,6 +887,8 @@ public function prefix($name, $params = [], callable $callback = null) } $params = array_merge($params, ['prefix' => $name]); $this->scope($path, $params, $callback); + + return $this; } /** @@ -885,23 +903,31 @@ public function prefix($name, $params = [], callable $callback = null) * Routes connected in the scoped collection will have the correct path segment * prepended, and have a matching plugin routing key set. * + * ### Options + * + * - `path` The path prefix to use. Defaults to `Inflector::dasherize($name)`. + * - `_namePrefix` Set a prefix used for named routes. The prefix is prepended to the + * name of any route created in a scope callback. + * * @param string $name The plugin name to build routes for - * @param array|callable $options Either the options to use, or a callback - * @param callable|null $callback The callback to invoke that builds the plugin routes + * @param \Closure|array $options Either the options to use, or a callback to build routes. + * @param \Closure|null $callback The callback to invoke that builds the plugin routes * Only required when $options is defined. - * @return void + * @return $this */ - public function plugin($name, $options = [], $callback = null) + public function plugin(string $name, Closure|array $options = [], ?Closure $callback = null) { - if ($callback === null) { + if (!is_array($options)) { $callback = $options; $options = []; } - $params = ['plugin' => $name] + $this->_params; - if (empty($options['path'])) { - $options['path'] = '/' . Inflector::underscore($name); - } - $this->scope($options['path'], $params, $callback); + + $path = $options['path'] ?? '/' . Inflector::dasherize($name); + unset($options['path']); + $options = ['plugin' => $name] + $options; + $this->scope($path, $options, $callback); + + return $this; } /** @@ -911,22 +937,26 @@ public function plugin($name, $options = [], $callback = null) * added to. This means that both the current path and parameters will be appended * to the supplied parameters. * + * ### Special Keys in $params + * + * - `_namePrefix` Set a prefix used for named routes. The prefix is prepended to the + * name of any route created in a scope callback. + * * @param string $path The path to create a scope for. - * @param array|callable $params Either the parameters to add to routes, or a callback. - * @param callable|null $callback The callback to invoke that builds the plugin routes. + * @param \Closure|array $params Either the parameters to add to routes, or a callback. + * @param \Closure|null $callback The callback to invoke that builds the plugin routes. * Only required when $params is defined. - * @return void + * @return $this * @throws \InvalidArgumentException when there is no callable parameter. */ - public function scope($path, $params, $callback = null) + public function scope(string $path, Closure|array $params, ?Closure $callback = null) { - if ($callback === null) { + if ($params instanceof Closure) { $callback = $params; $params = []; } - if (!is_callable($callback)) { - $msg = 'Need a callable function/object to connect routes.'; - throw new InvalidArgumentException($msg); + if ($callback === null) { + throw new InvalidArgumentException('Need a valid Closure to connect routes.'); } if ($this->_path !== '/') { @@ -945,23 +975,29 @@ public function scope($path, $params, $callback = null) 'namePrefix' => $namePrefix, 'middleware' => $this->middleware, ]); + // Inherit default options from parent scope + $builder->defaultOptions = $this->defaultOptions; $callback($builder); + + return $this; } /** - * Connect the `/:controller` and `/:controller/:action/*` fallback routes. + * Connect the `/{controller}` and `/{controller}/{action}/*` fallback routes. * * This is a shortcut method for connecting fallback routes in a given scope. * * @param string|null $routeClass the route class to use, uses the default routeClass * if not specified - * @return void + * @return $this */ - public function fallbacks($routeClass = null) + public function fallbacks(?string $routeClass = null) { $routeClass = $routeClass ?: $this->_routeClass; - $this->connect('/:controller', ['action' => 'index'], compact('routeClass')); - $this->connect('/:controller/:action/*', [], compact('routeClass')); + $this->connect('/{controller}', ['action' => 'index'], compact('routeClass')); + $this->connect('/{controller}/{action}/*', [], compact('routeClass')); + + return $this; } /** @@ -971,11 +1007,11 @@ public function fallbacks($routeClass = null) * scope or any child scopes that share the same RouteCollection. * * @param string $name The name of the middleware. Used when applying middleware to a scope. - * @param callable|string $middleware The middleware callable or class name to register. + * @param \Psr\Http\Server\MiddlewareInterface|\Closure|string $middleware The middleware to register. * @return $this * @see \Cake\Routing\RouteCollection */ - public function registerMiddleware($name, $middleware) + public function registerMiddleware(string $name, MiddlewareInterface|Closure|string $middleware) { $this->_collection->registerMiddleware($name, $middleware); @@ -983,36 +1019,47 @@ public function registerMiddleware($name, $middleware) } /** - * Apply a middleware to the current route scope. + * Apply one or many middleware to the current route scope. * - * Requires middleware to be registered via `registerMiddleware()` + * Requires middleware to be registered via `registerMiddleware()`. * * @param string ...$names The names of the middleware to apply to the current scope. * @return $this - * @see \Cake\Routing\RouteCollection::addMiddlewareToScope() + * @throws \InvalidArgumentException If it cannot apply one of the given middleware or middleware groups. + * @see \Cake\Routing\RouteCollection::registerMiddleware() */ - public function applyMiddleware(...$names) + public function applyMiddleware(string ...$names) { foreach ($names as $name) { if (!$this->_collection->middlewareExists($name)) { - $message = "Cannot apply '$name' middleware or middleware group. " . - 'Use registerMiddleware() to register middleware.'; - throw new RuntimeException($message); + $message = "Cannot apply `{$name}` middleware or middleware group. " . + 'Use `registerMiddleware()` to register middleware.'; + throw new InvalidArgumentException($message); } } - $this->middleware = array_merge($this->middleware, $names); + $this->middleware = array_unique(array_merge($this->middleware, $names)); return $this; } + /** + * Get the middleware that this builder will apply to routes. + * + * @return array + */ + public function getMiddleware(): array + { + return $this->middleware; + } + /** * Apply a set of middleware to a group * * @param string $name Name of the middleware group - * @param array $middlewareNames Names of the middleware + * @param array $middlewareNames Names of the middleware * @return $this */ - public function middlewareGroup($name, array $middlewareNames) + public function middlewareGroup(string $name, array $middlewareNames) { $this->_collection->middlewareGroup($name, $middlewareNames); diff --git a/src/Routing/RouteCollection.php b/src/Routing/RouteCollection.php index c11d909c896..a7b30f51809 100644 --- a/src/Routing/RouteCollection.php +++ b/src/Routing/RouteCollection.php @@ -1,4 +1,6 @@ > */ - protected $_routeTable = []; + protected array $_routeTable = []; /** - * The routes connected to this collection. + * The hash map of named routes that are in this collection. * - * @var \Cake\Routing\Route\Route[] + * @var array<\Cake\Routing\Route\Route> */ - protected $_routes = []; + protected array $_named = []; /** - * The hash map of named routes that are in this collection. + * Routes indexed by static path. * - * @var \Cake\Routing\Route\Route[] + * @var array> */ - protected $_named = []; + protected array $staticPaths = []; /** * Routes indexed by path prefix. * - * @var array + * @var array> */ - protected $_paths = []; + protected array $_paths = []; /** * A map of middleware names and the related objects. * * @var array */ - protected $_middleware = []; + protected array $_middleware = []; /** * A map of middleware group names and the related middleware names. * * @var array */ - protected $_middlewareGroups = []; - - /** - * A map of paths and the list of applicable middleware. - * - * @var array - */ - protected $_middlewarePaths = []; + protected array $_middlewareGroups = []; /** * Route extensions * - * @var array + * @var array */ - protected $_extensions = []; + protected array $_extensions = []; /** * Add a route to the collection. * * @param \Cake\Routing\Route\Route $route The route object to add. - * @param array $options Additional options for the route. Primarily for the + * @param array $options Additional options for the route. Primarily for the * `_name` option, which enables named routes. * @return void */ - public function add(Route $route, array $options = []) + public function add(Route $route, array $options = []): void { - $this->_routes[] = $route; - // Explicit names if (isset($options['_name'])) { if (isset($this->_named[$options['_name']])) { @@ -114,96 +108,68 @@ public function add(Route $route, array $options = []) // Generated names. $name = $route->getName(); - if (!isset($this->_routeTable[$name])) { - $this->_routeTable[$name] = []; - } + $this->_routeTable[$name] ??= []; $this->_routeTable[$name][] = $route; // Index path prefixes (for parsing) $path = $route->staticPath(); - $this->_paths[$path][] = $route; $extensions = $route->getExtensions(); - if (count($extensions) > 0) { + if ($extensions !== []) { $this->setExtensions($extensions); } + + if ($path === $route->template) { + $this->staticPaths[$path][] = $route; + } + + $this->_paths[$path][] = $route; } /** - * Takes the URL string and iterates the routes until one is able to parse the route. + * Takes the ServerRequestInterface, iterates the routes until one is able to parse the route. * - * @param string $url URL to parse. - * @param string $method The HTTP method to use. + * @param \Psr\Http\Message\ServerRequestInterface $request The request to parse route data from. * @return array An array of request parameters parsed from the URL. * @throws \Cake\Routing\Exception\MissingRouteException When a URL has no matching route. */ - public function parse($url, $method = '') + public function parseRequest(ServerRequestInterface $request): array { - $decoded = urldecode($url); - - // Sort path segments matching longest paths first. - $paths = array_keys($this->_paths); - rsort($paths); - - foreach ($paths as $path) { - if (strpos($decoded, $path) !== 0) { - continue; - } + $uri = $request->getUri(); - $queryParameters = null; - if (strpos($url, '?') !== false) { - list($url, $queryParameters) = explode('?', $url, 2); - parse_str($queryParameters, $queryParameters); - } - /* @var \Cake\Routing\Route\Route $route */ - foreach ($this->_paths[$path] as $route) { - $r = $route->parse($url, $method); - if ($r === false) { + $urlPath = $uri->getPath(); + if (str_contains($urlPath, '%')) { + $urlPath = urldecodeSegments($urlPath); + } + if ($urlPath !== '/') { + $urlPath = rtrim($urlPath, '/'); + } + if (isset($this->staticPaths[$urlPath])) { + foreach ($this->staticPaths[$urlPath] as $route) { + $r = $route->parseRequest($request); + if ($r === null) { continue; } - if ($queryParameters) { - $r['?'] = $queryParameters; + if ($uri->getQuery()) { + parse_str($uri->getQuery(), $queryParameters); + $r['?'] = array_merge($r['?'] ?? [], $queryParameters); } return $r; } } - $exceptionProperties = ['url' => $url]; - if ($method !== '') { - // Ensure that if the method is included, it is the first element of - // the array, to match the order that the strings are printed in the - // MissingRouteException error message, $_messageTemplateWithMethod. - $exceptionProperties = array_merge(['method' => $method], $exceptionProperties); - } - throw new MissingRouteException($exceptionProperties); - } - - /** - * Takes the ServerRequestInterface, iterates the routes until one is able to parse the route. - * - * @param \Psr\Http\Message\ServerRequestInterface $request The request to parse route data from. - * @return array An array of request parameters parsed from the URL. - * @throws \Cake\Routing\Exception\MissingRouteException When a URL has no matching route. - */ - public function parseRequest(ServerRequestInterface $request) - { - $uri = $request->getUri(); - $urlPath = urldecode($uri->getPath()); - // Sort path segments matching longest paths first. - $paths = array_keys($this->_paths); - rsort($paths); + krsort($this->_paths); - foreach ($paths as $path) { - if (strpos($urlPath, $path) !== 0) { + foreach ($this->_paths as $path => $routes) { + if (!str_starts_with($urlPath, $path)) { continue; } - /* @var \Cake\Routing\Route\Route $route */ - foreach ($this->_paths[$path] as $route) { + foreach ($routes as $route) { $r = $route->parseRequest($request); - if ($r === false) { + if ($r === null) { continue; } if ($uri->getQuery()) { @@ -222,9 +188,9 @@ public function parseRequest(ServerRequestInterface $request) * and newer style urls containing '_name' * * @param array $url The url to match. - * @return array The set of names of the url + * @return array The set of names of the url */ - protected function _getNames($url) + protected function _getNames(array $url): array { $plugin = false; if (isset($url['plugin']) && $url['plugin'] !== false) { @@ -234,13 +200,13 @@ protected function _getNames($url) if (isset($url['prefix']) && $url['prefix'] !== false) { $prefix = strtolower($url['prefix']); } - $controller = strtolower($url['controller']); + $controller = isset($url['controller']) ? strtolower($url['controller']) : null; $action = strtolower($url['action']); $names = [ - "${controller}:${action}", - "${controller}:_action", - "_controller:${action}", + "{$controller}:{$action}", + "{$controller}:_action", + "_controller:{$action}", '_controller:_action', ]; @@ -252,13 +218,13 @@ protected function _getNames($url) // Only a plugin if ($prefix === false) { return [ - "${plugin}.${controller}:${action}", - "${plugin}.${controller}:_action", - "${plugin}._controller:${action}", - "${plugin}._controller:_action", - "_plugin.${controller}:${action}", - "_plugin.${controller}:_action", - "_plugin._controller:${action}", + "{$plugin}.{$controller}:{$action}", + "{$plugin}.{$controller}:_action", + "{$plugin}._controller:{$action}", + "{$plugin}._controller:_action", + "_plugin.{$controller}:{$action}", + "_plugin.{$controller}:_action", + "_plugin._controller:{$action}", '_plugin._controller:_action', ]; } @@ -266,13 +232,13 @@ protected function _getNames($url) // Only a prefix if ($plugin === false) { return [ - "${prefix}:${controller}:${action}", - "${prefix}:${controller}:_action", - "${prefix}:_controller:${action}", - "${prefix}:_controller:_action", - "_prefix:${controller}:${action}", - "_prefix:${controller}:_action", - "_prefix:_controller:${action}", + "{$prefix}:{$controller}:{$action}", + "{$prefix}:{$controller}:_action", + "{$prefix}:_controller:{$action}", + "{$prefix}:_controller:_action", + "_prefix:{$controller}:{$action}", + "_prefix:{$controller}:_action", + "_prefix:_controller:{$action}", '_prefix:_controller:_action', ]; } @@ -280,21 +246,21 @@ protected function _getNames($url) // Prefix and plugin has the most options // as there are 4 factors. return [ - "${prefix}:${plugin}.${controller}:${action}", - "${prefix}:${plugin}.${controller}:_action", - "${prefix}:${plugin}._controller:${action}", - "${prefix}:${plugin}._controller:_action", - "${prefix}:_plugin.${controller}:${action}", - "${prefix}:_plugin.${controller}:_action", - "${prefix}:_plugin._controller:${action}", - "${prefix}:_plugin._controller:_action", - "_prefix:${plugin}.${controller}:${action}", - "_prefix:${plugin}.${controller}:_action", - "_prefix:${plugin}._controller:${action}", - "_prefix:${plugin}._controller:_action", - "_prefix:_plugin.${controller}:${action}", - "_prefix:_plugin.${controller}:_action", - "_prefix:_plugin._controller:${action}", + "{$prefix}:{$plugin}.{$controller}:{$action}", + "{$prefix}:{$plugin}.{$controller}:_action", + "{$prefix}:{$plugin}._controller:{$action}", + "{$prefix}:{$plugin}._controller:_action", + "{$prefix}:_plugin.{$controller}:{$action}", + "{$prefix}:_plugin.{$controller}:_action", + "{$prefix}:_plugin._controller:{$action}", + "{$prefix}:_plugin._controller:_action", + "_prefix:{$plugin}.{$controller}:{$action}", + "_prefix:{$plugin}.{$controller}:_action", + "_prefix:{$plugin}._controller:{$action}", + "_prefix:{$plugin}._controller:_action", + "_prefix:_plugin.{$controller}:{$action}", + "_prefix:_plugin.{$controller}:_action", + "_prefix:_plugin._controller:{$action}", '_prefix:_plugin._controller:_action', ]; } @@ -311,7 +277,7 @@ protected function _getNames($url) * @return string The URL string on match. * @throws \Cake\Routing\Exception\MissingRouteException When no route could be matched. */ - public function match($url, $context) + public function match(array $url, array $context): string { // Named routes support optimization. if (isset($url['_name'])) { @@ -323,10 +289,17 @@ public function match($url, $context) if ($out) { return $out; } + $message = sprintf( + 'A named route was found for `%s`, but matching failed. Passed parameters: `%s`.', + $name, + (string)json_encode($url), + ); + throw new MissingRouteException([ 'url' => $name, 'context' => $context, - 'message' => 'A named route was found for "%s", but matching failed.', + // Escape `%` so the message survives CakeException's vsprintf() pass unchanged. + 'message' => str_replace('%', '%%', $message), ]); } throw new MissingRouteException(['url' => $name, 'context' => $context]); @@ -336,11 +309,10 @@ public function match($url, $context) if (empty($this->_routeTable[$name])) { continue; } - /* @var \Cake\Routing\Route\Route $route */ foreach ($this->_routeTable[$name] as $route) { $match = $route->match($url, $context); if ($match) { - return strlen($match) > 1 ? trim($match, '/') : $match; + return $match === '/' ? $match : trim($match, '/'); } } } @@ -350,48 +322,37 @@ public function match($url, $context) /** * Get all the connected routes as a flat list. * - * @return \Cake\Routing\Route\Route[] + * Routes will not be returned in the order they were added. + * + * @return array<\Cake\Routing\Route\Route> */ - public function routes() + public function routes(): array { - return $this->_routes; + krsort($this->_paths); + + return array_reduce( + $this->_paths, + 'array_merge', + [], + ); } /** * Get the connected named routes. * - * @return \Cake\Routing\Route\Route[] + * @return array<\Cake\Routing\Route\Route> */ - public function named() + public function named(): array { return $this->_named; } - /** - * Get/set the extensions that the route collection could handle. - * - * @param null|string|array $extensions Either the list of extensions to set, - * or null to get. - * @param bool $merge Whether to merge with or override existing extensions. - * Defaults to `true`. - * @return array The valid extensions. - * @deprecated 3.5.0 Use getExtensions()/setExtensions() instead. - */ - public function extensions($extensions = null, $merge = true) - { - if ($extensions !== null) { - $this->setExtensions((array)$extensions, $merge); - } - - return $this->getExtensions(); - } - /** * Get the extensions that can be handled. * - * @return array The valid extensions. + * @return array The valid extensions. */ - public function getExtensions() + public function getExtensions(): array { return $this->_extensions; } @@ -399,17 +360,17 @@ public function getExtensions() /** * Set the extensions that the route collection can handle. * - * @param array $extensions The list of extensions to set. + * @param array $extensions The list of extensions to set. * @param bool $merge Whether to merge with or override existing extensions. * Defaults to `true`. * @return $this */ - public function setExtensions(array $extensions, $merge = true) + public function setExtensions(array $extensions, bool $merge = true) { if ($merge) { $extensions = array_unique(array_merge( $this->_extensions, - $extensions + $extensions, )); } $this->_extensions = $extensions; @@ -424,10 +385,10 @@ public function setExtensions(array $extensions, $merge = true) * scope or any child scopes that share the same RouteCollection. * * @param string $name The name of the middleware. Used when applying middleware to a scope. - * @param callable|string $middleware The middleware callable or class name to register. + * @param \Psr\Http\Server\MiddlewareInterface|\Closure|string $middleware The middleware to register. * @return $this */ - public function registerMiddleware($name, $middleware) + public function registerMiddleware(string $name, MiddlewareInterface|Closure|string $middleware) { $this->_middleware[$name] = $middleware; @@ -438,20 +399,21 @@ public function registerMiddleware($name, $middleware) * Add middleware to a middleware group * * @param string $name Name of the middleware group - * @param array $middlewareNames Names of the middleware + * @param array $middlewareNames Names of the middleware * @return $this + * @throws \InvalidArgumentException */ - public function middlewareGroup($name, array $middlewareNames) + public function middlewareGroup(string $name, array $middlewareNames) { if ($this->hasMiddleware($name)) { - $message = "Cannot add middleware group '$name'. A middleware by this name has already been registered."; - throw new RuntimeException($message); + $message = "Cannot add middleware group '{$name}'. A middleware by this name has already been registered."; + throw new InvalidArgumentException($message); } foreach ($middlewareNames as $middlewareName) { if (!$this->hasMiddleware($middlewareName)) { - $message = "Cannot add '$middlewareName' middleware to group '$name'. It has not been registered."; - throw new RuntimeException($message); + $message = "Cannot add '{$middlewareName}' middleware to group '{$name}'. It has not been registered."; + throw new InvalidArgumentException($message); } } @@ -466,7 +428,7 @@ public function middlewareGroup($name, array $middlewareNames) * @param string $name The name of the middleware group to check. * @return bool */ - public function hasMiddlewareGroup($name) + public function hasMiddlewareGroup(string $name): bool { return array_key_exists($name, $this->_middlewareGroups); } @@ -477,7 +439,7 @@ public function hasMiddlewareGroup($name) * @param string $name The name of the middleware to check. * @return bool */ - public function hasMiddleware($name) + public function hasMiddleware(string $name): bool { return isset($this->_middleware[$name]); } @@ -488,47 +450,20 @@ public function hasMiddleware($name) * @param string $name The name of the middleware to check. * @return bool */ - public function middlewareExists($name) + public function middlewareExists(string $name): bool { return $this->hasMiddleware($name) || $this->hasMiddlewareGroup($name); } - /** - * Apply a registered middleware(s) for the provided path - * - * @param string $path The URL path to register middleware for. - * @param string[] $middleware The middleware names to add for the path. - * @return $this - */ - public function applyMiddleware($path, array $middleware) - { - foreach ($middleware as $name) { - if (!$this->hasMiddleware($name) && !$this->hasMiddlewareGroup($name)) { - $message = "Cannot apply '$name' middleware or middleware group to path '$path'. It has not been registered."; - throw new RuntimeException($message); - } - } - // Matches route element pattern in Cake\Routing\Route - $path = '#^' . preg_quote($path, '#') . '#'; - $path = preg_replace('/\\\\:([a-z0-9-_]+(?_middlewarePaths[$path])) { - $this->_middlewarePaths[$path] = []; - } - $this->_middlewarePaths[$path] = array_merge($this->_middlewarePaths[$path], $middleware); - - return $this; - } - /** * Get an array of middleware given a list of names * - * @param array $names The names of the middleware or groups to fetch + * @param array $names The names of the middleware or groups to fetch * @return array An array of middleware. If any of the passed names are groups, * the groups middleware will be flattened into the returned list. - * @throws \RuntimeException when a requested middleware does not exist. + * @throws \InvalidArgumentException when a requested middleware does not exist. */ - public function getMiddleware(array $names) + public function getMiddleware(array $names): array { $out = []; foreach ($names as $name) { @@ -537,8 +472,10 @@ public function getMiddleware(array $names) continue; } if (!$this->hasMiddleware($name)) { - $message = "The middleware named '$name' has not been registered. Use registerMiddleware() to define it."; - throw new RuntimeException($message); + throw new InvalidArgumentException(sprintf( + 'The middleware named `%s` has not been registered. Use registerMiddleware() to define it.', + $name, + )); } $out[] = $this->_middleware[$name]; } diff --git a/src/Routing/Router.php b/src/Routing/Router.php index 41a9e58a098..e13b79bc5ad 100644 --- a/src/Routing/Router.php +++ b/src/Routing/Router.php @@ -1,4 +1,6 @@ */ - protected static $_requestContext = []; + protected static array $_requestContext = []; /** * Named expressions * - * @var array + * @var array */ - protected static $_namedExpressions = [ + protected static array $_namedExpressions = [ 'Action' => Router::ACTION, 'Year' => Router::YEAR, 'Month' => Router::MONTH, 'Day' => Router::DAY, 'ID' => Router::ID, - 'UUID' => Router::UUID + 'UUID' => Router::UUID, ]; /** - * Maintains the request object stack for the current request. - * This will contain more than one request object when requestAction is used. + * Maintains the request object reference. * - * @var array + * @var \Cake\Http\ServerRequest|null */ - protected static $_requests = []; + protected static ?ServerRequest $_request = null; /** * Initial state is populated the first time reload() is called which is at the bottom @@ -142,22 +134,29 @@ class Router * * @var array */ - protected static $_initialState = []; + protected static array $_initialState = []; /** * The stack of URL filters to apply against routing URLs before passing the * parameters to the route collection. * - * @var callable[] + * @var array<\Closure> */ - protected static $_urlFilters = []; + protected static array $_urlFilters = []; /** * Default extensions defined with Router::extensions() * - * @var array + * @var array */ - protected static $_defaultExtensions = []; + protected static array $_defaultExtensions = []; + + /** + * Cache of parsed route paths + * + * @var array + */ + protected static array $_routePaths = []; /** * Get or set default route class. @@ -165,308 +164,65 @@ class Router * @param string|null $routeClass Class name. * @return string|null */ - public static function defaultRouteClass($routeClass = null) + public static function defaultRouteClass(?string $routeClass = null): ?string { if ($routeClass === null) { return static::$_defaultRouteClass; } static::$_defaultRouteClass = $routeClass; + + return null; } /** * Gets the named route patterns for use in config/routes.php * - * @return array Named route elements + * @return array Named route elements * @see \Cake\Routing\Router::$_namedExpressions */ - public static function getNamedExpressions() + public static function getNamedExpressions(): array { return static::$_namedExpressions; } /** - * Connects a new Route in the router. - * - * Compatibility proxy to \Cake\Routing\RouteBuilder::connect() in the `/` scope. - * - * @param string $route A string describing the template of the route - * @param array $defaults An array describing the default route parameters. These parameters will be used by default - * and can supply routing parameters that are not dynamic. See above. - * @param array $options An array matching the named elements in the route to regular expressions which that - * element should match. Also contains additional parameters such as which routed parameters should be - * shifted into the passed arguments, supplying patterns for routing parameters and supplying the name of a - * custom routing class. - * @return void - * @throws \Cake\Core\Exception\Exception - * @see \Cake\Routing\RouteBuilder::connect() - * @see \Cake\Routing\Router::scope() - */ - public static function connect($route, $defaults = [], $options = []) - { - static::$initialized = true; - static::scope('/', function ($routes) use ($route, $defaults, $options) { - $routes->connect($route, $defaults, $options); - }); - } - - /** - * Connects a new redirection Route in the router. - * - * Compatibility proxy to \Cake\Routing\RouteBuilder::redirect() in the `/` scope. - * - * @param string $route A string describing the template of the route - * @param array $url A URL to redirect to. Can be a string or a Cake array-based URL - * @param array $options An array matching the named elements in the route to regular expressions which that - * element should match. Also contains additional parameters such as which routed parameters should be - * shifted into the passed arguments. As well as supplying patterns for routing parameters. - * @return void - * @see \Cake\Routing\RouteBuilder::redirect() - * @deprecated 3.3.0 Use Router::scope() and RouteBuilder::redirect() instead. - */ - public static function redirect($route, $url, $options = []) - { - if (is_string($url)) { - $url = ['redirect' => $url]; - } - if (!isset($options['routeClass'])) { - $options['routeClass'] = 'Cake\Routing\Route\RedirectRoute'; - } - static::connect($route, $url, $options); - } - - /** - * Generate REST resource routes for the given controller(s). - * - * Compatibility proxy to \Cake\Routing\RouteBuilder::resources(). Additional, compatibility - * around prefixes and plugins and prefixes is handled by this method. - * - * A quick way to generate a default routes to a set of REST resources (controller(s)). - * - * ### Usage - * - * Connect resource routes for an app controller: - * - * ``` - * Router::mapResources('Posts'); - * ``` - * - * Connect resource routes for the Comment controller in the - * Comments plugin: - * - * ``` - * Router::mapResources('Comments.Comment'); - * ``` - * - * Plugins will create lower_case underscored resource routes. e.g - * `/comments/comment` - * - * Connect resource routes for the Posts controller in the - * Admin prefix: - * - * ``` - * Router::mapResources('Posts', ['prefix' => 'admin']); - * ``` - * - * Prefixes will create lower_case underscored resource routes. e.g - * `/admin/posts` - * - * ### Options: - * - * - 'id' - The regular expression fragment to use when matching IDs. By default, matches - * integer values and UUIDs. - * - 'prefix' - Routing prefix to use for the generated routes. Defaults to ''. - * Using this option will create prefixed routes, similar to using Routing.prefixes. - * - 'only' - Only connect the specific list of actions. - * - 'actions' - Override the method names used for connecting actions. - * - 'map' - Additional resource routes that should be connected. If you define 'only' and 'map', - * make sure that your mapped methods are also in the 'only' list. - * - 'path' - Change the path so it doesn't match the resource name. E.g ArticlesController - * is available at `/posts` - * - * @param string|array $controller A controller name or array of controller names (i.e. "Posts" or "ListItems") - * @param array $options Options to use when generating REST routes - * @see \Cake\Routing\RouteBuilder::resources() - * @deprecated 3.3.0 Use Router::scope() and RouteBuilder::resources() instead. - * @return void - */ - public static function mapResources($controller, $options = []) - { - foreach ((array)$controller as $name) { - list($plugin, $name) = pluginSplit($name); - - $prefix = $pluginUrl = false; - if (!empty($options['prefix'])) { - $prefix = $options['prefix']; - unset($options['prefix']); - } - if ($plugin) { - $pluginUrl = Inflector::underscore($plugin); - } - - $callback = function ($routes) use ($name, $options) { - $routes->resources($name, $options); - }; - - if ($plugin && $prefix) { - $path = '/' . implode('/', [$prefix, $pluginUrl]); - $params = ['prefix' => $prefix, 'plugin' => $plugin]; - static::scope($path, $params, $callback); - - return; - } - - if ($prefix) { - static::prefix($prefix, $callback); - - return; - } - - if ($plugin) { - static::plugin($plugin, $callback); - - return; - } - - static::scope('/', $callback); - - return; - } - } - - /** - * Parses given URL string. Returns 'routing' parameters for that URL. + * Get the routing parameters for the request if possible. * - * @param string $url URL to be parsed. - * @param string $method The HTTP method being used. + * @param \Cake\Http\ServerRequest $request The request to parse request data from. * @return array Parsed elements from URL. * @throws \Cake\Routing\Exception\MissingRouteException When a route cannot be handled - * @deprecated 3.4.0 Use Router::parseRequest() instead. */ - public static function parse($url, $method = '') + public static function parseRequest(ServerRequest $request): array { - if (!static::$initialized) { - static::_loadRoutes(); - } - if (strpos($url, '/') !== 0) { - $url = '/' . $url; - } - - return static::$_collection->parse($url, $method); - } - - /** - * Get the routing parameters for the request is possible. - * - * @param \Psr\Http\Message\ServerRequestInterface $request The request to parse request data from. - * @return array Parsed elements from URL. - * @throws \Cake\Routing\Exception\MissingRouteException When a route cannot be handled - */ - public static function parseRequest(ServerRequestInterface $request) - { - if (!static::$initialized) { - static::_loadRoutes(); - } - return static::$_collection->parseRequest($request); } /** - * Takes parameter and path information back from the Dispatcher, sets these - * parameters as the current request parameters that are merged with URL arrays - * created later in the request. - * - * Nested requests will create a stack of requests. You can remove requests using - * Router::popRequest(). This is done automatically when using Object::requestAction(). - * - * Will accept either a Cake\Http\ServerRequest object or an array of arrays. Support for - * accepting arrays may be removed in the future. + * Set current request instance. * - * @param \Cake\Http\ServerRequest|array $request Parameters and path information or a Cake\Http\ServerRequest object. + * @param \Cake\Http\ServerRequest $request request object. * @return void */ - public static function setRequestInfo($request) - { - if ($request instanceof ServerRequest) { - static::pushRequest($request); - } else { - $requestData = $request; - $requestData += [[], []]; - $requestData[0] += [ - 'controller' => false, - 'action' => false, - 'plugin' => null - ]; - $request = new ServerRequest(); - $request->addParams($requestData[0])->addPaths($requestData[1]); - static::pushRequest($request); - } - } - - /** - * Push a request onto the request stack. Pushing a request - * sets the request context used when generating URLs. - * - * @param \Cake\Http\ServerRequest $request Request instance. - * @return void - */ - public static function pushRequest(ServerRequest $request) - { - static::$_requests[] = $request; - static::setRequestContext($request); - } - - /** - * Store the request context for a given request. - * - * @param \Psr\Http\Message\ServerRequestInterface $request The request instance. - * @return void - * @throws InvalidArgumentException When parameter is an incorrect type. - */ - public static function setRequestContext(ServerRequestInterface $request) + public static function setRequest(ServerRequest $request): void { + static::$_request = $request; $uri = $request->getUri(); - static::$_requestContext = [ - '_base' => $request->getAttribute('base'), - '_port' => $uri->getPort(), - '_scheme' => $uri->getScheme(), - '_host' => $uri->getHost(), - ]; - } - /** - * Pops a request off of the request stack. Used when doing requestAction - * - * @return \Cake\Http\ServerRequest The request removed from the stack. - * @see \Cake\Routing\Router::pushRequest() - * @see \Cake\Routing\RequestActionTrait::requestAction() - */ - public static function popRequest() - { - $removed = array_pop(static::$_requests); - $last = end(static::$_requests); - if ($last) { - static::setRequestContext($last); - reset(static::$_requests); - } - - return $removed; + static::$_requestContext['_base'] = $request->getAttribute('base', ''); + static::$_requestContext['params'] = $request->getAttribute('params', []); + static::$_requestContext['_scheme'] ??= $uri->getScheme(); + static::$_requestContext['_host'] ??= $uri->getHost(); + static::$_requestContext['_port'] ??= $uri->getPort(); } /** - * Get the current request object, or the first one. + * Get the current request object. * - * @param bool $current True to get the current request, or false to get the first one. * @return \Cake\Http\ServerRequest|null */ - public static function getRequest($current = false) + public static function getRequest(): ?ServerRequest { - if ($current) { - $request = end(static::$_requests); - - return $request ?: null; - } - - return isset(static::$_requests[0]) ? static::$_requests[0] : null; + return static::$_request; } /** @@ -475,20 +231,44 @@ public static function getRequest($current = false) * * @return void */ - public static function reload() + public static function reload(): void { - if (empty(static::$_initialState)) { + if (static::$_initialState === []) { static::$_collection = new RouteCollection(); - static::$_initialState = get_class_vars(get_called_class()); + static::$_initialState = get_class_vars(static::class); return; } foreach (static::$_initialState as $key => $val) { - if ($key !== '_initialState') { + if ($key !== '_initialState' && $key !== '_collection') { static::${$key} = $val; } } static::$_collection = new RouteCollection(); + static::$_routePaths = []; + } + + /** + * Reset routes and related state. + * + * Similar to reload() except that this doesn't reset all global state, + * as that leads to incorrect behavior in some plugin test case scenarios. + * + * This method will reset: + * + * - routes + * - URL Filters + * - the initialized property + * + * Extensions and default route classes will not be modified + * + * @internal + * @return void + */ + public static function resetRoutes(): void + { + static::$_collection = new RouteCollection(); + static::$_urlFilters = []; } /** @@ -517,10 +297,10 @@ public static function reload() * }); * ``` * - * @param callable $function The function to add + * @param \Closure $function The function to add * @return void */ - public static function addUrlFilter(callable $function) + public static function addUrlFilter(Closure $function): void { static::$_urlFilters[] = $function; } @@ -533,11 +313,22 @@ public static function addUrlFilter(callable $function) * @see \Cake\Routing\Router::url() * @see \Cake\Routing\Router::addUrlFilter() */ - protected static function _applyUrlFilters($url) + protected static function _applyUrlFilters(array $url): array { - $request = static::getRequest(true); + $request = static::getRequest(); foreach (static::$_urlFilters as $filter) { - $url = $filter($url, $request); + try { + $url = $filter($url, $request); + } catch (Throwable $e) { + $ref = new ReflectionFunction($filter); + $message = sprintf( + 'URL filter defined in %s on line %s could not be applied. The filter failed with: %s', + $ref->getFileName(), + $ref->getStartLine(), + $e->getMessage(), + ); + throw new CakeException($message, (int)$e->getCode(), $e); + } } return $url; @@ -551,8 +342,8 @@ protected static function _applyUrlFilters($url) * ### Usage * * - `Router::url('/posts/edit/1');` Returns the string with the base dir prepended. - * This usage does not use reverser routing. - * - `Router::url(['controller' => 'posts', 'action' => 'edit']);` Returns a URL + * This usage does not use reverse routing. + * - `Router::url(['controller' => 'Posts', 'action' => 'edit']);` Returns a URL * generated through reverse routing. * - `Router::url(['_name' => 'custom-name', ...]);` Returns a URL generated * through reverse routing. This form allows you to leverage named routes. @@ -560,31 +351,40 @@ protected static function _applyUrlFilters($url) * There are a few 'special' parameters that can change the final URL string that is generated * * - `_base` - Set to false to remove the base path from the generated URL. If your application - * is not in the root directory, this can be used to generate URLs that are 'cake relative'. - * cake relative URLs are required when using requestAction. + * is not in the root directory, this can be used to generate URLs that are "cake relative". * - `_scheme` - Set to create links on different schemes like `webcal` or `ftp`. Defaults * to the current scheme. * - `_host` - Set the host to use for the link. Defaults to the current host. * - `_port` - Set the port if you need to create links on non-standard ports. * - `_full` - If true output of `Router::fullBaseUrl()` will be prepended to generated URLs. - * - `#` - Allows you to set URL hash fragments. - * - `_ssl` - Set to true to convert the generated URL to https, or false to force http. + * - `_https` - Set to true to convert the generated URL to https, or false to force http. * - `_name` - Name of route. If you have setup named routes you can use this key * to specify it. + * - `#` - Allows you to set URL hash fragments. * - * @param string|array|null $url An array specifying any of the following: + * @param \Psr\Http\Message\UriInterface|array|string|null $url An array specifying any of the following: * 'controller', 'action', 'plugin' additionally, you can provide routed - * elements or query string parameters. If string it can be name any valid url - * string. + * elements or query string parameters. If string it can be any valid url + * string or it can be an UriInterface instance. * @param bool $full If true, the full base URL will be prepended to the result. * Default is false. * @return string Full translated URL with base path. - * @throws \Cake\Core\Exception\Exception When the route name is not found + * @throws \Cake\Core\Exception\CakeException When the route name is not found */ - public static function url($url = null, $full = false) + public static function url(UriInterface|array|string|null $url = null, bool $full = false): string { - if (!static::$initialized) { - static::_loadRoutes(); + $context = static::$_requestContext; + // For CLI request context would be empty + $context['_base'] ??= Configure::read('App.base', ''); + + if (!$url) { + $here = static::getRequest()?->getRequestTarget() ?? '/'; + $output = $context['_base'] . $here; + if ($full) { + return static::fullBaseUrl() . $output; + } + + return $output; } $params = [ @@ -593,49 +393,39 @@ public static function url($url = null, $full = false) 'action' => 'index', '_ext' => null, ]; - $here = $base = $output = $frag = null; + if (!empty($context['params'])) { + $params = $context['params']; + } - // In 4.x this should be replaced with state injected via setRequestContext - $request = static::getRequest(true); - if ($request) { - $params = $request->params; - $here = $request->here; - $base = $request->getAttribute('base'); - } else { - $base = Configure::read('App.base'); - if (isset(static::$_requestContext['_base'])) { - $base = static::$_requestContext['_base']; + $frag = ''; + + if (is_array($url)) { + if (isset($url['_path'])) { + $url = self::unwrapShortString($url); } - } - if (empty($url)) { - $output = isset($here) ? $here : $base . '/'; - if ($full) { - $output = static::fullBaseUrl() . $output; + if (isset($url['_https'])) { + $url['_scheme'] = $url['_https'] === true ? 'https' : 'http'; } - return $output; - } - if (is_array($url)) { if (isset($url['_full']) && $url['_full'] === true) { $full = true; - unset($url['_full']); } if (isset($url['#'])) { $frag = '#' . $url['#']; - unset($url['#']); - } - if (isset($url['_ssl'])) { - $url['_scheme'] = ($url['_ssl'] === true) ? 'https' : 'http'; - unset($url['_ssl']); } + unset($url['_https'], $url['_full'], $url['#']); $url = static::_applyUrlFilters($url); if (!isset($url['_name'])) { // Copy the current action if the controller is the current one. - if (empty($url['action']) && - (empty($url['controller']) || $params['controller'] === $url['controller']) + if ( + empty($url['action']) && + ( + empty($url['controller']) || + $params['controller'] === $url['controller'] + ) ) { $url['action'] = $params['action']; } @@ -649,28 +439,37 @@ public static function url($url = null, $full = false) 'plugin' => $params['plugin'], 'controller' => $params['controller'], 'action' => 'index', - '_ext' => null + '_ext' => null, ]; } - $output = static::$_collection->match($url, static::$_requestContext + ['params' => $params]); + // If a full URL is requested with a scheme the host should default + // to App.fullBaseUrl to avoid corrupt URLs + if ($full && isset($url['_scheme']) && !isset($url['_host'])) { + $url['_host'] = $context['_host']; + } + $context['params'] = $params; + + $output = static::$_collection->match($url, $context); } else { - $plainString = ( - strpos($url, 'javascript:') === 0 || - strpos($url, 'mailto:') === 0 || - strpos($url, 'tel:') === 0 || - strpos($url, 'sms:') === 0 || - strpos($url, '#') === 0 || - strpos($url, '?') === 0 || - strpos($url, '//') === 0 || - strpos($url, '://') !== false - ); - - if ($plainString) { + $url = (string)$url; + + if ( + str_starts_with($url, 'javascript:') || + str_starts_with($url, 'mailto:') || + str_starts_with($url, 'tel:') || + str_starts_with($url, 'sms:') || + str_starts_with($url, '#') || + str_starts_with($url, '?') || + str_starts_with($url, '//') || + str_contains($url, '://') + ) { return $url; } - $output = $base . $url; + + $output = $context['_base'] . $url; } + $protocol = preg_match('#^[a-z][a-z0-9+\-.]*\://#i', $output); if ($protocol === 0) { $output = str_replace('//', '/', '/' . $output); @@ -682,9 +481,57 @@ public static function url($url = null, $full = false) return $output . $frag; } + /** + * Generate URL for route path. + * + * Route path examples: + * - Bookmarks::view + * - Admin/Bookmarks::view + * - Cms.Articles::edit + * - Vendor/Cms.Management/Admin/Articles::view + * + * @param string $path Route path specifying controller and action, optionally with plugin and prefix. + * @param array $params An array specifying any additional parameters. + * Can be also any special parameters supported by `Router::url()`. + * @param bool $full If true, the full base URL will be prepended to the result. + * Default is false. + * @return string Full translated URL with base path. + */ + public static function pathUrl(string $path, array $params = [], bool $full = false): string + { + return static::url(['_path' => $path] + $params, $full); + } + + /** + * Finds URL for specified action. + * + * Returns a bool if the url exists + * + * ### Usage + * + * @see Router::url() + * @param array|string|null $url An array specifying any of the following: + * 'controller', 'action', 'plugin' additionally, you can provide routed + * elements or query string parameters. If string it can be any valid url + * string. + * @param bool $full If true, the full base URL will be prepended to the result. + * Default is false. + * @return bool + */ + public static function routeExists(array|string|null $url = null, bool $full = false): bool + { + try { + static::url($url, $full); + + return true; + } catch (MissingRouteException) { + return false; + } + } + /** * Sets the full base URL that will be used as a prefix for generating - * fully qualified URLs for this application. If not parameters are passed, + * fully qualified URLs for this application. If no parameters are passed, * the currently configured value is returned. * * ### Note: @@ -697,16 +544,44 @@ public static function url($url = null, $full = false) * For example: `http://example.com` * @return string */ - public static function fullBaseUrl($base = null) + public static function fullBaseUrl(?string $base = null): string { + if ($base === null && static::$_fullBaseUrl !== null) { + return static::$_fullBaseUrl; + } + if ($base !== null) { static::$_fullBaseUrl = $base; Configure::write('App.fullBaseUrl', $base); - } - if (empty(static::$_fullBaseUrl)) { - static::$_fullBaseUrl = Configure::read('App.fullBaseUrl'); + } else { + $base = (string)Configure::read('App.fullBaseUrl'); + + // If App.fullBaseUrl is empty but context is set from request through setRequest() + if (!$base && !empty(static::$_requestContext['_host'])) { + $base = sprintf( + '%s://%s', + static::$_requestContext['_scheme'], + static::$_requestContext['_host'], + ); + if (!empty(static::$_requestContext['_port'])) { + $base .= ':' . static::$_requestContext['_port']; + } + + Configure::write('App.fullBaseUrl', $base); + + return static::$_fullBaseUrl = $base; + } + + static::$_fullBaseUrl = $base; } + $parts = parse_url(static::$_fullBaseUrl); + static::$_requestContext = [ + '_scheme' => $parts['scheme'] ?? null, + '_host' => $parts['host'] ?? null, + '_port' => $parts['port'] ?? null, + ] + static::$_requestContext; + return static::$_fullBaseUrl; } @@ -714,66 +589,66 @@ public static function fullBaseUrl($base = null) * Reverses a parsed parameter array into an array. * * Works similarly to Router::url(), but since parsed URL's contain additional - * 'pass' as well as 'url.url' keys. Those keys need to be specially + * keys like 'pass', '_matchedRoute' etc. those keys need to be specially * handled in order to reverse a params array into a string URL. * - * This will strip out 'autoRender', 'bare', 'requested', and 'return' param names as those - * are used for CakePHP internals and should not normally be part of an output URL. - * * @param \Cake\Http\ServerRequest|array $params The params array or - * Cake\Http\ServerRequest object that needs to be reversed. + * {@link \Cake\Http\ServerRequest} object that needs to be reversed. * @return array The URL array ready to be used for redirect or HTML link. */ - public static function reverseToArray($params) + public static function reverseToArray(ServerRequest|array $params): array { - $url = []; + $route = null; if ($params instanceof ServerRequest) { - $url = $params->query; - $params = $params->params; - } elseif (isset($params['url'])) { - $url = $params['url']; + $route = $params->getAttribute('route'); + assert($route === null || $route instanceof Route); + + $queryString = $params->getQueryParams(); + $params = $params->getAttribute('params'); + assert(is_array($params)); + $params['?'] = $queryString; } - $pass = isset($params['pass']) ? $params['pass'] : []; + $pass = $params['pass'] ?? []; + $template = $params['_matchedRoute'] ?? null; unset( $params['pass'], - $params['paging'], - $params['models'], - $params['url'], - $url['url'], - $params['autoRender'], - $params['bare'], - $params['requested'], - $params['return'], - $params['_Token'], $params['_matchedRoute'], - $params['_name'] + $params['_name'], ); - $params = array_merge($params, $pass); - if (!empty($url)) { - $params['?'] = $url; + if (!$route && $template) { + // Locate the route that was used to match this route + // so we can access the pass parameter configuration. + foreach (static::getRouteCollection()->routes() as $maybe) { + if ($maybe->template === $template) { + $route = $maybe; + break; + } + } + } + if ($route) { + // If we found a route, slice off the number of passed args. + $routePass = $route->options['pass'] ?? []; + $pass = array_slice($pass, count($routePass)); } - return $params; + return array_merge($params, $pass); } /** * Reverses a parsed parameter array into a string. * * Works similarly to Router::url(), but since parsed URL's contain additional - * 'pass' as well as 'url.url' keys. Those keys need to be specially + * keys like 'pass', '_matchedRoute' etc. those keys need to be specially * handled in order to reverse a params array into a string URL. * - * This will strip out 'autoRender', 'bare', 'requested', and 'return' param names as those - * are used for CakePHP internals and should not normally be part of an output URL. - * * @param \Cake\Http\ServerRequest|array $params The params array or - * Cake\Network\Request object that needs to be reversed. + * {@link \Cake\Http\ServerRequest} object that needs to be reversed. * @param bool $full Set to true to include the full URL including the * protocol when reversing the URL. * @return string The string that is the reversed result of the array */ - public static function reverse($params, $full = false) + public static function reverse(ServerRequest|array $params, bool $full = false): string { $params = static::reverseToArray($params); @@ -789,7 +664,7 @@ public static function reverse($params, $full = false) * @param array|string $url URL to normalize Either an array or a string URL. * @return string Normalized URL */ - public static function normalize($url = '/') + public static function normalize(array|string $url = '/'): string { if (is_array($url)) { $url = static::url($url); @@ -799,17 +674,20 @@ public static function normalize($url = '/') } $request = static::getRequest(); - if (!empty($request->base) && stristr($url, $request->base)) { - $url = preg_replace('/^' . preg_quote($request->base, '/') . '/', '', $url, 1); + if ($request) { + $base = $request->getAttribute('base', ''); + if ($base !== '' && stristr($url, $base)) { + $url = (string)preg_replace('/^' . preg_quote($base, '/') . '/', '', $url, 1); + } } $url = '/' . $url; - while (strpos($url, '//') !== false) { + while (str_contains($url, '//')) { $url = str_replace('//', '/', $url); } $url = preg_replace('/(?:(\/$))/', '', $url); - if (empty($url)) { + if (!$url) { return '/'; } @@ -822,30 +700,27 @@ public static function normalize($url = '/') * Instructs the router to parse out file extensions * from the URL. For example, http://example.com/posts.rss would yield a file * extension of "rss". The file extension itself is made available in the - * controller as `$this->request->getParam('_ext')`, and is used by the RequestHandler - * component to automatically switch to alternate layouts and templates, and - * load helpers corresponding to the given content, i.e. RssHelper. Switching + * controller as `$this->request->getParam('_ext')`, and is used by content + * type negotiation to automatically switch to alternate layouts and templates, and + * load helpers corresponding to the given content. Switching * layouts and helpers requires that the chosen extension has a defined mime type * in `Cake\Http\Response`. * * A string or an array of valid extensions can be passed to this method. * If called without any parameters it will return current list of set extensions. * - * @param array|string|null $extensions List of extensions to be added. + * @param array|string|null $extensions List of extensions to be added. * @param bool $merge Whether to merge with or override existing extensions. * Defaults to `true`. - * @return array Array of extensions Router is configured to parse. + * @return array Array of extensions Router is configured to parse. */ - public static function extensions($extensions = null, $merge = true) + public static function extensions(array|string|null $extensions = null, bool $merge = true): array { $collection = static::$_collection; if ($extensions === null) { - if (!static::$initialized) { - static::_loadRoutes(); - } - return array_unique(array_merge(static::$_defaultExtensions, $collection->getExtensions())); } + $extensions = (array)$extensions; if ($merge) { $extensions = array_unique(array_merge(static::$_defaultExtensions, $extensions)); @@ -854,70 +729,14 @@ public static function extensions($extensions = null, $merge = true) return static::$_defaultExtensions = $extensions; } - /** - * Provides legacy support for named parameters on incoming URLs. - * - * Checks the passed parameters for elements containing `$options['separator']` - * Those parameters are split and parsed as if they were old style named parameters. - * - * The parsed parameters will be moved from params['pass'] to params['named']. - * - * ### Options - * - * - `separator` The string to use as a separator. Defaults to `:`. - * - * @param \Cake\Http\ServerRequest $request The request object to modify. - * @param array $options The array of options. - * @return \Cake\Http\ServerRequest The modified request - * @deprecated 3.3.0 Named parameter backwards compatibility will be removed in 4.0. - */ - public static function parseNamedParams(ServerRequest $request, array $options = []) - { - $options += ['separator' => ':']; - if (empty($request->params['pass'])) { - $request->params['named'] = []; - - return $request; - } - $named = []; - foreach ($request->getParam('pass') as $key => $value) { - if (strpos($value, $options['separator']) === false) { - continue; - } - unset($request->params['pass'][$key]); - list($key, $value) = explode($options['separator'], $value, 2); - - if (preg_match_all('/\[([A-Za-z0-9_-]+)?\]/', $key, $matches, PREG_SET_ORDER)) { - $matches = array_reverse($matches); - $parts = explode('[', $key); - $key = array_shift($parts); - $arr = $value; - foreach ($matches as $match) { - if (empty($match[1])) { - $arr = [$arr]; - } else { - $arr = [ - $match[1] => $arr - ]; - } - } - $value = $arr; - } - $named = array_merge_recursive($named, [$key => $value]); - } - $request->params['named'] = $named; - - return $request; - } - /** * Create a RouteBuilder for the provided path. * * @param string $path The path to set the builder to. - * @param array $options The options for the builder + * @param array $options The options for the builder * @return \Cake\Routing\RouteBuilder */ - public static function createRouteBuilder($path, array $options = []) + public static function createRouteBuilder(string $path, array $options = []): RouteBuilder { $defaults = [ 'routeClass' => static::defaultRouteClass(), @@ -932,167 +751,132 @@ public static function createRouteBuilder($path, array $options = []) } /** - * Create a routing scope. - * - * Routing scopes allow you to keep your routes DRY and avoid repeating - * common path prefixes, and or parameter sets. - * - * Scoped collections will be indexed by path for faster route parsing. If you - * re-open or re-use a scope the connected routes will be merged with the - * existing ones. - * - * ### Options - * - * The `$params` array allows you to define options for the routing scope. - * The options listed below *are not* available to be used as routing defaults - * - * - `routeClass` The route class to use in this scope. Defaults to - * `Router::defaultRouteClass()` - * - `extensions` The extensions to enable in this scope. Defaults to the globally - * enabled extensions set with `Router::extensions()` - * - * ### Example - * - * ``` - * Router::scope('/blog', ['plugin' => 'Blog'], function ($routes) { - * $routes->connect('/', ['controller' => 'Articles']); - * }); - * ``` - * - * The above would result in a `/blog/` route being created, with both the - * plugin & controller default parameters set. - * - * You can use `Router::plugin()` and `Router::prefix()` as shortcuts to creating - * specific kinds of scopes. + * Get the route scopes and their connected routes. * - * @param string $path The path prefix for the scope. This path will be prepended - * to all routes connected in the scoped collection. - * @param array|callable $params An array of routing defaults to add to each connected route. - * If you have no parameters, this argument can be a callable. - * @param callable|null $callback The callback to invoke with the scoped collection. - * @throws \InvalidArgumentException When an invalid callable is provided. - * @return void + * @return array<\Cake\Routing\Route\Route> */ - public static function scope($path, $params = [], $callback = null) + public static function routes(): array { - $options = []; - if (is_array($params)) { - $options = $params; - unset($params['routeClass'], $params['extensions']); - } - $builder = static::createRouteBuilder('/', $options); - $builder->scope($path, $params, $callback); + return static::$_collection->routes(); } /** - * Create prefixed routes. - * - * This method creates a scoped route collection that includes - * relevant prefix information. - * - * The path parameter is used to generate the routing parameter name. - * For example a path of `admin` would result in `'prefix' => 'admin'` being - * applied to all connected routes. - * - * The prefix name will be inflected to the underscore version to create - * the routing path. If you want a custom path name, use the `path` option. - * - * You can re-open a prefix as many times as necessary, as well as nest prefixes. - * Nested prefixes will result in prefix values like `admin/api` which translates - * to the `Controller\Admin\Api\` namespace. + * Get the RouteCollection inside the Router * - * @param string $name The prefix name to use. - * @param array|callable $params An array of routing defaults to add to each connected route. - * If you have no parameters, this argument can be a callable. - * @param callable|null $callback The callback to invoke that builds the prefixed routes. - * @return void + * @return \Cake\Routing\RouteCollection */ - public static function prefix($name, $params = [], $callback = null) + public static function getRouteCollection(): RouteCollection { - if ($callback === null) { - $callback = $params; - $params = []; - } - $name = Inflector::underscore($name); - - if (empty($params['path'])) { - $path = '/' . $name; - } else { - $path = $params['path']; - unset($params['path']); - } - - $params = array_merge($params, ['prefix' => $name]); - static::scope($path, $params, $callback); + return static::$_collection; } /** - * Add plugin routes. - * - * This method creates a scoped route collection that includes - * relevant plugin information. + * Set the RouteCollection inside the Router * - * The plugin name will be inflected to the underscore version to create - * the routing path. If you want a custom path name, use the `path` option. - * - * Routes connected in the scoped collection will have the correct path segment - * prepended, and have a matching plugin routing key set. - * - * @param string $name The plugin name to build routes for - * @param array|callable $options Either the options to use, or a callback - * @param callable|null $callback The callback to invoke that builds the plugin routes. - * Only required when $options is defined + * @param \Cake\Routing\RouteCollection $routeCollection route collection * @return void */ - public static function plugin($name, $options = [], $callback = null) + public static function setRouteCollection(RouteCollection $routeCollection): void { - if ($callback === null) { - $callback = $options; - $options = []; - } - $params = ['plugin' => $name]; - if (empty($options['path'])) { - $options['path'] = '/' . Inflector::underscore($name); - } - if (isset($options['_namePrefix'])) { - $params['_namePrefix'] = $options['_namePrefix']; - } - static::scope($options['path'], $params, $callback); + static::$_collection = $routeCollection; } /** - * Get the route scopes and their connected routes. + * Inject route defaults from `_path` key * - * @return \Cake\Routing\Route\Route[] + * @param array $url Route array with `_path` key + * @return array */ - public static function routes() + protected static function unwrapShortString(array $url): array { - if (!static::$initialized) { - static::_loadRoutes(); + foreach (['plugin', 'prefix', 'controller', 'action'] as $key) { + if (array_key_exists($key, $url)) { + throw new InvalidArgumentException( + "`{$key}` cannot be used when defining route targets with a string route path.", + ); + } } + $url += static::parseRoutePath($url['_path']); + $url += [ + 'plugin' => false, + 'prefix' => false, + ]; + unset($url['_path']); - return static::$_collection->routes(); + return $url; } /** - * Get the RouteCollection inside the Router + * Parse a string route path * - * @return \Cake\Routing\RouteCollection - */ - public static function getRouteCollection() - { - return static::$_collection; - } - - /** - * Loads route configuration + * String examples: + * - Bookmarks::view + * - Admin/Bookmarks::view + * - Cms.Articles::edit + * - Vendor/Cms.Management/Admin/Articles::view * - * @deprecated 3.5.0 Routes will be loaded via the Application::routes() hook in 4.0.0 - * @return void + * @param string $url Route path in [Plugin.][Prefix/]Controller::action format + * @return array */ - protected static function _loadRoutes() + public static function parseRoutePath(string $url): array { - static::$initialized = true; - include CONFIG . 'routes.php'; + if (isset(static::$_routePaths[$url])) { + return static::$_routePaths[$url]; + } + + $regex = '#^ + (?:(?[a-z0-9]+(?:/[a-z0-9]+)*)\.)? + (?:(?[a-z0-9]+(?:/[a-z0-9]+)*)/)? + (?[a-z0-9]+) + :: + (?[a-z0-9_]+) + (?(?:/(?:[a-z][a-z0-9-_]*=)? + (?:([a-z0-9-_=]+)|(["\'][^\'"]+[\'"])) + )+/?)? + $#ix'; + + if (!preg_match($regex, $url, $matches)) { + throw new InvalidArgumentException(sprintf('Could not parse a string route path `%s`.', $url)); + } + + $defaults = [ + 'controller' => $matches['controller'], + 'action' => $matches['action'], + ]; + if ($matches['plugin'] !== '') { + $defaults['plugin'] = $matches['plugin']; + } + if ($matches['prefix'] !== '') { + $defaults['prefix'] = $matches['prefix']; + } + + if (isset($matches['params']) && $matches['params'] !== '') { + $paramsArray = explode('/', trim($matches['params'], '/')); + foreach ($paramsArray as $param) { + if (str_contains($param, '=')) { + if (!preg_match('/(?.+?)=(?.*)/', $param, $paramMatches)) { + throw new InvalidArgumentException( + "Could not parse a key=value from `{$param}` in route path `{$url}`.", + ); + } + $paramKey = $paramMatches['key']; + if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $paramKey)) { + throw new InvalidArgumentException( + "Param key `{$paramKey}` is not valid in route path `{$url}`.", + ); + } + $defaults[$paramKey] = trim($paramMatches['value'], '\'"'); + } else { + $defaults[] = $param; + } + } + } + // Only cache 200 routes per request. Beyond that we could + // be soaking up too much memory. + if (count(static::$_routePaths) < 200) { + static::$_routePaths[$url] = $defaults; + } + + return $defaults; } } diff --git a/src/Routing/RoutingApplicationInterface.php b/src/Routing/RoutingApplicationInterface.php new file mode 100644 index 00000000000..d2b98cc9f67 --- /dev/null +++ b/src/Routing/RoutingApplicationInterface.php @@ -0,0 +1,33 @@ + false, + 'prefix' => false, + ]; + + return $url + $params; +} + +/** + * Convenience wrapper for Router::url(). + * + * @param \Psr\Http\Message\UriInterface|array|string|null $url An array specifying any of the following: + * 'controller', 'action', 'plugin' additionally, you can provide routed + * elements or query string parameters. If string it can be any valid url + * string or it can be an UriInterface instance. + * @param bool $full If true, the full base URL will be prepended to the result. + * Default is false. + * @return string Full translated URL with base path. + * @throws \Cake\Core\Exception\CakeException When the route name is not found + * @see \Cake\Routing\Router::url() + * @since 4.5.0 + */ +function url(UriInterface|array|string|null $url = null, bool $full = false): string +{ + return Router::url($url, $full); +} + +/** + * urldecode all of the segments in a path, but skip over %2f + * because applications can use %2f in a segment. + * + * @internal + */ +function urldecodeSegments(string $url): string +{ + $parts = explode('/', $url); + $parts = array_map( + fn(string $part) => str_replace('/', '%2f', urldecode($part)), + $parts, + ); + + return implode('/', $parts); +} diff --git a/src/Routing/functions_global.php b/src/Routing/functions_global.php new file mode 100644 index 00000000000..ad009f95a5a --- /dev/null +++ b/src/Routing/functions_global.php @@ -0,0 +1,58 @@ +addSubcommand('list_prefixes', [ - 'help' => 'Show a list of all defined cache prefixes.', - ]); - $parser->addSubcommand('clear_all', [ - 'help' => 'Clear all caches.', - ]); - $parser->addSubcommand('clear', [ - 'help' => 'Clear the cache for a specified prefix.', - 'parser' => [ - 'description' => [ - 'Clear the cache for a particular prefix.', - 'For example, `cake cache clear _cake_model_` will clear the model cache', - 'Use `cake cache list_prefixes` to list available prefixes' - ], - 'arguments' => [ - 'prefix' => [ - 'help' => 'The cache prefix to be cleared.', - 'required' => true - ] - ] - ] - ]); - - return $parser; - } - - /** - * Clear metadata. - * - * @param string|null $prefix The cache prefix to be cleared. - * @throws \Cake\Console\Exception\StopException - * @return void - */ - public function clear($prefix = null) - { - try { - $engine = Cache::engine($prefix); - Cache::clear(false, $prefix); - if ($engine instanceof ApcEngine) { - $this->warn("ApcEngine detected: Cleared $prefix CLI cache successfully " . - "but $prefix web cache must be cleared separately."); - } elseif ($engine instanceof WincacheEngine) { - $this->warn("WincacheEngine detected: Cleared $prefix CLI cache successfully " . - "but $prefix web cache must be cleared separately."); - } else { - $this->out("Cleared $prefix cache"); - } - } catch (InvalidArgumentException $e) { - $this->abort($e->getMessage()); - } - } - - /** - * Clear metadata. - * - * @return void - */ - public function clearAll() - { - $prefixes = Cache::configured(); - foreach ($prefixes as $prefix) { - $this->clear($prefix); - } - } - - /** - * Show a list of all defined cache prefixes. - * - * @return void - */ - public function listPrefixes() - { - $prefixes = Cache::configured(); - foreach ($prefixes as $prefix) { - $this->out($prefix); - } - } -} diff --git a/src/Shell/CommandListShell.php b/src/Shell/CommandListShell.php deleted file mode 100644 index b08684f0c84..00000000000 --- a/src/Shell/CommandListShell.php +++ /dev/null @@ -1,171 +0,0 @@ -out(); - $this->out(sprintf('Welcome to CakePHP %s Console', 'v' . Configure::version())); - $this->hr(); - $this->out(sprintf('App : %s', APP_DIR)); - $this->out(sprintf('Path: %s', APP)); - $this->out(sprintf('PHP : %s', phpversion())); - $this->hr(); - } - - /** - * startup - * - * @return void - */ - public function startup() - { - if (!$this->param('xml') && !$this->param('version')) { - parent::startup(); - } - } - - /** - * Main function Prints out the list of shells. - * - * @return void - */ - public function main() - { - if (!$this->param('xml') && !$this->param('version')) { - $this->out('Current Paths:', 2); - $this->out('* app: ' . APP_DIR); - $this->out('* root: ' . rtrim(ROOT, DIRECTORY_SEPARATOR)); - $this->out('* core: ' . rtrim(CORE_PATH, DIRECTORY_SEPARATOR)); - $this->out(''); - - $this->out('Available Shells:', 2); - } - - if ($this->param('version')) { - $this->out(Configure::version()); - - return; - } - - $shellList = $this->Command->getShellList(); - if (!$shellList) { - return; - } - - if (!$this->param('xml')) { - $this->_asText($shellList); - } else { - $this->_asXml($shellList); - } - } - - /** - * Output text. - * - * @param array $shellList The shell list. - * @return void - */ - protected function _asText($shellList) - { - foreach ($shellList as $plugin => $commands) { - sort($commands); - $this->out(sprintf('[%s] %s', $plugin, implode(', ', $commands))); - $this->out(); - } - - $this->out('To run an app or core command, type `cake shell_name [args]`'); - $this->out('To run a plugin command, type `cake Plugin.shell_name [args]`'); - $this->out('To get help on a specific command, type `cake shell_name --help`', 2); - } - - /** - * Output as XML - * - * @param array $shellList The shell list. - * @return void - */ - protected function _asXml($shellList) - { - $plugins = Plugin::loaded(); - $shells = new SimpleXMLElement(''); - foreach ($shellList as $plugin => $commands) { - foreach ($commands as $command) { - $callable = $command; - if (in_array($plugin, $plugins)) { - $callable = Inflector::camelize($plugin) . '.' . $command; - } - - $shell = $shells->addChild('shell'); - $shell->addAttribute('name', $command); - $shell->addAttribute('call_as', $callable); - $shell->addAttribute('provider', $plugin); - $shell->addAttribute('help', $callable . ' -h'); - } - } - $this->_io->setOutputAs(ConsoleOutput::RAW); - $this->out($shells->saveXML()); - } - - /** - * Gets the option parser instance and configures it. - * - * @return \Cake\Console\ConsoleOptionParser - */ - public function getOptionParser() - { - $parser = parent::getOptionParser(); - - $parser->setDescription( - 'Get the list of available shells for this CakePHP application.' - )->addOption('xml', [ - 'help' => 'Get the listing as XML.', - 'boolean' => true - ])->addOption('version', [ - 'help' => 'Prints the currently installed version of CakePHP. (deprecated - use `cake --version` instead)', - 'boolean' => true - ]); - - return $parser; - } -} diff --git a/src/Shell/CompletionShell.php b/src/Shell/CompletionShell.php deleted file mode 100644 index 6cf4faa335f..00000000000 --- a/src/Shell/CompletionShell.php +++ /dev/null @@ -1,173 +0,0 @@ -out($this->getOptionParser()->help()); - } - - /** - * list commands - * - * @return int|bool|null Returns the number of bytes returned from writing to stdout. - */ - public function commands() - { - $options = $this->Command->commands(); - - return $this->_output($options); - } - - /** - * list options for the named command - * - * @return int|bool|null Returns the number of bytes returned from writing to stdout. - */ - public function options() - { - $commandName = $subCommandName = ''; - if (!empty($this->args[0])) { - $commandName = $this->args[0]; - } - if (!empty($this->args[1])) { - $subCommandName = $this->args[1]; - } - $options = $this->Command->options($commandName, $subCommandName); - - return $this->_output($options); - } - - /** - * list subcommands for the named command - * - * @return int|bool|null Returns the number of bytes returned from writing to stdout. - */ - public function subcommands() - { - if (!$this->args) { - return $this->_output(); - } - - $options = $this->Command->subCommands($this->args[0]); - - return $this->_output($options); - } - - /** - * Guess autocomplete from the whole argument string - * - * @return int|bool|null Returns the number of bytes returned from writing to stdout. - */ - public function fuzzy() - { - return $this->_output(); - } - - /** - * Gets the option parser instance and configures it. - * - * @return \Cake\Console\ConsoleOptionParser - */ - public function getOptionParser() - { - $parser = parent::getOptionParser(); - - $parser->setDescription( - 'Used by shells like bash to autocomplete command name, options and arguments' - )->addSubcommand('commands', [ - 'help' => 'Output a list of available commands', - 'parser' => [ - 'description' => 'List all available', - ] - ])->addSubcommand('subcommands', [ - 'help' => 'Output a list of available subcommands', - 'parser' => [ - 'description' => 'List subcommands for a command', - 'arguments' => [ - 'command' => [ - 'help' => 'The command name', - 'required' => false, - ] - ] - ] - ])->addSubcommand('options', [ - 'help' => 'Output a list of available options', - 'parser' => [ - 'description' => 'List options', - 'arguments' => [ - 'command' => [ - 'help' => 'The command name', - 'required' => false, - ], - 'subcommand' => [ - 'help' => 'The subcommand name', - 'required' => false, - ] - ] - ] - ])->addSubcommand('fuzzy', [ - 'help' => 'Guess autocomplete' - ])->setEpilog([ - 'This command is not intended to be called manually', - ]); - - return $parser; - } - - /** - * Emit results as a string, space delimited - * - * @param array $options The options to output - * @return int|bool|null Returns the number of bytes returned from writing to stdout. - */ - protected function _output($options = []) - { - if ($options) { - return $this->out(implode($options, ' ')); - } - } -} diff --git a/src/Shell/HelpShell.php b/src/Shell/HelpShell.php deleted file mode 100644 index babc5cf0c8b..00000000000 --- a/src/Shell/HelpShell.php +++ /dev/null @@ -1,172 +0,0 @@ -param('xml')) { - parent::startup(); - } - } - - /** - * {@inheritDoc} - */ - public function setCommandCollection(CommandCollection $commands) - { - $this->commands = $commands; - } - - /** - * Main function Prints out the list of shells. - * - * @return void - */ - public function main() - { - if (!$this->param('xml')) { - $this->out('Current Paths:', 2); - $this->out('* app: ' . APP_DIR); - $this->out('* root: ' . rtrim(ROOT, DIRECTORY_SEPARATOR)); - $this->out('* core: ' . rtrim(CORE_PATH, DIRECTORY_SEPARATOR)); - $this->out(''); - - $this->out('Available Commands:', 2); - } - - if (!$this->commands) { - $this->commands = new CommandCollection($this->getCommands()); - } - - $commands = $this->commands->getIterator(); - $commands->ksort(); - $commands = new CommandCollection((array)$commands); - - if ($this->param('xml')) { - $this->asXml($commands); - - return; - } - $this->asText($commands); - } - - /** - * Get the list of commands using the CommandTask - * - * Provides backwards compatibility when an application doesn't use - * CommandRunner. - * - * @return array - */ - protected function getCommands() - { - $task = new CommandTask($this->getIo()); - $nested = $task->getShellList(); - $out = []; - foreach ($nested as $section => $commands) { - $prefix = ''; - if ($section !== 'CORE' && $section !== 'app') { - $prefix = Inflector::underscore($section) . '.'; - } - foreach ($commands as $command) { - $out[$prefix . $command] = $command; - } - } - - return $out; - } - - /** - * Output text. - * - * @param \Cake\Console\CommandCollection $commands The command collection to output. - * @return void - */ - protected function asText($commands) - { - foreach ($commands as $name => $class) { - $this->out('- ' . $name); - } - $this->out(''); - - $this->out('To run a command, type `cake shell_name [args|options]`'); - $this->out('To get help on a specific command, type `cake shell_name --help`', 2); - } - - /** - * Output as XML - * - * @param \Cake\Console\CommandCollection $commands The command collection to output - * @return void - */ - protected function asXml($commands) - { - $shells = new SimpleXMLElement(''); - foreach ($commands as $name => $class) { - $shell = $shells->addChild('shell'); - $shell->addAttribute('name', $name); - $shell->addAttribute('call_as', $name); - $shell->addAttribute('provider', $class); - $shell->addAttribute('help', $name . ' -h'); - } - $this->_io->setOutputAs(ConsoleOutput::RAW); - $this->out($shells->saveXML()); - } - - /** - * Gets the option parser instance and configures it. - * - * @return \Cake\Console\ConsoleOptionParser - */ - public function getOptionParser() - { - $parser = parent::getOptionParser(); - - $parser->setDescription( - 'Get the list of available shells for this application.' - )->addOption('xml', [ - 'help' => 'Get the listing as XML.', - 'boolean' => true - ]); - - return $parser; - } -} diff --git a/src/Shell/Helper/ProgressHelper.php b/src/Shell/Helper/ProgressHelper.php deleted file mode 100644 index 9d92e542451..00000000000 --- a/src/Shell/Helper/ProgressHelper.php +++ /dev/null @@ -1,151 +0,0 @@ -helper('Progress')->output(['callback' => function ($progress) { - * // Do work - * $progress->increment(); - * }); - * ``` - */ -class ProgressHelper extends Helper -{ - - /** - * The current progress. - * - * @var int - */ - protected $_progress = 0; - - /** - * The total number of 'items' to progress through. - * - * @var int - */ - protected $_total = 0; - - /** - * The width of the bar. - * - * @var int - */ - protected $_width = 0; - - /** - * Output a progress bar. - * - * Takes a number of options to customize the behavior: - * - * - `total` The total number of items in the progress bar. Defaults - * to 100. - * - `width` The width of the progress bar. Defaults to 80. - * - `callback` The callback that will be called in a loop to advance the progress bar. - * - * @param array $args The arguments/options to use when outputing the progress bar. - * @return void - */ - public function output($args) - { - $args += ['callback' => null]; - if (isset($args[0])) { - $args['callback'] = $args[0]; - } - if (!$args['callback'] || !is_callable($args['callback'])) { - throw new RuntimeException('Callback option must be a callable.'); - } - $this->init($args); - - $callback = $args['callback']; - - $this->_io->out('', 0); - while ($this->_progress < $this->_total) { - $callback($this); - $this->draw(); - } - $this->_io->out(''); - } - - /** - * Initialize the progress bar for use. - * - * - `total` The total number of items in the progress bar. Defaults - * to 100. - * - `width` The width of the progress bar. Defaults to 80. - * - * @param array $args The initialization data. - * @return $this - */ - public function init(array $args = []) - { - $args += ['total' => 100, 'width' => 80]; - $this->_progress = 0; - $this->_width = $args['width']; - $this->_total = $args['total']; - - return $this; - } - - /** - * Increment the progress bar. - * - * @param int $num The amount of progress to advance by. - * @return $this - */ - public function increment($num = 1) - { - $this->_progress = min(max(0, $this->_progress + $num), $this->_total); - - return $this; - } - - /** - * Render the progress bar based on the current state. - * - * @return $this - */ - public function draw() - { - $numberLen = strlen(' 100%'); - $complete = round($this->_progress / $this->_total, 2); - $barLen = ($this->_width - $numberLen) * ($this->_progress / $this->_total); - $bar = ''; - if ($barLen > 1) { - $bar = str_repeat('=', $barLen - 1) . '>'; - } - - $pad = ceil($this->_width - $numberLen - $barLen); - if ($pad > 0) { - $bar .= str_repeat(' ', $pad); - } - $percent = ($complete * 100) . '%'; - $bar .= str_pad($percent, $numberLen, ' ', STR_PAD_LEFT); - - $this->_io->overwrite($bar, 0); - - return $this; - } -} diff --git a/src/Shell/Helper/TableHelper.php b/src/Shell/Helper/TableHelper.php deleted file mode 100644 index 60132ae0854..00000000000 --- a/src/Shell/Helper/TableHelper.php +++ /dev/null @@ -1,149 +0,0 @@ - true, - 'rowSeparator' => false, - 'headerStyle' => 'info', - ]; - - /** - * Calculate the column widths - * - * @param array $rows The rows on which the columns width will be calculated on. - * @return array - */ - protected function _calculateWidths($rows) - { - $widths = []; - foreach ($rows as $line) { - foreach (array_values($line) as $k => $v) { - $columnLength = mb_strwidth($v); - if ($columnLength >= (isset($widths[$k]) ? $widths[$k] : 0)) { - $widths[$k] = $columnLength; - } - } - } - - return $widths; - } - - /** - * Output a row separator. - * - * @param array $widths The widths of each column to output. - * @return void - */ - protected function _rowSeparator($widths) - { - $out = ''; - foreach ($widths as $column) { - $out .= '+' . str_repeat('-', $column + 2); - } - $out .= '+'; - $this->_io->out($out); - } - - /** - * Output a row. - * - * @param array $row The row to output. - * @param array $widths The widths of each column to output. - * @param array $options Options to be passed. - * @return void - */ - protected function _render(array $row, $widths, $options = []) - { - if (count($row) === 0) { - return; - } - - $out = ''; - foreach (array_values($row) as $i => $column) { - $pad = $widths[$i] - mb_strwidth($column); - if (!empty($options['style'])) { - $column = $this->_addStyle($column, $options['style']); - } - $out .= '| ' . $column . str_repeat(' ', $pad) . ' '; - } - $out .= '|'; - $this->_io->out($out); - } - - /** - * Output a table. - * - * Data will be output based on the order of the values - * in the array. The keys will not be used to align data. - * - * @param array $rows The data to render out. - * @return void - */ - public function output($rows) - { - if (!is_array($rows) || count($rows) === 0) { - return; - } - - $config = $this->getConfig(); - $widths = $this->_calculateWidths($rows); - - $this->_rowSeparator($widths); - if ($config['headers'] === true) { - $this->_render(array_shift($rows), $widths, ['style' => $config['headerStyle']]); - $this->_rowSeparator($widths); - } - - if (!$rows) { - return; - } - - foreach ($rows as $line) { - $this->_render($line, $widths); - if ($config['rowSeparator'] === true) { - $this->_rowSeparator($widths); - } - } - if ($config['rowSeparator'] !== true) { - $this->_rowSeparator($widths); - } - } - - /** - * Add style tags - * - * @param string $text The text to be surrounded - * @param string $style The style to be applied - * @return string - */ - protected function _addStyle($text, $style) - { - return '<' . $style . '>' . $text . ''; - } -} diff --git a/src/Shell/I18nShell.php b/src/Shell/I18nShell.php deleted file mode 100644 index 40828f22791..00000000000 --- a/src/Shell/I18nShell.php +++ /dev/null @@ -1,168 +0,0 @@ -out('I18n Shell'); - $this->hr(); - $this->out('[E]xtract POT file from sources'); - $this->out('[I]nitialize a language from POT file'); - $this->out('[H]elp'); - $this->out('[Q]uit'); - - $choice = strtolower($this->in('What would you like to do?', ['E', 'I', 'H', 'Q'])); - switch ($choice) { - case 'e': - $this->Extract->main(); - break; - case 'i': - $this->init(); - break; - case 'h': - $this->out($this->OptionParser->help()); - break; - case 'q': - $this->_stop(); - - return; - default: - $this->out('You have made an invalid selection. Please choose a command to execute by entering E, I, H, or Q.'); - } - $this->hr(); - $this->main(); - } - - /** - * Inits PO file from POT file. - * - * @param string|null $language Language code to use. - * @return void - * @throws \Cake\Console\Exception\StopException - */ - public function init($language = null) - { - if (!$language) { - $language = $this->in('Please specify language code, e.g. `en`, `eng`, `en_US` etc.'); - } - if (strlen($language) < 2) { - $this->abort('Invalid language code. Valid is `en`, `eng`, `en_US` etc.'); - } - - $this->_paths = [APP]; - if ($this->param('plugin')) { - $plugin = Inflector::camelize($this->param('plugin')); - $this->_paths = [Plugin::classPath($plugin)]; - } - - $response = $this->in('What folder?', null, rtrim($this->_paths[0], DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'Locale'); - $sourceFolder = rtrim($response, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; - $targetFolder = $sourceFolder . $language . DIRECTORY_SEPARATOR; - if (!is_dir($targetFolder)) { - mkdir($targetFolder, 0775, true); - } - - $count = 0; - $iterator = new DirectoryIterator($sourceFolder); - foreach ($iterator as $fileinfo) { - if (!$fileinfo->isFile()) { - continue; - } - $filename = $fileinfo->getFilename(); - $newFilename = $fileinfo->getBasename('.pot'); - $newFilename .= '.po'; - - $this->createFile($targetFolder . $newFilename, file_get_contents($sourceFolder . $filename)); - $count++; - } - - $this->out('Generated ' . $count . ' PO files in ' . $targetFolder); - } - - /** - * Gets the option parser instance and configures it. - * - * @return \Cake\Console\ConsoleOptionParser - * @throws \Cake\Console\Exception\ConsoleException - */ - public function getOptionParser() - { - $parser = parent::getOptionParser(); - $initParser = [ - 'options' => [ - 'plugin' => [ - 'help' => 'Plugin name.', - 'short' => 'p' - ], - 'force' => [ - 'help' => 'Force overwriting.', - 'short' => 'f', - 'boolean' => true - ] - ], - 'arguments' => [ - 'language' => [ - 'help' => 'Two-letter language code.' - ] - ] - ]; - - $parser->setDescription( - 'I18n Shell generates .pot files(s) with translations.' - )->addSubcommand('extract', [ - 'help' => 'Extract the po translations from your application', - 'parser' => $this->Extract->getOptionParser() - ]) - ->addSubcommand('init', [ - 'help' => 'Init PO language file from POT file', - 'parser' => $initParser - ]); - - return $parser; - } -} diff --git a/src/Shell/OrmCacheShell.php b/src/Shell/OrmCacheShell.php deleted file mode 100644 index 0eaac515f49..00000000000 --- a/src/Shell/OrmCacheShell.php +++ /dev/null @@ -1,143 +0,0 @@ -_getSchema(); - if (!$schema) { - return false; - } - $tables = [$name]; - if (empty($name)) { - $tables = $schema->listTables(); - } - foreach ($tables as $table) { - $this->_io->verbose('Building metadata cache for ' . $table); - $schema->describe($table, ['forceRefresh' => true]); - } - $this->out('Cache build complete'); - - return true; - } - - /** - * Clear metadata. - * - * @param string|null $name The name of the table to clear cache data for. - * @return bool - */ - public function clear($name = null) - { - $schema = $this->_getSchema(); - if (!$schema) { - return false; - } - $tables = [$name]; - if (empty($name)) { - $tables = $schema->listTables(); - } - $configName = $schema->getCacheMetadata(); - - foreach ($tables as $table) { - $this->_io->verbose(sprintf( - 'Clearing metadata cache from "%s" for %s', - $configName, - $table - )); - $key = $schema->cacheKey($table); - Cache::delete($key, $configName); - } - $this->out('Cache clear complete'); - - return true; - } - - /** - * Helper method to get the schema collection. - * - * @return false|\Cake\Database\Schema\CachedCollection - */ - protected function _getSchema() - { - /** @var \Cake\Database\Connection $source */ - $source = ConnectionManager::get($this->params['connection']); - if (!method_exists($source, 'schemaCollection')) { - $msg = sprintf( - 'The "%s" connection is not compatible with orm caching, ' . - 'as it does not implement a "schemaCollection()" method.', - $this->params['connection'] - ); - $this->abort($msg); - - return false; - } - $config = $source->config(); - if (empty($config['cacheMetadata'])) { - $this->_io->verbose('Metadata cache was disabled in config. Enabling to clear cache.'); - $source->cacheMetadata(true); - } - - return $source->getSchemaCollection(); - } - - /** - * Get the option parser for this shell. - * - * @return \Cake\Console\ConsoleOptionParser - */ - public function getOptionParser() - { - $parser = parent::getOptionParser(); - $parser->addSubcommand('clear', [ - 'help' => 'Clear all metadata caches for the connection. If a ' . - 'table name is provided, only that table will be removed.', - ])->addSubcommand('build', [ - 'help' => 'Build all metadata caches for the connection. If a ' . - 'table name is provided, only that table will be cached.', - ])->addOption('connection', [ - 'help' => 'The connection to build/clear metadata cache data for.', - 'short' => 'c', - 'default' => 'default', - ])->addArgument('name', [ - 'help' => 'A specific table you want to clear/refresh cached data for.', - 'optional' => true, - ]); - - return $parser; - } -} diff --git a/src/Shell/PluginShell.php b/src/Shell/PluginShell.php deleted file mode 100644 index 3236a956c9f..00000000000 --- a/src/Shell/PluginShell.php +++ /dev/null @@ -1,81 +0,0 @@ -out($loaded); - } - - /** - * Gets the option parser instance and configures it. - * - * @return \Cake\Console\ConsoleOptionParser - */ - public function getOptionParser() - { - $parser = parent::getOptionParser(); - - $parser->setDescription('Plugin Shell perform various tasks related to plugin.') - ->addSubcommand('assets', [ - 'help' => 'Symlink / copy plugin assets to app\'s webroot', - 'parser' => $this->Assets->getOptionParser() - ]) - ->addSubcommand('loaded', [ - 'help' => 'Lists all loaded plugins', - 'parser' => $parser, - ]) - ->addSubcommand('load', [ - 'help' => 'Loads a plugin', - 'parser' => $this->Load->getOptionParser(), - ]) - ->addSubcommand('unload', [ - 'help' => 'Unloads a plugin', - 'parser' => $this->Unload->getOptionParser(), - ]); - - return $parser; - } -} diff --git a/src/Shell/RoutesShell.php b/src/Shell/RoutesShell.php deleted file mode 100644 index 14c2067e8d6..00000000000 --- a/src/Shell/RoutesShell.php +++ /dev/null @@ -1,152 +0,0 @@ -options['_name']) ? $route->options['_name'] : $route->getName(); - $output[] = [$name, $route->template, json_encode($route->defaults)]; - } - $this->helper('table')->output($output); - $this->out(); - } - - /** - * Checks a url for the route that will be applied. - * - * @param string $url The URL to parse - * @return bool Success - */ - public function check($url) - { - try { - $route = Router::parse($url); - $name = null; - foreach (Router::routes() as $r) { - if ($r->match($route)) { - $name = isset($r->options['_name']) ? $r->options['_name'] : $r->getName(); - break; - } - } - - unset($route['_matchedRoute']); - - $output = [ - ['Route name', 'URI template', 'Defaults'], - [$name, $url, json_encode($route)] - ]; - $this->helper('table')->output($output); - $this->out(); - } catch (MissingRouteException $e) { - $this->warn("'$url' did not match any routes."); - $this->out(); - - return false; - } - - return true; - } - - /** - * Generate a URL based on a set of parameters - * - * Takes variadic arguments of key/value pairs. - * @return bool Success - */ - public function generate() - { - try { - $args = $this->_splitArgs($this->args); - $url = Router::url($args); - $this->out("> $url"); - $this->out(); - } catch (MissingRouteException $e) { - $this->err('The provided parameters do not match any routes.'); - $this->out(); - - return false; - } - - return true; - } - - /** - * Get the option parser. - * - * @return \Cake\Console\ConsoleOptionParser - */ - public function getOptionParser() - { - $parser = parent::getOptionParser(); - $parser->setDescription( - 'Get the list of routes connected in this application. ' . - 'This tool also lets you test URL generation and URL parsing.' - )->addSubcommand('check', [ - 'help' => 'Check a URL string against the routes. ' . - 'Will output the routing parameters the route resolves to.' - ])->addSubcommand('generate', [ - 'help' => 'Check a routing array against the routes. ' . - "Will output the URL if there is a match.\n\n" . - 'Routing parameters should be supplied in a key:value format. ' . - 'For example `controller:Articles action:view 2`' - ]); - - return $parser; - } - - /** - * Split the CLI arguments into a hash. - * - * @param array $args The arguments to split. - * @return array - */ - protected function _splitArgs($args) - { - $out = []; - foreach ($args as $arg) { - if (strpos($arg, ':') !== false) { - list($key, $value) = explode(':', $arg); - if (in_array($value, ['true', 'false'])) { - $value = $value === 'true'; - } - $out[$key] = $value; - } else { - $out[] = $arg; - } - } - - return $out; - } -} diff --git a/src/Shell/ServerShell.php b/src/Shell/ServerShell.php deleted file mode 100644 index 788bc05e67f..00000000000 --- a/src/Shell/ServerShell.php +++ /dev/null @@ -1,169 +0,0 @@ -_host = self::DEFAULT_HOST; - $this->_port = self::DEFAULT_PORT; - $this->_documentRoot = WWW_ROOT; - } - - /** - * Starts up the Shell and displays the welcome message. - * Allows for checking and configuring prior to command or main execution - * - * Override this method if you want to remove the welcome information, - * or otherwise modify the pre-command flow. - * - * @return void - * @link https://book.cakephp.org/3.0/en/console-and-shells.html#hook-methods - */ - public function startup() - { - if (!empty($this->params['host'])) { - $this->_host = $this->params['host']; - } - if (!empty($this->params['port'])) { - $this->_port = $this->params['port']; - } - if (!empty($this->params['document_root'])) { - $this->_documentRoot = $this->params['document_root']; - } - - // For Windows - if (substr($this->_documentRoot, -1, 1) === DIRECTORY_SEPARATOR) { - $this->_documentRoot = substr($this->_documentRoot, 0, strlen($this->_documentRoot) - 1); - } - if (preg_match("/^([a-z]:)[\\\]+(.+)$/i", $this->_documentRoot, $m)) { - $this->_documentRoot = $m[1] . '\\' . $m[2]; - } - - parent::startup(); - } - - /** - * Displays a header for the shell - * - * @return void - */ - protected function _welcome() - { - $this->out(); - $this->out(sprintf('Welcome to CakePHP %s Console', 'v' . Configure::version())); - $this->hr(); - $this->out(sprintf('App : %s', APP_DIR)); - $this->out(sprintf('Path: %s', APP)); - $this->out(sprintf('DocumentRoot: %s', $this->_documentRoot)); - $this->hr(); - } - - /** - * Override main() to handle action - * - * @return void - */ - public function main() - { - $command = sprintf( - 'php -S %s:%d -t %s %s', - $this->_host, - $this->_port, - escapeshellarg($this->_documentRoot), - escapeshellarg($this->_documentRoot . '/index.php') - ); - - $port = ':' . $this->_port; - $this->out(sprintf('built-in server is running in http://%s%s/', $this->_host, $port)); - $this->out(sprintf('You can exit with `CTRL-C`')); - system($command); - } - - /** - * Gets the option parser instance and configures it. - * - * @return \Cake\Console\ConsoleOptionParser - */ - public function getOptionParser() - { - $parser = parent::getOptionParser(); - - $parser->setDescription([ - 'PHP Built-in Server for CakePHP', - '[WARN] Don\'t use this in a production environment', - ])->addOption('host', [ - 'short' => 'H', - 'help' => 'ServerHost' - ])->addOption('port', [ - 'short' => 'p', - 'help' => 'ListenPort' - ])->addOption('document_root', [ - 'short' => 'd', - 'help' => 'DocumentRoot' - ]); - - return $parser; - } -} diff --git a/src/Shell/Task/AssetsTask.php b/src/Shell/Task/AssetsTask.php deleted file mode 100644 index c0b86570ace..00000000000 --- a/src/Shell/Task/AssetsTask.php +++ /dev/null @@ -1,339 +0,0 @@ -_process($this->_list($name)); - } - - /** - * Copying plugin assets to app's webroot. For vendor namespaced plugin, - * parent folder for vendor name are created if required. - * - * @param string|null $name Name of plugin for which to symlink assets. - * If null all plugins will be processed. - * @return void - */ - public function copy($name = null) - { - $this->_process($this->_list($name), true); - } - - /** - * Remove plugin assets from app's webroot. - * - * @param string|null $name Name of plugin for which to remove assets. - * If null all plugins will be processed. - * @return void - * @since 3.5.12 - */ - public function remove($name = null) - { - $plugins = $this->_list($name); - - foreach ($plugins as $plugin => $config) { - $this->out(); - $this->out('For plugin: ' . $plugin); - $this->hr(); - - $this->_remove($config); - } - - $this->out(); - $this->out('Done'); - } - - /** - * Get list of plugins to process. Plugins without a webroot directory are skipped. - * - * @param string|null $name Name of plugin for which to symlink assets. - * If null all plugins will be processed. - * @return array List of plugins with meta data. - */ - protected function _list($name = null) - { - if ($name === null) { - $pluginsList = Plugin::loaded(); - } else { - if (!Plugin::loaded($name)) { - $this->err(sprintf('Plugin %s is not loaded.', $name)); - - return []; - } - $pluginsList = [$name]; - } - - $plugins = []; - - foreach ($pluginsList as $plugin) { - $path = Plugin::path($plugin) . 'webroot'; - if (!is_dir($path)) { - $this->verbose('', 1); - $this->verbose( - sprintf('Skipping plugin %s. It does not have webroot folder.', $plugin), - 2 - ); - continue; - } - - $link = Inflector::underscore($plugin); - $dir = WWW_ROOT; - $namespaced = false; - if (strpos($link, '/') !== false) { - $namespaced = true; - $parts = explode('/', $link); - $link = array_pop($parts); - $dir = WWW_ROOT . implode(DIRECTORY_SEPARATOR, $parts) . DIRECTORY_SEPARATOR; - } - - $plugins[$plugin] = [ - 'srcPath' => Plugin::path($plugin) . 'webroot', - 'destDir' => $dir, - 'link' => $link, - 'namespaced' => $namespaced - ]; - } - - return $plugins; - } - - /** - * Process plugins - * - * @param array $plugins List of plugins to process - * @param bool $copy Force copy mode. Default false. - * @return void - */ - protected function _process($plugins, $copy = false) - { - $overwrite = (bool)$this->param('overwrite'); - - foreach ($plugins as $plugin => $config) { - $this->out(); - $this->out('For plugin: ' . $plugin); - $this->hr(); - - if ($config['namespaced'] && - !is_dir($config['destDir']) && - !$this->_createDirectory($config['destDir']) - ) { - continue; - } - - $dest = $config['destDir'] . $config['link']; - - if (file_exists($dest)) { - if ($overwrite && !$this->_remove($config)) { - continue; - } elseif (!$overwrite) { - $this->verbose( - $dest . ' already exists', - 1 - ); - - continue; - } - } - - if (!$copy) { - $result = $this->_createSymlink( - $config['srcPath'], - $dest - ); - if ($result) { - continue; - } - } - - $this->_copyDirectory( - $config['srcPath'], - $dest - ); - } - - $this->out(); - $this->out('Done'); - } - - /** - * Remove folder/symlink. - * - * @param array $config Plugin config. - * @return bool - */ - protected function _remove($config) - { - if ($config['namespaced'] && !is_dir($config['destDir'])) { - $this->verbose( - $config['destDir'] . $config['link'] . ' does not exist', - 1 - ); - - return false; - } - - $dest = $config['destDir'] . $config['link']; - - if (!file_exists($dest)) { - $this->verbose( - $dest . ' does not exist', - 1 - ); - - return false; - } - - if (is_link($dest)) { - // @codingStandardsIgnoreLine - if (@unlink($dest)) { - $this->out('Unlinked ' . $dest); - - return true; - } else { - $this->err('Failed to unlink ' . $dest); - - return false; - } - } - - $folder = new Folder($dest); - if ($folder->delete()) { - $this->out('Deleted ' . $dest); - - return true; - } else { - $this->err('Failed to delete ' . $dest); - - return false; - } - } - - /** - * Create directory - * - * @param string $dir Directory name - * @return bool - */ - protected function _createDirectory($dir) - { - $old = umask(0); - // @codingStandardsIgnoreStart - $result = @mkdir($dir, 0755, true); - // @codingStandardsIgnoreEnd - umask($old); - - if ($result) { - $this->out('Created directory ' . $dir); - - return true; - } - - $this->err('Failed creating directory ' . $dir); - - return false; - } - - /** - * Create symlink - * - * @param string $target Target directory - * @param string $link Link name - * @return bool - */ - protected function _createSymlink($target, $link) - { - // @codingStandardsIgnoreStart - $result = @symlink($target, $link); - // @codingStandardsIgnoreEnd - - if ($result) { - $this->out('Created symlink ' . $link); - - return true; - } - - return false; - } - - /** - * Copy directory - * - * @param string $source Source directory - * @param string $destination Destination directory - * @return bool - */ - protected function _copyDirectory($source, $destination) - { - $folder = new Folder($source); - if ($folder->copy(['to' => $destination])) { - $this->out('Copied assets to directory ' . $destination); - - return true; - } - - $this->err('Error copying assets to directory ' . $destination); - - return false; - } - - /** - * Gets the option parser instance and configures it. - * - * @return \Cake\Console\ConsoleOptionParser - */ - public function getOptionParser() - { - $parser = parent::getOptionParser(); - - $parser->addSubcommand('symlink', [ - 'help' => 'Symlink (copy as fallback) plugin assets to app\'s webroot.' - ])->addSubcommand('copy', [ - 'help' => 'Copy plugin assets to app\'s webroot.' - ])->addSubcommand('remove', [ - 'help' => 'Remove plugin assets from app\'s webroot.' - ])->addArgument('name', [ - 'help' => 'A specific plugin you want to symlink assets for.', - 'optional' => true, - ]) - ->addOption('overwrite', [ - 'boolean' => true, - 'default' => false, - 'help' => 'Overwrite existing symlink / folder.' - ]); - - return $parser; - } -} diff --git a/src/Shell/Task/CommandTask.php b/src/Shell/Task/CommandTask.php deleted file mode 100644 index bff4bb354af..00000000000 --- a/src/Shell/Task/CommandTask.php +++ /dev/null @@ -1,261 +0,0 @@ - null, 'app' => null]; - - $appPath = App::path('Shell'); - $appShells = $this->_scanDir($appPath[0]); - $appShells = array_diff($appShells, $skipFiles); - $shellList = $this->_appendShells('app', $appShells, $shellList); - - $shells = $this->_scanDir(dirname(__DIR__)); - $shells = array_diff($shells, $appShells, $skipFiles, $hiddenCommands); - $shellList = $this->_appendShells('CORE', $shells, $shellList); - - foreach ($plugins as $plugin) { - $pluginPath = Plugin::classPath($plugin) . 'Shell'; - $pluginShells = $this->_scanDir($pluginPath); - $shellList = $this->_appendShells($plugin, $pluginShells, $shellList); - } - - return array_filter($shellList); - } - - /** - * Scan the provided paths for shells, and append them into $shellList - * - * @param string $type The type of object. - * @param array $shells The shell name. - * @param array $shellList List of shells. - * @return array The updated $shellList - */ - protected function _appendShells($type, $shells, $shellList) - { - foreach ($shells as $shell) { - $shellList[$type][] = Inflector::underscore(str_replace('Shell', '', $shell)); - } - - return $shellList; - } - - /** - * Scan a directory for .php files and return the class names that - * should be within them. - * - * @param string $dir The directory to read. - * @return array The list of shell classnames based on conventions. - */ - protected function _scanDir($dir) - { - $dir = new Folder($dir); - $contents = $dir->read(true, true); - if (empty($contents[1])) { - return []; - } - $shells = []; - foreach ($contents[1] as $file) { - if (substr($file, -4) !== '.php') { - continue; - } - $shells[] = substr($file, 0, -4); - } - - return $shells; - } - - /** - * Return a list of all commands - * - * @return array - */ - public function commands() - { - $shellList = $this->getShellList(); - $flatten = Hash::flatten($shellList); - $duplicates = array_intersect($flatten, array_unique(array_diff_key($flatten, array_unique($flatten)))); - $duplicates = Hash::expand($duplicates); - - $options = []; - foreach ($shellList as $type => $commands) { - foreach ($commands as $shell) { - $prefix = ''; - if (!in_array(strtolower($type), ['app', 'core']) && - isset($duplicates[$type]) && - in_array($shell, $duplicates[$type]) - ) { - $prefix = $type . '.'; - } - - $options[] = $prefix . $shell; - } - } - - return $options; - } - - /** - * Return a list of subcommands for a given command - * - * @param string $commandName The command you want subcommands from. - * @return array - */ - public function subCommands($commandName) - { - $Shell = $this->getShell($commandName); - - if (!$Shell) { - return []; - } - - $taskMap = $this->Tasks->normalizeArray((array)$Shell->tasks); - $return = array_keys($taskMap); - $return = array_map('Cake\Utility\Inflector::underscore', $return); - - $shellMethodNames = ['main', 'help', 'getOptionParser', 'initialize', 'runCommand']; - - $baseClasses = ['Object', 'Shell', 'AppShell']; - - $Reflection = new ReflectionClass($Shell); - $methods = $Reflection->getMethods(ReflectionMethod::IS_PUBLIC); - $methodNames = []; - foreach ($methods as $method) { - $declaringClass = $method->getDeclaringClass()->getShortName(); - if (!in_array($declaringClass, $baseClasses)) { - $methodNames[] = $method->getName(); - } - } - - $return = array_merge($return, array_diff($methodNames, $shellMethodNames)); - sort($return); - - return $return; - } - - /** - * Get Shell instance for the given command - * - * @param string $commandName The command you want. - * @return \Cake\Console\Shell|bool Shell instance if the command can be found, false otherwise. - */ - public function getShell($commandName) - { - list($pluginDot, $name) = pluginSplit($commandName, true); - - if (in_array(strtolower($pluginDot), ['app.', 'core.'])) { - $commandName = $name; - $pluginDot = ''; - } - - if (!in_array($commandName, $this->commands()) && (empty($pluginDot) && !in_array($name, $this->commands()))) { - return false; - } - - if (empty($pluginDot)) { - $shellList = $this->getShellList(); - - if (!in_array($commandName, $shellList['app']) && !in_array($commandName, $shellList['CORE'])) { - unset($shellList['CORE'], $shellList['app']); - foreach ($shellList as $plugin => $commands) { - if (in_array($commandName, $commands)) { - $pluginDot = $plugin . '.'; - break; - } - } - } - } - - $name = Inflector::camelize($name); - $pluginDot = Inflector::camelize($pluginDot); - $class = App::className($pluginDot . $name, 'Shell', 'Shell'); - if (!$class) { - return false; - } - - /* @var \Cake\Console\Shell $Shell */ - $Shell = new $class(); - $Shell->plugin = trim($pluginDot, '.'); - $Shell->initialize(); - - return $Shell; - } - - /** - * Get options list for the given command or subcommand - * - * @param string $commandName The command to get options for. - * @param string $subCommandName The subcommand to get options for. Can be empty to get options for the command. - * If this parameter is used, the subcommand must be a valid subcommand of the command passed - * @return array Options list for the given command or subcommand - */ - public function options($commandName, $subCommandName = '') - { - $Shell = $this->getShell($commandName); - - if (!$Shell) { - return []; - } - - $parser = $Shell->getOptionParser(); - - if (!empty($subCommandName)) { - $subCommandName = Inflector::camelize($subCommandName); - if ($Shell->hasTask($subCommandName)) { - $parser = $Shell->{$subCommandName}->getOptionParser(); - } else { - return []; - } - } - - $options = []; - $array = $parser->options(); - /* @var \Cake\Console\ConsoleInputOption $obj */ - foreach ($array as $name => $obj) { - $options[] = "--$name"; - $short = $obj->short(); - if ($short) { - $options[] = "-$short"; - } - } - - return $options; - } -} diff --git a/src/Shell/Task/ExtractTask.php b/src/Shell/Task/ExtractTask.php deleted file mode 100644 index 19127158c5e..00000000000 --- a/src/Shell/Task/ExtractTask.php +++ /dev/null @@ -1,756 +0,0 @@ -_paths) > 0 ? $this->_paths : ['None']; - $message = sprintf( - "Current paths: %s\nWhat is the path you would like to extract?\n[Q]uit [D]one", - implode(', ', $currentPaths) - ); - $response = $this->in($message, null, $defaultPath); - if (strtoupper($response) === 'Q') { - $this->err('Extract Aborted'); - $this->_stop(); - - return; - } - if (strtoupper($response) === 'D' && count($this->_paths)) { - $this->out(); - - return; - } - if (strtoupper($response) === 'D') { - $this->warn('No directories selected. Please choose a directory.'); - } elseif (is_dir($response)) { - $this->_paths[] = $response; - $defaultPath = 'D'; - } else { - $this->err('The directory path you supplied was not found. Please try again.'); - } - $this->out(); - } - } - - /** - * Execution method always used for tasks - * - * @return void - */ - public function main() - { - if (!empty($this->params['exclude'])) { - $this->_exclude = explode(',', $this->params['exclude']); - } - if (isset($this->params['files']) && !is_array($this->params['files'])) { - $this->_files = explode(',', $this->params['files']); - } - if (isset($this->params['paths'])) { - $this->_paths = explode(',', $this->params['paths']); - } elseif (isset($this->params['plugin'])) { - $plugin = Inflector::camelize($this->params['plugin']); - if (!Plugin::loaded($plugin)) { - Plugin::load($plugin); - } - $this->_paths = [Plugin::classPath($plugin)]; - $this->params['plugin'] = $plugin; - } else { - $this->_getPaths(); - } - - if (isset($this->params['extract-core'])) { - $this->_extractCore = !(strtolower($this->params['extract-core']) === 'no'); - } else { - $response = $this->in('Would you like to extract the messages from the CakePHP core?', ['y', 'n'], 'n'); - $this->_extractCore = strtolower($response) === 'y'; - } - - if (!empty($this->params['exclude-plugins']) && $this->_isExtractingApp()) { - $this->_exclude = array_merge($this->_exclude, App::path('Plugin')); - } - - if (!empty($this->params['validation-domain'])) { - $this->_validationDomain = $this->params['validation-domain']; - } - - if ($this->_extractCore) { - $this->_paths[] = CAKE; - } - - if (isset($this->params['output'])) { - $this->_output = $this->params['output']; - } elseif (isset($this->params['plugin'])) { - $this->_output = $this->_paths[0] . 'Locale'; - } else { - $message = "What is the path you would like to output?\n[Q]uit"; - while (true) { - $response = $this->in($message, null, rtrim($this->_paths[0], DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'Locale'); - if (strtoupper($response) === 'Q') { - $this->err('Extract Aborted'); - $this->_stop(); - - return; - } - if ($this->_isPathUsable($response)) { - $this->_output = $response . DIRECTORY_SEPARATOR; - break; - } - - $this->err(''); - $this->err( - 'The directory path you supplied was ' . - 'not found. Please try again.' - ); - $this->out(); - } - } - - if (isset($this->params['merge'])) { - $this->_merge = !(strtolower($this->params['merge']) === 'no'); - } else { - $this->out(); - $response = $this->in('Would you like to merge all domain strings into the default.pot file?', ['y', 'n'], 'n'); - $this->_merge = strtolower($response) === 'y'; - } - - if (empty($this->_files)) { - $this->_searchFiles(); - } - - $this->_output = rtrim($this->_output, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; - if (!$this->_isPathUsable($this->_output)) { - $this->err(sprintf('The output directory %s was not found or writable.', $this->_output)); - $this->_stop(); - - return; - } - - $this->_extract(); - } - - /** - * Add a translation to the internal translations property - * - * Takes care of duplicate translations - * - * @param string $domain The domain - * @param string $msgid The message string - * @param array $details Context and plural form if any, file and line references - * @return void - */ - protected function _addTranslation($domain, $msgid, $details = []) - { - $context = isset($details['msgctxt']) ? $details['msgctxt'] : ''; - - if (empty($this->_translations[$domain][$msgid][$context])) { - $this->_translations[$domain][$msgid][$context] = [ - 'msgid_plural' => false - ]; - } - - if (isset($details['msgid_plural'])) { - $this->_translations[$domain][$msgid][$context]['msgid_plural'] = $details['msgid_plural']; - } - - if (isset($details['file'])) { - $line = isset($details['line']) ? $details['line'] : 0; - $this->_translations[$domain][$msgid][$context]['references'][$details['file']][] = $line; - } - } - - /** - * Extract text - * - * @return void - */ - protected function _extract() - { - $this->out(); - $this->out(); - $this->out('Extracting...'); - $this->hr(); - $this->out('Paths:'); - foreach ($this->_paths as $path) { - $this->out(' ' . $path); - } - $this->out('Output Directory: ' . $this->_output); - $this->hr(); - $this->_extractTokens(); - $this->_buildFiles(); - $this->_writeFiles(); - $this->_paths = $this->_files = $this->_storage = []; - $this->_translations = $this->_tokens = []; - $this->out(); - $this->out('Done.'); - } - - /** - * Gets the option parser instance and configures it. - * - * @return \Cake\Console\ConsoleOptionParser - */ - public function getOptionParser() - { - $parser = parent::getOptionParser(); - $parser->setDescription( - 'CakePHP Language String Extraction:' - )->addOption('app', [ - 'help' => 'Directory where your application is located.' - ])->addOption('paths', [ - 'help' => 'Comma separated list of paths.' - ])->addOption('merge', [ - 'help' => 'Merge all domain strings into the default.po file.', - 'choices' => ['yes', 'no'] - ])->addOption('output', [ - 'help' => 'Full path to output directory.' - ])->addOption('files', [ - 'help' => 'Comma separated list of files.' - ])->addOption('exclude-plugins', [ - 'boolean' => true, - 'default' => true, - 'help' => 'Ignores all files in plugins if this command is run inside from the same app directory.' - ])->addOption('plugin', [ - 'help' => 'Extracts tokens only from the plugin specified and puts the result in the plugin\'s Locale directory.' - ])->addOption('ignore-model-validation', [ - 'boolean' => true, - 'default' => false, - 'help' => 'Ignores validation messages in the $validate property.' . - ' If this flag is not set and the command is run from the same app directory,' . - ' all messages in model validation rules will be extracted as tokens.' - ])->addOption('validation-domain', [ - 'help' => 'If set to a value, the localization domain to be used for model validation messages.' - ])->addOption('exclude', [ - 'help' => 'Comma separated list of directories to exclude.' . - ' Any path containing a path segment with the provided values will be skipped. E.g. test,vendors' - ])->addOption('overwrite', [ - 'boolean' => true, - 'default' => false, - 'help' => 'Always overwrite existing .pot files.' - ])->addOption('extract-core', [ - 'help' => 'Extract messages from the CakePHP core libs.', - 'choices' => ['yes', 'no'] - ])->addOption('no-location', [ - 'boolean' => true, - 'default' => false, - 'help' => 'Do not write file locations for each extracted message.', - ]); - - return $parser; - } - - /** - * Extract tokens out of all files to be processed - * - * @return void - */ - protected function _extractTokens() - { - /** @var \Cake\Shell\Helper\ProgressHelper $progress */ - $progress = $this->helper('progress'); - $progress->init(['total' => count($this->_files)]); - $isVerbose = $this->param('verbose'); - - foreach ($this->_files as $file) { - $this->_file = $file; - if ($isVerbose) { - $this->out(sprintf('Processing %s...', $file), 1, Shell::VERBOSE); - } - - $code = file_get_contents($file); - $allTokens = token_get_all($code); - - $this->_tokens = []; - foreach ($allTokens as $token) { - if (!is_array($token) || ($token[0] !== T_WHITESPACE && $token[0] !== T_INLINE_HTML)) { - $this->_tokens[] = $token; - } - } - unset($allTokens); - $this->_parse('__', ['singular']); - $this->_parse('__n', ['singular', 'plural']); - $this->_parse('__d', ['domain', 'singular']); - $this->_parse('__dn', ['domain', 'singular', 'plural']); - $this->_parse('__x', ['context', 'singular']); - $this->_parse('__xn', ['context', 'singular', 'plural']); - $this->_parse('__dx', ['domain', 'context', 'singular']); - $this->_parse('__dxn', ['domain', 'context', 'singular', 'plural']); - - if (!$isVerbose) { - $progress->increment(1); - $progress->draw(); - } - } - } - - /** - * Parse tokens - * - * @param string $functionName Function name that indicates translatable string (e.g: '__') - * @param array $map Array containing what variables it will find (e.g: domain, singular, plural) - * @return void - */ - protected function _parse($functionName, $map) - { - $count = 0; - $tokenCount = count($this->_tokens); - - while (($tokenCount - $count) > 1) { - $countToken = $this->_tokens[$count]; - $firstParenthesis = $this->_tokens[$count + 1]; - if (!is_array($countToken)) { - $count++; - continue; - } - - list($type, $string, $line) = $countToken; - if (($type == T_STRING) && ($string === $functionName) && ($firstParenthesis === '(')) { - $position = $count; - $depth = 0; - - while (!$depth) { - if ($this->_tokens[$position] === '(') { - $depth++; - } elseif ($this->_tokens[$position] === ')') { - $depth--; - } - $position++; - } - - $mapCount = count($map); - $strings = $this->_getStrings($position, $mapCount); - - if ($mapCount === count($strings)) { - $singular = null; - extract(array_combine($map, $strings)); - $domain = isset($domain) ? $domain : 'default'; - $details = [ - 'file' => $this->_file, - 'line' => $line, - ]; - if (isset($plural)) { - $details['msgid_plural'] = $plural; - } - if (isset($context)) { - $details['msgctxt'] = $context; - } - $this->_addTranslation($domain, $singular, $details); - } elseif (strpos($this->_file, CAKE_CORE_INCLUDE_PATH) === false) { - $this->_markerError($this->_file, $line, $functionName, $count); - } - } - $count++; - } - } - - /** - * Build the translate template file contents out of obtained strings - * - * @return void - */ - protected function _buildFiles() - { - $paths = $this->_paths; - $paths[] = realpath(APP) . DIRECTORY_SEPARATOR; - - usort($paths, function ($a, $b) { - return strlen($a) - strlen($b); - }); - - foreach ($this->_translations as $domain => $translations) { - foreach ($translations as $msgid => $contexts) { - foreach ($contexts as $context => $details) { - $plural = $details['msgid_plural']; - $files = $details['references']; - $occurrences = []; - foreach ($files as $file => $lines) { - $lines = array_unique($lines); - $occurrences[] = $file . ':' . implode(';', $lines); - } - $occurrences = implode("\n#: ", $occurrences); - $header = ''; - if (!$this->param('no-location')) { - $header = '#: ' . str_replace(DIRECTORY_SEPARATOR, '/', str_replace($paths, '', $occurrences)) . "\n"; - } - - $sentence = ''; - if ($context !== '') { - $sentence .= "msgctxt \"{$context}\"\n"; - } - if ($plural === false) { - $sentence .= "msgid \"{$msgid}\"\n"; - $sentence .= "msgstr \"\"\n\n"; - } else { - $sentence .= "msgid \"{$msgid}\"\n"; - $sentence .= "msgid_plural \"{$plural}\"\n"; - $sentence .= "msgstr[0] \"\"\n"; - $sentence .= "msgstr[1] \"\"\n\n"; - } - - if ($domain !== 'default' && $this->_merge) { - $this->_store('default', $header, $sentence); - } else { - $this->_store($domain, $header, $sentence); - } - } - } - } - } - - /** - * Prepare a file to be stored - * - * @param string $domain The domain - * @param string $header The header content. - * @param string $sentence The sentence to store. - * @return void - */ - protected function _store($domain, $header, $sentence) - { - if (!isset($this->_storage[$domain])) { - $this->_storage[$domain] = []; - } - if (!isset($this->_storage[$domain][$sentence])) { - $this->_storage[$domain][$sentence] = $header; - } else { - $this->_storage[$domain][$sentence] .= $header; - } - } - - /** - * Write the files that need to be stored - * - * @return void - */ - protected function _writeFiles() - { - $overwriteAll = false; - if (!empty($this->params['overwrite'])) { - $overwriteAll = true; - } - foreach ($this->_storage as $domain => $sentences) { - $output = $this->_writeHeader(); - foreach ($sentences as $sentence => $header) { - $output .= $header . $sentence; - } - - // Remove vendor prefix if present. - $slashPosition = strpos($domain, '/'); - if ($slashPosition !== false) { - $domain = substr($domain, $slashPosition + 1); - } - - $filename = str_replace('/', '_', $domain) . '.pot'; - $File = new File($this->_output . $filename); - $response = ''; - while ($overwriteAll === false && $File->exists() && strtoupper($response) !== 'Y') { - $this->out(); - $response = $this->in( - sprintf('Error: %s already exists in this location. Overwrite? [Y]es, [N]o, [A]ll', $filename), - ['y', 'n', 'a'], - 'y' - ); - if (strtoupper($response) === 'N') { - $response = ''; - while (!$response) { - $response = $this->in('What would you like to name this file?', null, 'new_' . $filename); - $File = new File($this->_output . $response); - $filename = $response; - } - } elseif (strtoupper($response) === 'A') { - $overwriteAll = true; - } - } - $File->write($output); - $File->close(); - } - } - - /** - * Build the translation template header - * - * @return string Translation template header - */ - protected function _writeHeader() - { - $output = "# LANGUAGE translation of CakePHP Application\n"; - $output .= "# Copyright YEAR NAME \n"; - $output .= "#\n"; - $output .= "#, fuzzy\n"; - $output .= "msgid \"\"\n"; - $output .= "msgstr \"\"\n"; - $output .= "\"Project-Id-Version: PROJECT VERSION\\n\"\n"; - $output .= '"POT-Creation-Date: ' . date('Y-m-d H:iO') . "\\n\"\n"; - $output .= "\"PO-Revision-Date: YYYY-mm-DD HH:MM+ZZZZ\\n\"\n"; - $output .= "\"Last-Translator: NAME \\n\"\n"; - $output .= "\"Language-Team: LANGUAGE \\n\"\n"; - $output .= "\"MIME-Version: 1.0\\n\"\n"; - $output .= "\"Content-Type: text/plain; charset=utf-8\\n\"\n"; - $output .= "\"Content-Transfer-Encoding: 8bit\\n\"\n"; - $output .= "\"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\\n\"\n\n"; - - return $output; - } - - /** - * Get the strings from the position forward - * - * @param int $position Actual position on tokens array - * @param int $target Number of strings to extract - * @return array Strings extracted - */ - protected function _getStrings(&$position, $target) - { - $strings = []; - $count = count($strings); - while ($count < $target && ($this->_tokens[$position] === ',' || $this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING || $this->_tokens[$position][0] == T_LNUMBER)) { - $count = count($strings); - if ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING && $this->_tokens[$position + 1] === '.') { - $string = ''; - while ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING || $this->_tokens[$position] === '.') { - if ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING) { - $string .= $this->_formatString($this->_tokens[$position][1]); - } - $position++; - } - $strings[] = $string; - } elseif ($this->_tokens[$position][0] == T_CONSTANT_ENCAPSED_STRING) { - $strings[] = $this->_formatString($this->_tokens[$position][1]); - } elseif ($this->_tokens[$position][0] == T_LNUMBER) { - $strings[] = $this->_tokens[$position][1]; - } - $position++; - } - - return $strings; - } - - /** - * Format a string to be added as a translatable string - * - * @param string $string String to format - * @return string Formatted string - */ - protected function _formatString($string) - { - $quote = substr($string, 0, 1); - $string = substr($string, 1, -1); - if ($quote === '"') { - $string = stripcslashes($string); - } else { - $string = strtr($string, ["\\'" => "'", '\\\\' => '\\']); - } - $string = str_replace("\r\n", "\n", $string); - - return addcslashes($string, "\0..\37\\\""); - } - - /** - * Indicate an invalid marker on a processed file - * - * @param string $file File where invalid marker resides - * @param int $line Line number - * @param string $marker Marker found - * @param int $count Count - * @return void - */ - protected function _markerError($file, $line, $marker, $count) - { - $this->err(sprintf("Invalid marker content in %s:%s\n* %s(", $file, $line, $marker)); - $count += 2; - $tokenCount = count($this->_tokens); - $parenthesis = 1; - - while ((($tokenCount - $count) > 0) && $parenthesis) { - if (is_array($this->_tokens[$count])) { - $this->err($this->_tokens[$count][1], false); - } else { - $this->err($this->_tokens[$count], false); - if ($this->_tokens[$count] === '(') { - $parenthesis++; - } - - if ($this->_tokens[$count] === ')') { - $parenthesis--; - } - } - $count++; - } - $this->err("\n", true); - } - - /** - * Search files that may contain translatable strings - * - * @return void - */ - protected function _searchFiles() - { - $pattern = false; - if (!empty($this->_exclude)) { - $exclude = []; - foreach ($this->_exclude as $e) { - if (DIRECTORY_SEPARATOR !== '\\' && $e[0] !== DIRECTORY_SEPARATOR) { - $e = DIRECTORY_SEPARATOR . $e; - } - $exclude[] = preg_quote($e, '/'); - } - $pattern = '/' . implode('|', $exclude) . '/'; - } - foreach ($this->_paths as $path) { - $path = realpath($path) . DIRECTORY_SEPARATOR; - $Folder = new Folder($path); - $files = $Folder->findRecursive('.*\.(php|ctp|thtml|inc|tpl)', true); - if (!empty($pattern)) { - $files = preg_grep($pattern, $files, PREG_GREP_INVERT); - $files = array_values($files); - } - $this->_files = array_merge($this->_files, $files); - } - $this->_files = array_unique($this->_files); - } - - /** - * Returns whether this execution is meant to extract string only from directories in folder represented by the - * APP constant, i.e. this task is extracting strings from same application. - * - * @return bool - */ - protected function _isExtractingApp() - { - return $this->_paths === [APP]; - } - - /** - * Checks whether or not a given path is usable for writing. - * - * @param string $path Path to folder - * @return bool true if it exists and is writable, false otherwise - */ - protected function _isPathUsable($path) - { - if (!is_dir($path)) { - mkdir($path, 0770, true); - } - - return is_dir($path) && is_writable($path); - } -} diff --git a/src/Shell/Task/LoadTask.php b/src/Shell/Task/LoadTask.php deleted file mode 100644 index 8fff05c4810..00000000000 --- a/src/Shell/Task/LoadTask.php +++ /dev/null @@ -1,133 +0,0 @@ -params['cli']) { - $filename .= '_cli'; - } - - $this->bootstrap = ROOT . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . $filename . '.php'; - - if (!$plugin) { - $this->err('You must provide a plugin name in CamelCase format.'); - $this->err('To load an "Example" plugin, run `cake plugin load Example`.'); - - return false; - } - - return $this->_modifyBootstrap( - $plugin, - $this->params['bootstrap'], - $this->params['routes'], - $this->params['autoload'] - ); - } - - /** - * Update the applications bootstrap.php file. - * - * @param string $plugin Name of plugin. - * @param bool $hasBootstrap Whether or not bootstrap should be loaded. - * @param bool $hasRoutes Whether or not routes should be loaded. - * @param bool $hasAutoloader Whether or not there is an autoloader configured for - * the plugin. - * @return bool If modify passed. - */ - protected function _modifyBootstrap($plugin, $hasBootstrap, $hasRoutes, $hasAutoloader) - { - $bootstrap = new File($this->bootstrap, false); - $contents = $bootstrap->read(); - if (!preg_match("@\n\s*Plugin::loadAll@", $contents)) { - $autoloadString = $hasAutoloader ? "'autoload' => true" : ''; - $bootstrapString = $hasBootstrap ? "'bootstrap' => true" : ''; - $routesString = $hasRoutes ? "'routes' => true" : ''; - - $append = "\nPlugin::load('%s', [%s]);\n"; - $options = implode(', ', array_filter([$autoloadString, $bootstrapString, $routesString])); - - $bootstrap->append(str_replace(', []', '', sprintf($append, $plugin, $options))); - $this->out(''); - $this->out(sprintf('%s modified', $this->bootstrap)); - - return true; - } - - return false; - } - - /** - * GetOptionParser method. - * - * @return \Cake\Console\ConsoleOptionParser - */ - public function getOptionParser() - { - $parser = parent::getOptionParser(); - - $parser->addOption('bootstrap', [ - 'short' => 'b', - 'help' => 'Will load bootstrap.php from plugin.', - 'boolean' => true, - 'default' => false, - ]) - ->addOption('routes', [ - 'short' => 'r', - 'help' => 'Will load routes.php from plugin.', - 'boolean' => true, - 'default' => false, - ]) - ->addOption('autoload', [ - 'help' => 'Will autoload the plugin using CakePHP.' . - 'Set to true if you are not using composer to autoload your plugin.', - 'boolean' => true, - 'default' => false, - ]) - ->addOption('cli', [ - 'help' => 'Use the bootstrap_cli file.', - 'boolean' => true, - 'default' => false, - ]) - ->addArgument('plugin', [ - 'help' => 'Name of the plugin to load.', - ]); - - return $parser; - } -} diff --git a/src/Shell/Task/UnloadTask.php b/src/Shell/Task/UnloadTask.php deleted file mode 100644 index 9dc60f21f25..00000000000 --- a/src/Shell/Task/UnloadTask.php +++ /dev/null @@ -1,109 +0,0 @@ -params['cli']) { - $filename .= '_cli'; - } - - $this->bootstrap = ROOT . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . $filename . '.php'; - - if (!$plugin) { - $this->err('You must provide a plugin name in CamelCase format.'); - $this->err('To unload an "Example" plugin, run `cake plugin unload Example`.'); - - return false; - } - - return (bool)$this->_modifyBootstrap($plugin); - } - - /** - * Update the applications bootstrap.php file. - * - * @param string $plugin Name of plugin. - * @return bool If modify passed. - */ - protected function _modifyBootstrap($plugin) - { - $finder = "@\nPlugin::load\((.|.\n|\n\s\s|\n\t|)+'$plugin'(.|.\n|)+\);\n@"; - - $bootstrap = new File($this->bootstrap, false); - $content = $bootstrap->read(); - - if (!preg_match("@\n\s*Plugin::loadAll@", $content)) { - $newContent = preg_replace($finder, '', $content); - - if ($newContent === $content) { - return false; - } - - $bootstrap->write($newContent); - - $this->out(''); - $this->out(sprintf('%s modified', $this->bootstrap)); - - return true; - } - - return false; - } - - /** - * GetOptionParser method. - * - * @return \Cake\Console\ConsoleOptionParser - */ - public function getOptionParser() - { - $parser = parent::getOptionParser(); - - $parser->addOption('cli', [ - 'help' => 'Use the bootstrap_cli file.', - 'boolean' => true, - 'default' => false, - ]) - ->addArgument('plugin', [ - 'help' => 'Name of the plugin to load.', - ]); - - return $parser; - } -} diff --git a/src/Shell/VersionShell.php b/src/Shell/VersionShell.php deleted file mode 100644 index 3da0597b406..00000000000 --- a/src/Shell/VersionShell.php +++ /dev/null @@ -1,35 +0,0 @@ -out(Configure::version()); - } -} diff --git a/src/Template/Element/auto_table_warning.ctp b/src/Template/Element/auto_table_warning.ctp deleted file mode 100644 index a6a1f47ab03..00000000000 --- a/src/Template/Element/auto_table_warning.ctp +++ /dev/null @@ -1,42 +0,0 @@ - -

Could this be caused by using Auto-Tables?

-

-Some of the Table objects in your application were created by instantiating "Cake\ORM\Table" -instead of any other specific subclass. -

-

This could be the cause for this exception. Auto-Tables are created for you under the following circumstances:

-
    -
  • The class for the specified table does not exist.
  • -
  • The Table was created with a typo: TableRegistry::get('Atricles');
  • -
  • The class file has a typo in the name or incorrect namespace: class Atricles extends Table
  • -
  • The file containing the class has a typo or incorrect casing: Atricles.php
  • -
  • The Table was used using associations but the association has a typo: $this->belongsTo('Atricles')
  • -
  • The table class resides in a Plugin but no plugin notation was used in the association definition.
  • -
-
-

Please try correcting the issue for the following table aliases:

-
    - $table) : ?> -
  • - -
-
diff --git a/src/Template/Element/exception_stack_trace.ctp b/src/Template/Element/exception_stack_trace.ctp deleted file mode 100644 index 5b4bf7c63be..00000000000 --- a/src/Template/Element/exception_stack_trace.ctp +++ /dev/null @@ -1,62 +0,0 @@ - - -getTrace() as $i => $stack): - $excerpt = $params = []; - - if (isset($stack['file'], $stack['line'])): - $excerpt = Debugger::excerpt($stack['file'], $stack['line'], 4); - endif; - - if (isset($stack['file'])): - $file = $stack['file']; - else: - $file = '[internal function]'; - endif; - - if ($stack['function']): - if (!empty($stack['args'])): - foreach ((array)$stack['args'] as $arg): - $params[] = Debugger::exportVar($arg, 4); - endforeach; - else: - $params[] = 'No arguments'; - endif; - endif; -?> - - diff --git a/src/Template/Element/exception_stack_trace_nav.ctp b/src/Template/Element/exception_stack_trace_nav.ctp deleted file mode 100644 index 859c089401b..00000000000 --- a/src/Template/Element/exception_stack_trace_nav.ctp +++ /dev/null @@ -1,43 +0,0 @@ - -toggle vendor stack frames - - diff --git a/src/Template/Element/plugin_class_error.ctp b/src/Template/Element/plugin_class_error.ctp deleted file mode 100644 index aebe9e83109..00000000000 --- a/src/Template/Element/plugin_class_error.ctp +++ /dev/null @@ -1,33 +0,0 @@ -
'; - -if (!Plugin::loaded($plugin)): - echo sprintf('Make sure your plugin %s is in the %s directory and was loaded.', h($plugin), $pluginPath); -else: - echo sprintf('Make sure your plugin was loaded from %s and Composer is able to autoload its classes, see %s and %s', - 'config' . DIRECTORY_SEPARATOR . 'bootstrap.php', - 'Loading a plugin', - 'Plugins - autoloading plugin classes' - ); -endif; - -?> diff --git a/src/Template/Error/duplicate_named_route.ctp b/src/Template/Error/duplicate_named_route.ctp deleted file mode 100644 index 236fdb13fc8..00000000000 --- a/src/Template/Error/duplicate_named_route.ctp +++ /dev/null @@ -1,61 +0,0 @@ -layout = 'dev_error'; - -$this->assign('title', 'Duplicate Named Route'); -$this->assign('templateName', 'duplicate_named_route.ctp'); - -$attributes = $error->getAttributes(); - -$this->start('subheading'); -?> - Error: - getMessage(); ?> -end() ?> - -start('file') ?> -

Route names must be unique across your entire application. -The same _name option cannot be used twice, -even if the names occur in different routing scopes. -Remove duplicate route names in your route configuration.

- - -

The passed context was:

-
-
-
- - - -

Duplicate Route

- - - '; - printf( - '', - $other->template, - Debugger::exportVar($other->defaults), - Debugger::exportVar($other->options) - ); - echo ''; - ?> -
TemplateDefaultsOptions
%s%s%s
- -end() ?> diff --git a/src/Template/Error/fatal_error.ctp b/src/Template/Error/fatal_error.ctp deleted file mode 100644 index 22262746a04..00000000000 --- a/src/Template/Error/fatal_error.ctp +++ /dev/null @@ -1,38 +0,0 @@ -layout = 'dev_error'; - -$this->assign('title', 'Fatal Error'); -$this->assign('templateName', 'fatal_error.ctp'); - -$this->start('subheading'); -?> - Error: - getMessage()) ?> -
- - File - getFile()) ?> -
- Line: - getLine()) ?> -end() ?> - -start('file'); -if (extension_loaded('xdebug')): - xdebug_print_function_stack(); -endif; -$this->end(); diff --git a/src/Template/Error/missing_action.ctp b/src/Template/Error/missing_action.ctp deleted file mode 100644 index a370d475ff0..00000000000 --- a/src/Template/Error/missing_action.ctp +++ /dev/null @@ -1,85 +0,0 @@ -layout = 'dev_error'; - -$this->assign('title', sprintf('Missing Method in %s', h($class))); -$this->assign( - 'subheading', - sprintf('The action %s is not defined in %s', h($action), h($class)) -); -$this->assign('templateName', 'missing_action.ctp'); - -$this->start('file'); -?> -

- Error: - %s::%s() in file: %s.', h($class), h($action), $path); ?> -

- - - -
-end() ?> diff --git a/src/Template/Error/missing_behavior.ctp b/src/Template/Error/missing_behavior.ctp deleted file mode 100644 index 37c6189df41..00000000000 --- a/src/Template/Error/missing_behavior.ctp +++ /dev/null @@ -1,68 +0,0 @@ -layout = 'dev_error'; - -$this->assign('templateName', 'missing_behavior.ctp'); - -$this->assign('title', 'Missing Behavior'); - -$this->start('subheading'); -printf('%s could not be found.', h($pluginDot . $class)); -echo $this->element('plugin_class_error', ['pluginPath' => $pluginPath]); -$this->end(); - -$this->start('file'); -?> -

- Error: - %s below in file: %s', h($class), $filePath . 'Model' . DIRECTORY_SEPARATOR . 'Behavior' . DIRECTORY_SEPARATOR . h($class) . '.php'); ?> -

- - -
-end() ?> diff --git a/src/Template/Error/missing_cell_view.ctp b/src/Template/Error/missing_cell_view.ctp deleted file mode 100644 index ad59435f748..00000000000 --- a/src/Template/Error/missing_cell_view.ctp +++ /dev/null @@ -1,43 +0,0 @@ -layout = 'dev_error'; - -$this->assign('templateName', 'missing_cell_view.ctp'); -$this->assign('title', 'Missing Cell View'); - -$this->start('subheading'); -printf('The view for %sCell was not be found.', h(Inflector::camelize($name))); -$this->end(); - -$this->start('file'); -?> -

- Confirm you have created the file: "_ext) ?>" - in one of the following paths: -

-
    -_paths($this->plugin); - foreach ($paths as $path): - if (strpos($path, CORE_PATH) !== false) { - continue; - } - echo sprintf('
  • %sCell/%s/%s
  • ', h($path), h($name), h($file . $this->_ext)); - endforeach; -?> -
-end(); ?> diff --git a/src/Template/Error/missing_component.ctp b/src/Template/Error/missing_component.ctp deleted file mode 100644 index d9ff8ef5366..00000000000 --- a/src/Template/Error/missing_component.ctp +++ /dev/null @@ -1,64 +0,0 @@ -layout = 'dev_error'; -$this->assign('title', 'Missing Component'); -$this->assign('templateName', 'missing_component.ctp'); - -$this->start('subheading'); -printf('%s could not be found.', h($pluginDot . $class)); -echo $this->element('plugin_class_error', ['pluginPath' => $pluginPath]); -$this->end(); - -$this->start('file'); -?> -

- Error: - %s below in file: %s', h($class), $filePath . 'Controller' . DIRECTORY_SEPARATOR . 'Component' . DIRECTORY_SEPARATOR . h($class) . '.php'); ?> -

- -
-end() ?> diff --git a/src/Template/Error/missing_connection.ctp b/src/Template/Error/missing_connection.ctp deleted file mode 100644 index fe9ed4b9ddd..00000000000 --- a/src/Template/Error/missing_connection.ctp +++ /dev/null @@ -1,32 +0,0 @@ -layout = 'dev_error'; - -$this->assign('templateName', 'missing_connection.ctp'); -$this->assign('title', 'Missing Database Connection'); - - -$this->start('subheading'); ?> -A Database connection using was missing or unable to connect. -
-end(); - -$this->start('file'); -echo $this->element('auto_table_warning'); -$this->end(); diff --git a/src/Template/Error/missing_controller.ctp b/src/Template/Error/missing_controller.ctp deleted file mode 100644 index 53cd4a3eb41..00000000000 --- a/src/Template/Error/missing_controller.ctp +++ /dev/null @@ -1,91 +0,0 @@ -layout = 'dev_error'; - -$this->assign('title', 'Missing Controller'); -$this->assign('templateName', 'missing_controller.ctp'); - -?> -start('subheading');?> -Error: - - Your routing resulted in as a controller name. - - Controller could not be found. - -end() ?> - - -start('file'); ?> - -

The controller name has not been properly inflected, and - could not be resolved to a controller that exists in your application.

- -

Ensure that your URL request->getUri()->getPath()) ?> is - using the same inflection style as your routes do. By default applications use DashedRoute - and URLs should use - to separate multi-word controller names.

- -

- In the case you tried to access a plugin controller make sure you added it to your composer file or you use the autoload option for the plugin. -

-

- Error: - Create the class Controller below in file: -

- - -
- -end(); ?> diff --git a/src/Template/Error/missing_datasource.ctp b/src/Template/Error/missing_datasource.ctp deleted file mode 100644 index 2102d821a74..00000000000 --- a/src/Template/Error/missing_datasource.ctp +++ /dev/null @@ -1,29 +0,0 @@ -layout = 'dev_error'; - -$this->assign('title', 'Missing Datasource'); -$this->assign('templateName', 'missing_datasource.ctp'); - -$this->start('subheading'); -?> -Error: -Datasource class could not be found. - - - -end() ?> diff --git a/src/Template/Error/missing_datasource_config.ctp b/src/Template/Error/missing_datasource_config.ctp deleted file mode 100644 index 05b0fa15395..00000000000 --- a/src/Template/Error/missing_datasource_config.ctp +++ /dev/null @@ -1,29 +0,0 @@ -layout = 'dev_error'; - -$this->assign('title', 'Missing Datasource Configuration'); -$this->assign('templateName', 'missing_datasource_config.ctp'); - -$this->start('subheading'); -?> - Error: - - The datasource configuration was not found in config. - - - -end() ?> diff --git a/src/Template/Error/missing_helper.ctp b/src/Template/Error/missing_helper.ctp deleted file mode 100644 index 1bbecba1620..00000000000 --- a/src/Template/Error/missing_helper.ctp +++ /dev/null @@ -1,65 +0,0 @@ -layout = 'dev_error'; -$this->assign('title', 'Missing Helper'); -$this->assign('templateName', 'missing_helper.ctp'); - -$this->start('subheading'); -?> - Error: - could not be found. - element('plugin_class_error', ['pluginPath' => $pluginPath]) ?> -end() ?> - -start('file') ?> -

- Error: - %s below in file: %s', h($class), $filePath . 'View' . DIRECTORY_SEPARATOR . 'Helper' . DIRECTORY_SEPARATOR . h($class) . '.php'); ?> -

- -
-end() ?> diff --git a/src/Template/Error/missing_layout.ctp b/src/Template/Error/missing_layout.ctp deleted file mode 100644 index d53649b3d9b..00000000000 --- a/src/Template/Error/missing_layout.ctp +++ /dev/null @@ -1,42 +0,0 @@ -layout = 'dev_error'; - -$this->assign('title', 'Missing Layout'); -$this->assign('templateName', 'missing_layout.ctp'); - -$this->start('subheading'); -?> - Error: - The layout file can not be found or does not exist. -end() ?> - -start('file') ?> -

- Confirm you have created the file: in one of the following paths: -

-
    -_paths($this->plugin); - foreach ($paths as $path): - if (strpos($path, CORE_PATH) !== false) { - continue; - } - echo sprintf('
  • %s%s
  • ', h($path), h($file)); - endforeach; -?> -
-end() ?> diff --git a/src/Template/Error/missing_plugin.ctp b/src/Template/Error/missing_plugin.ctp deleted file mode 100644 index ec6f9005214..00000000000 --- a/src/Template/Error/missing_plugin.ctp +++ /dev/null @@ -1,55 +0,0 @@ -layout = 'dev_error'; - -$pluginPath = Configure::read('App.paths.plugins.0'); - -$this->assign('title', 'Missing Plugin'); -$this->assign('templateName', 'missing_plugin.ctp'); - -$this->start('subheading'); -?> - Error: - The application is trying to load a file from the plugin. -
-
- Make sure your plugin is in the directory and was loaded. -end() ?> - -start('file') ?> - -
- -

- Loading all plugins: - -

- - -
-end() ?> diff --git a/src/Template/Error/missing_route.ctp b/src/Template/Error/missing_route.ctp deleted file mode 100644 index 9f53979c806..00000000000 --- a/src/Template/Error/missing_route.ctp +++ /dev/null @@ -1,59 +0,0 @@ -layout = 'dev_error'; - -$this->assign('title', 'Missing Route'); -$this->assign('templateName', 'missing_route.ctp'); - -$attributes = $error->getAttributes(); - -$this->start('subheading'); -?> - Error: - getMessage(); ?> -end() ?> - -start('file') ?> -

None of the currently connected routes match the provided parameters. -Add a matching route to

- - -

The passed context was:

-
-
-
- - -

Connected Routes

- - -'; - printf( - '', - $route->template, - Debugger::exportVar($route->defaults), - Debugger::exportVar($route->options) - ); - echo ''; -endforeach; -?> -
TemplateDefaultsOptions
%s%s%s
-end() ?> diff --git a/src/Template/Error/missing_template.ctp b/src/Template/Error/missing_template.ctp deleted file mode 100644 index 0d4f5a38085..00000000000 --- a/src/Template/Error/missing_template.ctp +++ /dev/null @@ -1,51 +0,0 @@ -layout = 'dev_error'; - -$this->assign('title', 'Missing Template'); -$this->assign('templateName', 'missing_template.ctp'); - -$isEmail = strpos($file, 'Email/') === 0; - -$this->start('subheading'); -?> - - Error: - was not found.', h($file)); ?> - - Error: - %sController::%s() was not found.', h(Inflector::camelize($this->request->controller)), h($this->request->action)); ?> - -end() ?> - -start('file') ?> -

- - in one of the following paths: -

-
    -_paths($this->plugin); - foreach ($paths as $path): - if (strpos($path, CORE_PATH) !== false) { - continue; - } - echo sprintf('
  • %s%s
  • ', h($path), h($file)); - endforeach; -?> -
-end() ?> diff --git a/src/Template/Error/missing_view.ctp b/src/Template/Error/missing_view.ctp deleted file mode 100644 index 99d385fb993..00000000000 --- a/src/Template/Error/missing_view.ctp +++ /dev/null @@ -1,67 +0,0 @@ -layout = 'dev_error'; -$this->assign('title', 'Missing View'); -$this->assign('templateName', 'missing_view.ctp'); - -$this->start('subheading'); -?> - Error: - could not be found. - - Make sure your plugin is in the directory and was loaded. - - element('plugin_class_error', ['pluginPath' => $pluginPath]) ?> - -end() ?> - -start('file') ?> -

- Error: - %s below in file: %s', h($class), $filePath . 'View' . DIRECTORY_SEPARATOR . h($class) . '.php'); ?> -

- -
-end() ?> diff --git a/src/Template/Error/pdo_error.ctp b/src/Template/Error/pdo_error.ctp deleted file mode 100644 index df76bef9fd4..00000000000 --- a/src/Template/Error/pdo_error.ctp +++ /dev/null @@ -1,44 +0,0 @@ -layout = 'dev_error'; - -$this->assign('title', 'Database Error'); -$this->assign('templateName', 'pdo_error.ctp'); - -$this->start('subheading'); -?> - Error: - -end() ?> - -start('file') ?> -

- If you are using SQL keywords as table column names, you can enable identifier - quoting for your database connection in config/app.php. -

-queryString)) : ?> -

- SQL Query: -

-
queryString); ?>
- -params)) : ?> - SQL Query Params: -
params)); ?>
- -element('auto_table_warning'); ?> -end() ?> diff --git a/src/Template/Layout/dev_error.ctp b/src/Template/Layout/dev_error.ctp deleted file mode 100644 index c747fd7f0ad..00000000000 --- a/src/Template/Layout/dev_error.ctp +++ /dev/null @@ -1,297 +0,0 @@ - - - - - Html->charset() ?> - - - Error: <?= h($this->fetch('title')) ?> - - Html->meta('icon') ?> - - - -
-

- fetch('title')) ?> - -

- -
- -
- fetch('subheading')): ?> -

- fetch('subheading') ?> -

- - - element('exception_stack_trace'); ?> - -
- fetch('file') ?> -
- - fetch('templateName')): ?> -

- If you want to customize this error message, create - fetch('templateName') ?> -

- -
- -
- element('exception_stack_trace_nav') ?> -
- - - - diff --git a/src/TestSuite/ConnectionHelper.php b/src/TestSuite/ConnectionHelper.php new file mode 100644 index 00000000000..0ec8d64a325 --- /dev/null +++ b/src/TestSuite/ConnectionHelper.php @@ -0,0 +1,161 @@ +` aliases for all non-test connections. + * + * This forces all models to use the test connection instead. For example, + * if a model is confused to use connection `files` then it will be aliased + * to `test_files`. + * + * The `default` connection is aliased to `test`. + * + * @return void + */ + public static function addTestAliases(): void + { + ConnectionManager::alias('test', 'default'); + foreach (ConnectionManager::configured() as $connection) { + if ($connection === 'test' || $connection === 'default') { + continue; + } + + if (str_starts_with($connection, 'test_')) { + $original = substr($connection, 5); + ConnectionManager::alias($connection, $original); + } else { + $test = 'test_' . $connection; + ConnectionManager::alias($test, $connection); + } + } + } + + /** + * Enables query logging for all database connections. + * + * @param array|null $connections Connection names or null for all. + * @return void + */ + public static function enableQueryLogging(?array $connections = null): void + { + $connections ??= ConnectionManager::configured(); + foreach ($connections as $connection) { + $connection = ConnectionManager::get($connection); + $message = '--Starting test run ' . date('Y-m-d H:i:s'); + if ( + $connection instanceof Connection && + $connection->getWriteDriver()->log($message) === false + ) { + $connection->getWriteDriver()->setLogger(new QueryLogger()); + $connection->getWriteDriver()->log($message); + } + } + } + + /** + * Drops all tables. + * + * @param string $connectionName Connection name + * @param array|null $tables List of tables names or null for all. + * @return void + */ + public static function dropTables(string $connectionName, ?array $tables = null): void + { + $connection = ConnectionManager::get($connectionName); + assert($connection instanceof Connection); + $collection = $connection->getSchemaCollection(); + $allTables = $collection->listTablesWithoutViews(); + + // Skip special tables. + // spatial_ref_sys - postgis and it is undroppable. + $skip = ['spatial_ref_sys']; + $allTables = array_diff($allTables, $skip); + + $tables = $tables !== null ? array_intersect($tables, $allTables) : $allTables; + /** @var array<\Cake\Database\Schema\TableSchema> $schemas Specify type for psalm */ + $schemas = array_map(fn(string $table) => $collection->describe($table), $tables); + + $dialect = $connection->getWriteDriver()->schemaDialect(); + foreach ($schemas as $schema) { + foreach ($dialect->dropConstraintSql($schema) as $statement) { + $connection->execute($statement); + } + } + foreach ($schemas as $schema) { + foreach ($dialect->dropTableSql($schema) as $statement) { + $connection->execute($statement); + } + } + } + + /** + * Truncates all tables. + * + * @param string $connectionName Connection name + * @param array|null $tables List of tables names or null for all. + * @return void + */ + public static function truncateTables(string $connectionName, ?array $tables = null): void + { + $connection = ConnectionManager::get($connectionName); + assert($connection instanceof Connection); + $collection = $connection->getSchemaCollection(); + + $allTables = $collection->listTablesWithoutViews(); + $tables = $tables !== null ? array_intersect($tables, $allTables) : $allTables; + /** @var array<\Cake\Database\Schema\TableSchema> $schemas Specify type for psalm */ + $schemas = array_map(fn(string $table) => $collection->describe($table), $tables); + + self::runWithoutConstraints($connection, function (Connection $connection) use ($schemas): void { + $dialect = $connection->getWriteDriver()->schemaDialect(); + foreach ($schemas as $schema) { + foreach ($dialect->truncateTableSql($schema) as $statement) { + $connection->execute($statement); + } + } + }); + } + + /** + * Runs callback with constraints disabled correctly per-database + * + * @param \Cake\Database\Connection $connection Database connection + * @param \Closure $callback callback + * @return void + */ + public static function runWithoutConstraints(Connection $connection, Closure $callback): void + { + if ($connection->getWriteDriver()->supports(DriverFeatureEnum::DISABLE_CONSTRAINT_WITHOUT_TRANSACTION)) { + $connection->disableConstraints(fn(Connection $connection) => $callback($connection)); + } else { + $connection->transactional(function (Connection $connection) use ($callback): void { + $connection->disableConstraints(fn(Connection $connection) => $callback($connection)); + }); + } + } +} diff --git a/src/TestSuite/ConsoleIntegrationTestCase.php b/src/TestSuite/ConsoleIntegrationTestCase.php deleted file mode 100644 index e1f88a43c2e..00000000000 --- a/src/TestSuite/ConsoleIntegrationTestCase.php +++ /dev/null @@ -1,305 +0,0 @@ -_makeRunner(); - - $this->_out = new ConsoleOutput(); - $this->_err = new ConsoleOutput(); - $this->_in = $this->getMockBuilder(ConsoleInput::class) - ->disableOriginalConstructor() - ->setMethods(['read']) - ->getMock(); - - $i = 0; - foreach ($input as $in) { - $this->_in - ->expects($this->at($i++)) - ->method('read') - ->will($this->returnValue($in)); - } - - $args = $this->_commandStringToArgs("cake $command"); - - $io = new ConsoleIo($this->_out, $this->_err, $this->_in); - - $this->_exitCode = $runner->run($args, $io); - } - - /** - * tearDown - * - * @return void - */ - public function tearDown() - { - parent::tearDown(); - - $this->_exitCode = null; - $this->_out = null; - $this->_err = null; - $this->_in = null; - $this->_useCommandRunner = false; - } - - /** - * Set this test case to use the CommandRunner rather than the legacy - * ShellDispatcher - * - * @return void - */ - public function useCommandRunner() - { - $this->_useCommandRunner = true; - } - - /** - * Asserts shell exited with the expected code - * - * @param int $expected Expected exit code - * @param string $message Failure message to be appended to the generated message - * @return void - */ - public function assertExitCode($expected, $message = '') - { - $message = sprintf( - 'Shell exited with code %d instead of the expected code %d. %s', - $this->_exitCode, - $expected, - $message - ); - $this->assertSame($expected, $this->_exitCode, $message); - } - - /** - * Asserts that `stdout` is empty - * - * @param string $message The message to output when the assertion fails. - * @return void - */ - public function assertOutputEmpty($message = 'stdout was not empty') - { - $output = implode(PHP_EOL, $this->_out->messages()); - $this->assertSame('', $output, $message); - } - - /** - * Asserts `stdout` contains expected output - * - * @param string $expected Expected output - * @param string $message Failure message - * @return void - */ - public function assertOutputContains($expected, $message = '') - { - $output = implode(PHP_EOL, $this->_out->messages()); - $this->assertContains($expected, $output, $message); - } - - /** - * Asserts `stdout` contains expected regexp - * - * @param string $pattern Expected pattern - * @param string $message Failure message - * @return void - */ - public function assertOutputRegExp($pattern, $message = '') - { - $output = implode(PHP_EOL, $this->_out->messages()); - $this->assertRegExp($pattern, $output, $message); - } - - /** - * Check that a row of cells exists in the output. - * - * @param array $row Row of cells to ensure exist in the output. - * @param string $message Failure message. - * @return void - */ - protected function assertOutputContainsRow(array $row, $message = '') - { - $row = array_map(function ($cell) { - return preg_quote($cell, '/'); - }, $row); - $cells = implode('\s+\|\s+', $row); - $pattern = '/' . $cells . '/'; - $this->assertOutputRegExp($pattern); - } - - /** - * Asserts `stderr` contains expected output - * - * @param string $expected Expected output - * @param string $message Failure message - * @return void - */ - public function assertErrorContains($expected, $message = '') - { - $output = implode(PHP_EOL, $this->_err->messages()); - $this->assertContains($expected, $output, $message); - } - - /** - * Asserts `stderr` contains expected regexp - * - * @param string $pattern Expected pattern - * @param string $message Failure message - * @return void - */ - public function assertErrorRegExp($pattern, $message = '') - { - $output = implode(PHP_EOL, $this->_err->messages()); - $this->assertRegExp($pattern, $output, $message); - } - - /** - * Asserts that `stderr` is empty - * - * @param string $message The message to output when the assertion fails. - * @return void - */ - public function assertErrorEmpty($message = 'stderr was not empty') - { - $output = implode(PHP_EOL, $this->_err->messages()); - $this->assertSame('', $output, $message); - } - - /** - * Builds the appropriate command dispatcher - * - * @return CommandRunner|LegacyCommandRunner - */ - protected function _makeRunner() - { - if ($this->_useCommandRunner) { - $applicationClassName = Configure::read('App.namespace') . '\Application'; - - return new CommandRunner(new $applicationClassName([CONFIG])); - } - - return new LegacyCommandRunner(); - } - - /** - * Creates an $argv array from a command string - * - * @param string $command Command string - * @return array - */ - protected function _commandStringToArgs($command) - { - $charCount = strlen($command); - $argv = []; - $arg = ''; - $inDQuote = false; - $inSQuote = false; - for ($i = 0; $i < $charCount; $i++) { - $char = substr($command, $i, 1); - - // end of argument - if ($char === ' ' && !$inDQuote && !$inSQuote) { - if (strlen($arg)) { - $argv[] = $arg; - } - $arg = ''; - continue; - } - - // exiting single quote - if ($inSQuote && $char === "'") { - $inSQuote = false; - continue; - } - - // exiting double quote - if ($inDQuote && $char === '"') { - $inDQuote = false; - continue; - } - - // entering double quote - if ($char === '"' && !$inSQuote) { - $inDQuote = true; - continue; - } - - // entering single quote - if ($char === "'" && !$inDQuote) { - $inSQuote = true; - continue; - } - - $arg .= $char; - } - $argv[] = $arg; - - return $argv; - } -} diff --git a/src/TestSuite/Constraint/Email/MailConstraintBase.php b/src/TestSuite/Constraint/Email/MailConstraintBase.php new file mode 100644 index 00000000000..72eca231ce8 --- /dev/null +++ b/src/TestSuite/Constraint/Email/MailConstraintBase.php @@ -0,0 +1,63 @@ +at = $at; + } + + /** + * Gets the email or emails to check + * + * @return array<\Cake\Mailer\Message> + */ + public function getMessages(): array + { + $messages = TestEmailTransport::getMessages(); + + if ($this->at !== null) { + if (!isset($messages[$this->at])) { + return []; + } + + return [$messages[$this->at]]; + } + + return $messages; + } +} diff --git a/src/TestSuite/Constraint/Email/MailContains.php b/src/TestSuite/Constraint/Email/MailContains.php new file mode 100644 index 00000000000..9c96c19a525 --- /dev/null +++ b/src/TestSuite/Constraint/Email/MailContains.php @@ -0,0 +1,98 @@ +getMessages(); + foreach ($messages as $message) { + $method = $this->getTypeMethod(); + $message = $message->$method(); + + if (preg_match("/{$other}/", $message) > 0) { + return true; + } + } + + return false; + } + + /** + * @return string + */ + protected function getTypeMethod(): string + { + return 'getBody' . ($this->type ? ucfirst($this->type) : 'String'); + } + + /** + * Returns the type-dependent strings of all messages + * respects $this->at + * + * @return string + */ + protected function getAssertedMessages(): string + { + $messageMembers = []; + $messages = $this->getMessages(); + foreach ($messages as $message) { + $method = $this->getTypeMethod(); + $messageMembers[] = $message->$method(); + } + if ($this->at && isset($messageMembers[$this->at - 1])) { + $messageMembers = [$messageMembers[$this->at - 1]]; + } + $result = implode(PHP_EOL, $messageMembers); + + return PHP_EOL . 'was: ' . mb_substr($result, 0, 1000); + } + + /** + * Assertion message string + * + * @return string + */ + public function toString(): string + { + if ($this->at) { + return sprintf('is in email #%d', $this->at) . $this->getAssertedMessages(); + } + + return 'is in an email' . $this->getAssertedMessages(); + } +} diff --git a/src/TestSuite/Constraint/Email/MailContainsAttachment.php b/src/TestSuite/Constraint/Email/MailContainsAttachment.php new file mode 100644 index 00000000000..7ed1f54d808 --- /dev/null +++ b/src/TestSuite/Constraint/Email/MailContainsAttachment.php @@ -0,0 +1,77 @@ +getMessages(); + foreach ($messages as $message) { + foreach ($message->getAttachments() as $filename => $fileInfo) { + if ($filename === $expectedFilename && !$expectedFileInfo) { + return true; + } + if ($expectedFileInfo && array_intersect($expectedFileInfo, $fileInfo) === $expectedFileInfo) { + return true; + } + } + } + + return false; + } + + /** + * Assertion message string + * + * @return string + */ + public function toString(): string + { + if ($this->at) { + return sprintf('is an attachment of email #%d', $this->at); + } + + return 'is an attachment of an email'; + } + + /** + * Overwrites the descriptions so we can remove the automatic "expected" message + * + * @param mixed $other Value + * @return string + */ + protected function failureDescription(mixed $other): string + { + [$expectedFilename] = $other; + + return "'" . $expectedFilename . "' " . $this->toString(); + } +} diff --git a/src/TestSuite/Constraint/Email/MailContainsHtml.php b/src/TestSuite/Constraint/Email/MailContainsHtml.php new file mode 100644 index 00000000000..58f2195b0ee --- /dev/null +++ b/src/TestSuite/Constraint/Email/MailContainsHtml.php @@ -0,0 +1,46 @@ +at) { + return sprintf('is in the html message of email #%d', $this->at) . $this->getAssertedMessages(); + } + + return 'is in the html message of an email' . $this->getAssertedMessages(); + } +} diff --git a/src/TestSuite/Constraint/Email/MailContainsText.php b/src/TestSuite/Constraint/Email/MailContainsText.php new file mode 100644 index 00000000000..8529cd5d0b2 --- /dev/null +++ b/src/TestSuite/Constraint/Email/MailContainsText.php @@ -0,0 +1,46 @@ +at) { + return sprintf('is in the text message of email #%d', $this->at) . $this->getAssertedMessages(); + } + + return 'is in the text message of an email' . $this->getAssertedMessages(); + } +} diff --git a/src/TestSuite/Constraint/Email/MailCount.php b/src/TestSuite/Constraint/Email/MailCount.php new file mode 100644 index 00000000000..69cec28d112 --- /dev/null +++ b/src/TestSuite/Constraint/Email/MailCount.php @@ -0,0 +1,46 @@ +getMessages()) === $other; + } + + /** + * Assertion message string + * + * @return string + */ + public function toString(): string + { + return 'emails were sent'; + } +} diff --git a/src/TestSuite/Constraint/Email/MailSentFrom.php b/src/TestSuite/Constraint/Email/MailSentFrom.php new file mode 100644 index 00000000000..b617c2dfb22 --- /dev/null +++ b/src/TestSuite/Constraint/Email/MailSentFrom.php @@ -0,0 +1,44 @@ +at) { + return sprintf('sent email #%d', $this->at); + } + + return 'sent an email'; + } +} diff --git a/src/TestSuite/Constraint/Email/MailSentTo.php b/src/TestSuite/Constraint/Email/MailSentTo.php new file mode 100644 index 00000000000..afb01d0e41b --- /dev/null +++ b/src/TestSuite/Constraint/Email/MailSentTo.php @@ -0,0 +1,44 @@ +at) { + return sprintf('was sent email #%d', $this->at); + } + + return 'was sent an email'; + } +} diff --git a/src/TestSuite/Constraint/Email/MailSentWith.php b/src/TestSuite/Constraint/Email/MailSentWith.php new file mode 100644 index 00000000000..50b9ef0812c --- /dev/null +++ b/src/TestSuite/Constraint/Email/MailSentWith.php @@ -0,0 +1,85 @@ +method = $method; + } + + parent::__construct($at); + } + + /** + * Checks constraint + * + * @param mixed $other Constraint check + * @return bool + */ + public function matches(mixed $other): bool + { + $emails = $this->getMessages(); + foreach ($emails as $email) { + $value = $email->{'get' . ucfirst($this->method)}(); + if ($value === $other) { + return true; + } + if ( + !is_array($other) + && in_array($this->method, ['to', 'cc', 'bcc', 'from', 'replyTo', 'sender'], true) + && array_key_exists($other, $value) + ) { + return true; + } + } + + return false; + } + + /** + * Assertion message string + * + * @return string + */ + public function toString(): string + { + if ($this->at) { + return sprintf('is in email #%d `%s`', $this->at, $this->method); + } + + return sprintf('is in an email `%s`', $this->method); + } +} diff --git a/src/TestSuite/Constraint/Email/MailSubjectContains.php b/src/TestSuite/Constraint/Email/MailSubjectContains.php new file mode 100644 index 00000000000..a65a02b2524 --- /dev/null +++ b/src/TestSuite/Constraint/Email/MailSubjectContains.php @@ -0,0 +1,86 @@ +getMessages(); + foreach ($messages as $message) { + $subject = $message->getOriginalSubject(); + if (str_contains($subject, $other)) { + return true; + } + } + + return false; + } + + /** + * Returns the subjects of all messages + * respects $this->at + * + * @return string + */ + protected function getAssertedMessages(): string + { + $messageMembers = []; + $messages = $this->getMessages(); + foreach ($messages as $message) { + $messageMembers[] = $message->getSubject(); + } + if ($this->at && isset($messageMembers[$this->at - 1])) { + $messageMembers = [$messageMembers[$this->at - 1]]; + } + $result = implode(PHP_EOL, $messageMembers); + + return PHP_EOL . 'was: ' . mb_substr($result, 0, 1000); + } + + /** + * Assertion message string + * + * @return string + */ + public function toString(): string + { + if ($this->at) { + return sprintf('is in an email subject #%d', $this->at) . $this->getAssertedMessages(); + } + + return 'is in an email subject' . $this->getAssertedMessages(); + } +} diff --git a/src/TestSuite/Constraint/Email/NoMailSent.php b/src/TestSuite/Constraint/Email/NoMailSent.php new file mode 100644 index 00000000000..f714f7b037f --- /dev/null +++ b/src/TestSuite/Constraint/Email/NoMailSent.php @@ -0,0 +1,57 @@ +getMessages() === []; + } + + /** + * Assertion message string + * + * @return string + */ + public function toString(): string + { + return 'no emails were sent'; + } + + /** + * Overwrites the descriptions so we can remove the automatic "expected" message + * + * @param mixed $other Value + * @return string + */ + protected function failureDescription(mixed $other): string + { + return $this->toString(); + } +} diff --git a/src/TestSuite/Constraint/EventFired.php b/src/TestSuite/Constraint/EventFired.php index c3fe9e1777d..5ec467c3b70 100644 --- a/src/TestSuite/Constraint/EventFired.php +++ b/src/TestSuite/Constraint/EventFired.php @@ -1,22 +1,28 @@ _eventManager = $eventManager; if ($this->_eventManager->getEventList() === null) { - throw new AssertionFailedError('The event manager you are asserting against is not configured to track events.'); + throw new AssertionFailedError( + 'The event manager you are asserting against is not configured to track events.', + ); } } @@ -48,9 +55,11 @@ public function __construct($eventManager) * @param mixed $other Constraint check * @return bool */ - public function matches($other) + public function matches(mixed $other): bool { - return $this->_eventManager->getEventList()->hasEvent($other); + $list = $this->_eventManager->getEventList(); + + return $list === null ? false : $list->hasEvent($other); } /** @@ -58,7 +67,7 @@ public function matches($other) * * @return string */ - public function toString() + public function toString(): string { return 'was fired'; } diff --git a/src/TestSuite/Constraint/EventFiredWith.php b/src/TestSuite/Constraint/EventFiredWith.php index b280082334e..47631e490b3 100644 --- a/src/TestSuite/Constraint/EventFiredWith.php +++ b/src/TestSuite/Constraint/EventFiredWith.php @@ -1,25 +1,18 @@ _eventManager = $eventManager; $this->_dataKey = $dataKey; $this->_dataValue = $dataValue; if ($this->_eventManager->getEventList() === null) { - throw new AssertionFailedError('The event manager you are asserting against is not configured to track events.'); + throw new AssertionFailedError( + 'The event manager you are asserting against is not configured to track events.', + ); } } @@ -68,36 +62,38 @@ public function __construct($eventManager, $dataKey, $dataValue) * * @param mixed $other Constraint check * @return bool + * @throws \PHPUnit\Framework\AssertionFailedError */ - public function matches($other) + public function matches(mixed $other): bool { - $firedEvents = []; + $eventGroup = []; $list = $this->_eventManager->getEventList(); - $totalEvents = count($list); - for ($e = 0; $e < $totalEvents; $e++) { - $firedEvents[] = $list[$e]; + if ($list !== null) { + $eventGroup = (new Collection($list)) + ->groupBy(function (EventInterface $event): string { + return $event->getName(); + }) + ->toArray(); } - $eventGroup = collection($firedEvents) - ->groupBy(function (Event $event) { - return $event->getName(); - }) - ->toArray(); - if (!array_key_exists($other, $eventGroup)) { return false; } + /** @var array<\Cake\Event\EventInterface> $events */ $events = $eventGroup[$other]; if (count($events) > 1) { - throw new AssertionFailedError(sprintf('Event "%s" was fired %d times, cannot make data assertion', $other, count($events))); + throw new AssertionFailedError(sprintf( + 'Event `%s` was fired %d times, cannot make data assertion', + $other, + count($events), + )); } - /* @var \Cake\Event\Event $event */ $event = $events[0]; - if (array_key_exists($this->_dataKey, $event->getData()) === false) { + if (array_key_exists($this->_dataKey, (array)$event->getData()) === false) { return false; } @@ -109,8 +105,8 @@ public function matches($other) * * @return string */ - public function toString() + public function toString(): string { - return 'was fired with ' . $this->_dataKey . ' matching ' . (string)$this->_dataValue; + return "was fired with `{$this->_dataKey}` matching `" . json_encode($this->_dataValue) . '`'; } } diff --git a/src/TestSuite/Constraint/Response/BodyContains.php b/src/TestSuite/Constraint/Response/BodyContains.php new file mode 100644 index 00000000000..19126a1bb89 --- /dev/null +++ b/src/TestSuite/Constraint/Response/BodyContains.php @@ -0,0 +1,70 @@ +ignoreCase = $ignoreCase; + } + + /** + * Checks assertion + * + * @param mixed $other Expected type + * @return bool + */ + public function matches(mixed $other): bool + { + $method = 'mb_strpos'; + if ($this->ignoreCase) { + $method = 'mb_stripos'; + } + + return $method($this->_getBodyAsString(), $other) !== false; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return 'is in response body'; + } +} diff --git a/src/TestSuite/Constraint/Response/BodyEmpty.php b/src/TestSuite/Constraint/Response/BodyEmpty.php new file mode 100644 index 00000000000..d56cdcc0c32 --- /dev/null +++ b/src/TestSuite/Constraint/Response/BodyEmpty.php @@ -0,0 +1,56 @@ +_getBodyAsString()); + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return 'response body is empty'; + } + + /** + * Overwrites the descriptions so we can remove the automatic "expected" message + * + * @param mixed $other Value + * @return string + */ + protected function failureDescription(mixed $other): string + { + return $this->toString(); + } +} diff --git a/src/TestSuite/Constraint/Response/BodyEquals.php b/src/TestSuite/Constraint/Response/BodyEquals.php new file mode 100644 index 00000000000..174fede325a --- /dev/null +++ b/src/TestSuite/Constraint/Response/BodyEquals.php @@ -0,0 +1,45 @@ +_getBodyAsString() === $other; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return 'matches response body'; + } +} diff --git a/src/TestSuite/Constraint/Response/BodyNotContains.php b/src/TestSuite/Constraint/Response/BodyNotContains.php new file mode 100644 index 00000000000..692d32b9af2 --- /dev/null +++ b/src/TestSuite/Constraint/Response/BodyNotContains.php @@ -0,0 +1,45 @@ +_getBodyAsString()) > 0; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return 'PCRE pattern found in response body'; + } + + /** + * @param mixed $other Expected + * @return string + */ + public function failureDescription(mixed $other): string + { + return '`' . $other . '`' . ' ' . $this->toString(); + } +} diff --git a/src/TestSuite/Constraint/Response/ContentType.php b/src/TestSuite/Constraint/Response/ContentType.php new file mode 100644 index 00000000000..3d9e91214eb --- /dev/null +++ b/src/TestSuite/Constraint/Response/ContentType.php @@ -0,0 +1,58 @@ +response->getType(); + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return 'is set as the Content-Type (`' . $this->response->getType() . '`)'; + } +} diff --git a/src/TestSuite/Constraint/Response/CookieEncryptedEquals.php b/src/TestSuite/Constraint/Response/CookieEncryptedEquals.php new file mode 100644 index 00000000000..13e74703521 --- /dev/null +++ b/src/TestSuite/Constraint/Response/CookieEncryptedEquals.php @@ -0,0 +1,94 @@ +key = $key; + $this->mode = $mode; + } + + /** + * Checks assertion + * + * @param mixed $other Expected content + * @return bool + */ + public function matches(mixed $other): bool + { + $cookie = $this->response->getCookie($this->cookieName); + + return $cookie !== null && $this->_decrypt($cookie['value'], $this->mode) === $other; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return sprintf("is encrypted in cookie '%s'", $this->cookieName); + } + + /** + * Returns the encryption key + * + * @return string + */ + protected function _getCookieEncryptionKey(): string + { + return $this->key; + } +} diff --git a/src/TestSuite/Constraint/Response/CookieEquals.php b/src/TestSuite/Constraint/Response/CookieEquals.php new file mode 100644 index 00000000000..67f5d7653a6 --- /dev/null +++ b/src/TestSuite/Constraint/Response/CookieEquals.php @@ -0,0 +1,73 @@ +cookieName = $cookieName; + } + + /** + * Checks assertion + * + * @param mixed $other Expected content + * @return bool + */ + public function matches(mixed $other): bool + { + $cookie = $this->readCookie($this->cookieName); + + return $cookie !== null && $cookie['value'] === $other; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return sprintf("is in cookie '%s'", $this->cookieName); + } +} diff --git a/src/TestSuite/Constraint/Response/CookieNotSet.php b/src/TestSuite/Constraint/Response/CookieNotSet.php new file mode 100644 index 00000000000..b3004377208 --- /dev/null +++ b/src/TestSuite/Constraint/Response/CookieNotSet.php @@ -0,0 +1,45 @@ +readCookie($other); + + return $cookie !== null && $cookie['value'] !== ''; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return 'cookie is set'; + } +} diff --git a/src/TestSuite/Constraint/Response/FileSent.php b/src/TestSuite/Constraint/Response/FileSent.php new file mode 100644 index 00000000000..9fa21d37c6f --- /dev/null +++ b/src/TestSuite/Constraint/Response/FileSent.php @@ -0,0 +1,63 @@ +response->getFile() !== null; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return 'file was sent'; + } + + /** + * Overwrites the descriptions so we can remove the automatic "expected" message + * + * @param mixed $other Value + * @return string + */ + protected function failureDescription(mixed $other): string + { + return $this->toString(); + } +} diff --git a/src/TestSuite/Constraint/Response/FileSentAs.php b/src/TestSuite/Constraint/Response/FileSentAs.php new file mode 100644 index 00000000000..1d9e5eec88f --- /dev/null +++ b/src/TestSuite/Constraint/Response/FileSentAs.php @@ -0,0 +1,57 @@ +response->getFile(); + if (!$file) { + return false; + } + + return $file->getPathName() === $other; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return 'file was sent'; + } +} diff --git a/src/TestSuite/Constraint/Response/HeaderContains.php b/src/TestSuite/Constraint/Response/HeaderContains.php new file mode 100644 index 00000000000..eee9edf4d49 --- /dev/null +++ b/src/TestSuite/Constraint/Response/HeaderContains.php @@ -0,0 +1,49 @@ +response->getHeaderLine($this->headerName), $other) !== false; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return sprintf( + "is in header '%s' (`%s`)", + $this->headerName, + $this->response->getHeaderLine($this->headerName), + ); + } +} diff --git a/src/TestSuite/Constraint/Response/HeaderEquals.php b/src/TestSuite/Constraint/Response/HeaderEquals.php new file mode 100644 index 00000000000..53410bc0397 --- /dev/null +++ b/src/TestSuite/Constraint/Response/HeaderEquals.php @@ -0,0 +1,67 @@ +headerName = $headerName; + } + + /** + * Checks assertion + * + * @param mixed $other Expected content + * @return bool + */ + public function matches(mixed $other): bool + { + return $this->response->getHeaderLine($this->headerName) === $other; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + $responseHeader = $this->response->getHeaderLine($this->headerName); + + return sprintf("equals content in header '%s' (`%s`)", $this->headerName, $responseHeader); + } +} diff --git a/src/TestSuite/Constraint/Response/HeaderNotContains.php b/src/TestSuite/Constraint/Response/HeaderNotContains.php new file mode 100644 index 00000000000..4b1ab9cc93a --- /dev/null +++ b/src/TestSuite/Constraint/Response/HeaderNotContains.php @@ -0,0 +1,49 @@ +headerName, + $this->response->getHeaderLine($this->headerName), + ); + } +} diff --git a/src/TestSuite/Constraint/Response/HeaderNotSet.php b/src/TestSuite/Constraint/Response/HeaderNotSet.php new file mode 100644 index 00000000000..e11ee4b2acb --- /dev/null +++ b/src/TestSuite/Constraint/Response/HeaderNotSet.php @@ -0,0 +1,45 @@ +headerName); + } +} diff --git a/src/TestSuite/Constraint/Response/HeaderSet.php b/src/TestSuite/Constraint/Response/HeaderSet.php new file mode 100644 index 00000000000..2989038e1b0 --- /dev/null +++ b/src/TestSuite/Constraint/Response/HeaderSet.php @@ -0,0 +1,76 @@ +headerName = $headerName; + } + + /** + * Checks assertion + * + * @param mixed $other Expected content + * @return bool + */ + public function matches(mixed $other): bool + { + return $this->response->hasHeader($this->headerName); + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return sprintf("response has header '%s'", $this->headerName); + } + + /** + * Overwrites the descriptions so we can remove the automatic "expected" message + * + * @param mixed $other Value + * @return string + */ + protected function failureDescription(mixed $other): string + { + return $this->toString(); + } +} diff --git a/src/TestSuite/Constraint/Response/ResponseBase.php b/src/TestSuite/Constraint/Response/ResponseBase.php new file mode 100644 index 00000000000..7c06e6d03ec --- /dev/null +++ b/src/TestSuite/Constraint/Response/ResponseBase.php @@ -0,0 +1,78 @@ +response = $response; + } + + /** + * Get the response body as string + * + * @return string The response body. + */ + protected function _getBodyAsString(): string + { + return (string)$this->response->getBody(); + } + + /** + * Read a cookie from either the response cookie collection, + * or headers + * + * @param string $name The name of the cookie you want to read. + * @return array|null Null if the cookie does not exist, array with `value` as the only key. + */ + protected function readCookie(string $name): ?array + { + if (method_exists($this->response, 'getCookie')) { + return $this->response->getCookie($name); + } + $cookies = CookieCollection::createFromHeader($this->response->getHeader('Set-Cookie')); + if (!$cookies->has($name)) { + return null; + } + + return $cookies->get($name)->toArray(); + } +} diff --git a/src/TestSuite/Constraint/Response/StatusCode.php b/src/TestSuite/Constraint/Response/StatusCode.php new file mode 100644 index 00000000000..ad033df9dba --- /dev/null +++ b/src/TestSuite/Constraint/Response/StatusCode.php @@ -0,0 +1,45 @@ +response->getStatusCode()); + } + + /** + * Failure description + * + * @param mixed $other Expected code + * @return string + */ + public function failureDescription(mixed $other): string + { + return '`' . $other . '` ' . $this->toString(); + } +} diff --git a/src/TestSuite/Constraint/Response/StatusCodeBase.php b/src/TestSuite/Constraint/Response/StatusCodeBase.php new file mode 100644 index 00000000000..bd50177bd93 --- /dev/null +++ b/src/TestSuite/Constraint/Response/StatusCodeBase.php @@ -0,0 +1,71 @@ +|int + */ + protected array|int $code; + + /** + * Check assertion + * + * @param array|int $other Array of min/max status codes, or a single code + * @return bool + */ + public function matches(mixed $other): bool + { + if (!$other) { + $other = $this->code; + } + + if (is_array($other)) { + return $this->statusCodeBetween($other[0], $other[1]); + } + + return $this->response->getStatusCode() === $other; + } + + /** + * Helper for checking status codes + * + * @param int $min Min status code (inclusive) + * @param int $max Max status code (inclusive) + * @return bool + */ + protected function statusCodeBetween(int $min, int $max): bool + { + return $this->response->getStatusCode() >= $min && $this->response->getStatusCode() <= $max; + } + + /** + * Overwrites the descriptions so we can remove the automatic "expected" message + * + * @param mixed $other Value + * @return string + */ + protected function failureDescription(mixed $other): string + { + return $this->toString(); + } +} diff --git a/src/TestSuite/Constraint/Response/StatusError.php b/src/TestSuite/Constraint/Response/StatusError.php new file mode 100644 index 00000000000..3722e8b67c6 --- /dev/null +++ b/src/TestSuite/Constraint/Response/StatusError.php @@ -0,0 +1,39 @@ +|int + */ + protected array|int $code = [400, 429]; + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return sprintf('%d is between 400 and 429', $this->response->getStatusCode()); + } +} diff --git a/src/TestSuite/Constraint/Response/StatusFailure.php b/src/TestSuite/Constraint/Response/StatusFailure.php new file mode 100644 index 00000000000..bcc9e9cb5f6 --- /dev/null +++ b/src/TestSuite/Constraint/Response/StatusFailure.php @@ -0,0 +1,39 @@ +|int + */ + protected array|int $code = [500, 505]; + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return sprintf('%d is between 500 and 505', $this->response->getStatusCode()); + } +} diff --git a/src/TestSuite/Constraint/Response/StatusOk.php b/src/TestSuite/Constraint/Response/StatusOk.php new file mode 100644 index 00000000000..d01bfdc19e2 --- /dev/null +++ b/src/TestSuite/Constraint/Response/StatusOk.php @@ -0,0 +1,39 @@ +|int + */ + protected array|int $code = [200, 204]; + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return sprintf('%d is between 200 and 204', $this->response->getStatusCode()); + } +} diff --git a/src/TestSuite/Constraint/Response/StatusSuccess.php b/src/TestSuite/Constraint/Response/StatusSuccess.php new file mode 100644 index 00000000000..0305ac2cf84 --- /dev/null +++ b/src/TestSuite/Constraint/Response/StatusSuccess.php @@ -0,0 +1,39 @@ +|int + */ + protected array|int $code = [200, 308]; + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return sprintf('%d is between 200 and 308', $this->response->getStatusCode()); + } +} diff --git a/src/TestSuite/Constraint/Session/FlashParamContains.php b/src/TestSuite/Constraint/Session/FlashParamContains.php new file mode 100644 index 00000000000..39f815a1fec --- /dev/null +++ b/src/TestSuite/Constraint/Session/FlashParamContains.php @@ -0,0 +1,130 @@ +enableRetainFlashMessages()` has been enabled for the test.'; + throw new AssertionFailedError($message); + } + + $this->session = $session; + $this->key = $key; + $this->param = $param; + $this->at = $at; + $this->ignoreCase = $ignoreCase; + } + + /** + * Compare to flash message(s) using contains logic + * + * @param mixed $other Value to compare with + * @return bool + */ + public function matches(mixed $other): bool + { + // Server::run calls Session::close at the end of the request. + // Which means, that we cannot use Session object here to access the session data. + // Call to Session::read will start new session (and will erase the data). + $messages = (array)Hash::get($_SESSION, 'Flash.' . $this->key); + if ($this->at !== null) { + $messages = [Hash::get($_SESSION, 'Flash.' . $this->key . '.' . $this->at)]; + } + + $method = 'mb_strpos'; + if ($this->ignoreCase) { + $method = 'mb_stripos'; + } + + foreach ($messages as $message) { + if (!isset($message[$this->param])) { + continue; + } + if ($method($message[$this->param], $other) !== false) { + return true; + } + } + + return false; + } + + /** + * Assertion message string + * + * @return string + */ + public function toString(): string + { + if ($this->at !== null) { + return sprintf("contains in '%s' %s #%d", $this->key, $this->param, $this->at); + } + + return sprintf("contains in '%s' %s", $this->key, $this->param); + } +} diff --git a/src/TestSuite/Constraint/Session/FlashParamEquals.php b/src/TestSuite/Constraint/Session/FlashParamEquals.php new file mode 100644 index 00000000000..2e89dfcff9b --- /dev/null +++ b/src/TestSuite/Constraint/Session/FlashParamEquals.php @@ -0,0 +1,113 @@ +enableRetainFlashMessages()` has been enabled for the test.'; + throw new AssertionFailedError($message); + } + + $this->session = $session; + $this->key = $key; + $this->param = $param; + $this->at = $at; + } + + /** + * Compare to flash message(s) + * + * @param mixed $other Value to compare with + * @return bool + */ + public function matches(mixed $other): bool + { + // Server::run calls Session::close at the end of the request. + // Which means, that we cannot use Session object here to access the session data. + // Call to Session::read will start new session (and will erase the data). + $messages = (array)Hash::get($_SESSION, 'Flash.' . $this->key); + if ($this->at) { + $messages = [Hash::get($_SESSION, 'Flash.' . $this->key . '.' . $this->at)]; + } + + foreach ($messages as $message) { + if (!isset($message[$this->param])) { + continue; + } + if ($message[$this->param] === $other) { + return true; + } + } + + return false; + } + + /** + * Assertion message string + * + * @return string + */ + public function toString(): string + { + if ($this->at !== null) { + return sprintf("is in '%s' %s #%d", $this->key, $this->param, $this->at); + } + + return sprintf("is in '%s' %s", $this->key, $this->param); + } +} diff --git a/src/TestSuite/Constraint/Session/SessionEquals.php b/src/TestSuite/Constraint/Session/SessionEquals.php new file mode 100644 index 00000000000..eb4ddbf364d --- /dev/null +++ b/src/TestSuite/Constraint/Session/SessionEquals.php @@ -0,0 +1,66 @@ +path = $path; + } + + /** + * Compare session value + * + * @param mixed $other Value to compare with + * @return bool + */ + public function matches(mixed $other): bool + { + // Server::run calls Session::close at the end of the request. + // Which means, that we cannot use Session object here to access the session data. + // Call to Session::read will start new session (and will erase the data). + return Hash::get($_SESSION, $this->path) === $other; + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return sprintf("is in session path '%s'", $this->path); + } +} diff --git a/src/TestSuite/Constraint/Session/SessionHasKey.php b/src/TestSuite/Constraint/Session/SessionHasKey.php new file mode 100644 index 00000000000..7b81d049f47 --- /dev/null +++ b/src/TestSuite/Constraint/Session/SessionHasKey.php @@ -0,0 +1,66 @@ +path = $path; + } + + /** + * Compare session value + * + * @param mixed $other Value to compare with + * @return bool + */ + public function matches(mixed $other): bool + { + // Server::run calls Session::close at the end of the request. + // Which means, that we cannot use Session object here to access the session data. + // Call to Session::read will start new session (and will erase the data). + return Hash::check($_SESSION, $this->path); + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return 'is a path present in the session'; + } +} diff --git a/src/TestSuite/Constraint/View/LayoutFileEquals.php b/src/TestSuite/Constraint/View/LayoutFileEquals.php new file mode 100644 index 00000000000..662bb24aaba --- /dev/null +++ b/src/TestSuite/Constraint/View/LayoutFileEquals.php @@ -0,0 +1,34 @@ +filename); + } +} diff --git a/src/TestSuite/Constraint/View/TemplateFileEquals.php b/src/TestSuite/Constraint/View/TemplateFileEquals.php new file mode 100644 index 00000000000..c71f1eea0ee --- /dev/null +++ b/src/TestSuite/Constraint/View/TemplateFileEquals.php @@ -0,0 +1,62 @@ +filename = $filename; + } + + /** + * Checks assertion + * + * @param mixed $other Expected filename + * @return bool + */ + public function matches(mixed $other): bool + { + return str_contains($this->filename, $other); + } + + /** + * Assertion message + * + * @return string + */ + public function toString(): string + { + return sprintf('equals template file `%s`', $this->filename); + } +} diff --git a/src/TestSuite/EmailAssertTrait.php b/src/TestSuite/EmailAssertTrait.php deleted file mode 100644 index d0353a788b5..00000000000 --- a/src/TestSuite/EmailAssertTrait.php +++ /dev/null @@ -1,292 +0,0 @@ -email(true)->send($content); - } - - /** - * Creates an email instance overriding its transport for testing purposes. - * - * @param bool $new Tells if new instance should forcibly be created. - * @return \Cake\Mailer\Email - */ - public function email($new = false) - { - if ($new || !$this->_email) { - $this->_email = new Email(); - $this->_email->setProfile(['transport' => 'debug'] + $this->_email->getProfile()); - } - - return $this->_email; - } - - /** - * Generates mock for given mailer class. - * - * @param string $className The mailer's FQCN. - * @param array $methods The methods to mock on the mailer. - * @return \Cake\Mailer\Mailer|\PHPUnit_Framework_MockObject_MockObject - */ - public function getMockForMailer($className, array $methods = []) - { - $name = current(array_slice(explode('\\', $className), -1)); - - if (!in_array('profile', $methods)) { - $methods[] = 'profile'; - } - - $mailer = $this->getMockBuilder($className) - ->setMockClassName($name) - ->setMethods($methods) - ->setConstructorArgs([$this->email()]) - ->getMock(); - - $mailer->expects($this->any()) - ->method('profile') - ->willReturn($mailer); - - return $mailer; - } - - /** - * Asserts email content (both text and HTML) contains `$needle`. - * - * @param string $needle Text to look for. - * @param string|null $message The failure message to define. - * @return void - */ - public function assertEmailMessageContains($needle, $message = null) - { - $this->assertEmailHtmlMessageContains($needle, $message); - $this->assertEmailTextMessageContains($needle, $message); - } - - /** - * Asserts HTML email content contains `$needle`. - * - * @param string $needle Text to look for. - * @param string|null $message The failure message to define. - * @return void - */ - public function assertEmailHtmlMessageContains($needle, $message = null) - { - $haystack = $this->email()->message('html'); - $this->assertTextContains($needle, $haystack, $message); - } - - /** - * Asserts text email content contains `$needle`. - * - * @param string $needle Text to look for. - * @param string|null $message The failure message to define. - * @return void - */ - public function assertEmailTextMessageContains($needle, $message = null) - { - $haystack = $this->email()->message('text'); - $this->assertTextContains($needle, $haystack, $message); - } - - /** - * Asserts email's subject contains `$expected`. - * - * @param string $expected Email's subject. - * @param string|null $message The failure message to define. - * @return void - */ - public function assertEmailSubject($expected, $message = null) - { - $result = $this->email()->getSubject(); - $this->assertSame($expected, $result, $message); - } - - /** - * Asserts email's sender email address and optionally name. - * - * @param string $email Sender's email address. - * @param string|null $name Sender's name. - * @param string|null $message The failure message to define. - * @return void - */ - public function assertEmailFrom($email, $name = null, $message = null) - { - if ($name === null) { - $name = $email; - } - - $expected = [$email => $name]; - $result = $this->email()->getFrom(); - $this->assertSame($expected, $result, $message); - } - - /** - * Asserts email is CC'd to only one email address (and optionally name). - * - * @param string $email CC'd email address. - * @param string|null $name CC'd person name. - * @param string|null $message The failure message to define. - * @return void - */ - public function assertEmailCc($email, $name = null, $message = null) - { - if ($name === null) { - $name = $email; - } - - $expected = [$email => $name]; - $result = $this->email()->getCc(); - $this->assertSame($expected, $result, $message); - } - - /** - * Asserts email CC'd addresses contain given email address (and - * optionally name). - * - * @param string $email CC'd email address. - * @param string|null $name CC'd person name. - * @param string|null $message The failure message to define. - * @return void - */ - public function assertEmailCcContains($email, $name = null, $message = null) - { - $result = $this->email()->getCc(); - $this->assertNotEmpty($result[$email], $message); - if ($name !== null) { - $this->assertEquals($result[$email], $name, $message); - } - } - - /** - * Asserts email is BCC'd to only one email address (and optionally name). - * - * @param string $email BCC'd email address. - * @param string|null $name BCC'd person name. - * @param string|null $message The failure message to define. - * @return void - */ - public function assertEmailBcc($email, $name = null, $message = null) - { - if ($name === null) { - $name = $email; - } - - $expected = [$email => $name]; - $result = $this->email()->getBcc(); - $this->assertSame($expected, $result, $message); - } - - /** - * Asserts email BCC'd addresses contain given email address (and - * optionally name). - * - * @param string $email BCC'd email address. - * @param string|null $name BCC'd person name. - * @param string|null $message The failure message to define. - * @return void - */ - public function assertEmailBccContains($email, $name = null, $message = null) - { - $result = $this->email()->getBcc(); - $this->assertNotEmpty($result[$email], $message); - if ($name !== null) { - $this->assertEquals($result[$email], $name, $message); - } - } - - /** - * Asserts email is sent to only the given recipient's address (and - * optionally name). - * - * @param string $email Recipient's email address. - * @param string|null $name Recipient's name. - * @param string|null $message The failure message to define. - * @return void - */ - public function assertEmailTo($email, $name = null, $message = null) - { - if ($name === null) { - $name = $email; - } - - $expected = [$email => $name]; - $result = $this->email()->getTo(); - $this->assertSame($expected, $result, $message); - } - - /** - * Asserts email recipients' list contains given email address (and - * optionally name). - * - * @param string $email Recipient's email address. - * @param string|null $name Recipient's name. - * @param string|null $message The failure message to define. - * @return void - */ - public function assertEmailToContains($email, $name = null, $message = null) - { - $result = $this->email()->getTo(); - $this->assertNotEmpty($result[$email], $message); - if ($name !== null) { - $this->assertEquals($result[$email], $name, $message); - } - } - - /** - * Asserts the email attachments contain the given filename (and optionally - * file info). - * - * @param string $filename Expected attachment's filename. - * @param array|null $file Expected attachment's file info. - * @param string|null $message The failure message to define. - * @return void - */ - public function assertEmailAttachmentsContains($filename, array $file = null, $message = null) - { - $result = $this->email()->getAttachments(); - $this->assertNotEmpty($result[$filename], $message); - if ($file === null) { - return; - } - $this->assertContains($file, $result, $message); - $this->assertEquals($file, $result[$filename], $message); - } -} diff --git a/src/TestSuite/EmailTrait.php b/src/TestSuite/EmailTrait.php new file mode 100644 index 00000000000..1aaecaa650c --- /dev/null +++ b/src/TestSuite/EmailTrait.php @@ -0,0 +1,275 @@ +assertThat($count, new MailCount(), $message); + } + + /** + * Asserts that no emails were sent + * + * @param string $message Message + * @return void + */ + public function assertNoMailSent(string $message = ''): void + { + $this->assertThat(null, new NoMailSent(), $message); + } + + /** + * Asserts an email at a specific index was sent to an address + * + * @param int $at Email index + * @param string $address Email address + * @param string $message Message + * @return void + */ + public function assertMailSentToAt(int $at, string $address, string $message = ''): void + { + $this->assertThat($address, new MailSentTo($at), $message); + } + + /** + * Asserts an email at a specific index was sent from an address + * + * @param int $at Email index + * @param string $address Email address + * @param string $message Message + * @return void + */ + public function assertMailSentFromAt(int $at, string $address, string $message = ''): void + { + $this->assertThat($address, new MailSentFrom($at), $message); + } + + /** + * Asserts an email at a specific index contains expected contents + * + * @param int $at Email index + * @param string $contents Contents + * @param string $message Message + * @return void + */ + public function assertMailContainsAt(int $at, string $contents, string $message = ''): void + { + $this->assertThat($contents, new MailContains($at), $message); + } + + /** + * Asserts an email at a specific index contains expected html contents + * + * @param int $at Email index + * @param string $contents Contents + * @param string $message Message + * @return void + */ + public function assertMailContainsHtmlAt(int $at, string $contents, string $message = ''): void + { + $this->assertThat($contents, new MailContainsHtml($at), $message); + } + + /** + * Asserts an email at a specific index contains expected text contents + * + * @param int $at Email index + * @param string $contents Contents + * @param string $message Message + * @return void + */ + public function assertMailContainsTextAt(int $at, string $contents, string $message = ''): void + { + $this->assertThat($contents, new MailContainsText($at), $message); + } + + /** + * Asserts an email at a specific index contains the expected value within an Email getter + * + * @param int $at Email index + * @param string $expected Contents + * @param string $parameter Email getter parameter (e.g. "cc", "bcc") + * @param string $message Message + * @return void + */ + public function assertMailSentWithAt(int $at, string $expected, string $parameter, string $message = ''): void + { + $this->assertThat($expected, new MailSentWith($at, $parameter), $message); + } + + /** + * Asserts an email was sent to an address + * + * @param string $address Email address + * @param string $message Message + * @return void + */ + public function assertMailSentTo(string $address, string $message = ''): void + { + $this->assertThat($address, new MailSentTo(), $message); + } + + /** + * Asserts an email was sent from an address + * + * @param array|string $address Email address + * @param string $message Message + * @return void + */ + public function assertMailSentFrom(array|string $address, string $message = ''): void + { + $this->assertThat($address, new MailSentFrom(), $message); + } + + /** + * Asserts an email contains expected contents + * + * @param string $contents Contents + * @param string $message Message + * @return void + */ + public function assertMailContains(string $contents, string $message = ''): void + { + $this->assertThat($contents, new MailContains(), $message); + } + + /** + * Asserts an email contains expected attachment + * + * @param string $filename Filename + * @param array $file Additional file properties + * @param string $message Message + * @return void + */ + public function assertMailContainsAttachment(string $filename, array $file = [], string $message = ''): void + { + $this->assertThat([$filename, $file], new MailContainsAttachment(), $message); + } + + /** + * Asserts an email contains expected html contents + * + * @param string $contents Contents + * @param string $message Message + * @return void + */ + public function assertMailContainsHtml(string $contents, string $message = ''): void + { + $this->assertThat($contents, new MailContainsHtml(), $message); + } + + /** + * Asserts an email contains an expected text content + * + * @param string $expected Expected text. + * @param string $message Message to display if assertion fails. + * @return void + */ + public function assertMailContainsText(string $expected, string $message = ''): void + { + $this->assertThat($expected, new MailContainsText(), $message); + } + + /** + * Asserts an email contains the expected value within an Email getter + * + * @param string $expected Contents + * @param string $parameter Email getter parameter (e.g. "cc", "subject") + * @param string $message Message + * @return void + */ + public function assertMailSentWith(string $expected, string $parameter, string $message = ''): void + { + $this->assertThat($expected, new MailSentWith(null, $parameter), $message); + } + + /** + * Asserts an email subject contains expected contents + * + * @param string $contents Contents + * @param string $message Message + * @return void + */ + public function assertMailSubjectContains(string $contents, string $message = ''): void + { + $this->assertThat($contents, new MailSubjectContains(), $message); + } + + /** + * Asserts an email at a specific index contains expected html contents + * + * @param int $at Email index + * @param string $contents Contents + * @param string $message Message + * @return void + */ + public function assertMailSubjectContainsAt(int $at, string $contents, string $message = ''): void + { + $this->assertThat($contents, new MailSubjectContains($at), $message); + } +} diff --git a/src/TestSuite/Fixture/Extension/PHPUnitExtension.php b/src/TestSuite/Fixture/Extension/PHPUnitExtension.php new file mode 100644 index 00000000000..df79cfddd63 --- /dev/null +++ b/src/TestSuite/Fixture/Extension/PHPUnitExtension.php @@ -0,0 +1,41 @@ +registerSubscriber( + new PHPUnitStartedSubscriber(), + ); + } +} diff --git a/src/TestSuite/Fixture/Extension/PHPUnitStartedSubscriber.php b/src/TestSuite/Fixture/Extension/PHPUnitStartedSubscriber.php new file mode 100644 index 00000000000..a3734d5feef --- /dev/null +++ b/src/TestSuite/Fixture/Extension/PHPUnitStartedSubscriber.php @@ -0,0 +1,48 @@ + 'Console', + 'stream' => 'php://stderr', + 'scopes' => ['cake.database.queries'], + ]); + } + } +} diff --git a/src/TestSuite/Fixture/FixtureHelper.php b/src/TestSuite/Fixture/FixtureHelper.php new file mode 100644 index 00000000000..597f8f63fdb --- /dev/null +++ b/src/TestSuite/Fixture/FixtureHelper.php @@ -0,0 +1,292 @@ + $fixtureNames Fixture names from test case + * @return array<\Cake\Datasource\FixtureInterface> + */ + public function loadFixtures(array $fixtureNames): array + { + static $cachedFixtures = []; + + $fixtures = []; + foreach ($fixtureNames as $fixtureName) { + if (str_contains($fixtureName, '.')) { + [$type, $pathName] = explode('.', $fixtureName, 2); + $path = explode('/', $pathName); + $name = array_pop($path); + $additionalPath = implode('\\', $path); + + if ($type === 'core') { + $baseNamespace = 'Cake'; + } elseif ($type === 'app') { + $baseNamespace = Configure::read('App.namespace'); + } elseif ($type === 'plugin') { + [$plugin, $name] = explode('.', $pathName); + $baseNamespace = str_replace('/', '\\', $plugin); + $additionalPath = null; + } else { + $baseNamespace = ''; + $name = $fixtureName; + } + + if (strpos($name, '/') > 0) { + $name = str_replace('/', '\\', $name); + } + + $nameSegments = [ + $baseNamespace, + 'Test\Fixture', + $additionalPath, + $name . 'Fixture', + ]; + /** @var class-string<\Cake\Datasource\FixtureInterface> $className */ + $className = implode('\\', array_filter($nameSegments)); + } else { + /** @var class-string<\Cake\Datasource\FixtureInterface> $className */ + $className = $fixtureName; + } + + if (isset($fixtures[$className])) { + throw new UnexpectedValueException(sprintf('Found duplicate fixture `%s`.', $fixtureName)); + } + + if (!class_exists($className)) { + throw new UnexpectedValueException(sprintf('Could not find fixture `%s`.', $fixtureName)); + } + + $cachedFixtures[$className] ??= new $className(); + $fixtures[$className] = $cachedFixtures[$className]; + } + + return $fixtures; + } + + /** + * Runs the callback once per connection. + * + * The callback signature: + * ``` + * function callback(ConnectionInterface $connection, array $fixtures) + * ``` + * + * @param \Closure $callback Callback run per connection + * @param array<\Cake\Datasource\FixtureInterface> $fixtures Test fixtures + * @return void + */ + public function runPerConnection(Closure $callback, array $fixtures): void + { + $groups = []; + foreach ($fixtures as $fixture) { + $groups[$fixture->connection()][] = $fixture; + } + + foreach ($groups as $connectionName => $fixtures) { + $callback(ConnectionManager::get($connectionName), $fixtures); + } + } + + /** + * Inserts fixture data. + * + * @param array<\Cake\Datasource\FixtureInterface> $fixtures Test fixtures + * @return void + * @internal + */ + public function insert(array $fixtures): void + { + $this->runPerConnection(function (ConnectionInterface $connection, array $groupFixtures): void { + if ($connection instanceof Connection) { + $sortedFixtures = $this->sortByConstraint($connection, $groupFixtures); + if ($sortedFixtures) { + $this->insertConnection($connection, $sortedFixtures); + } else { + ConnectionHelper::runWithoutConstraints( + $connection, + fn(Connection $connection) => $this->insertConnection($connection, $groupFixtures), + ); + } + } else { + $this->insertConnection($connection, $groupFixtures); + } + }, $fixtures); + } + + /** + * Inserts all fixtures for a connection and provides friendly errors for bad data. + * + * @param \Cake\Datasource\ConnectionInterface $connection Fixture connection + * @param array<\Cake\Datasource\FixtureInterface> $fixtures Connection fixtures + * @return void + */ + protected function insertConnection(ConnectionInterface $connection, array $fixtures): void + { + foreach ($fixtures as $fixture) { + try { + $fixture->insert($connection); + } catch (PDOException $exception) { + $message = sprintf( + 'Unable to insert rows for table `%s`.' + . " Fixture records might have invalid data or unknown constraints.\n%s", + $fixture->sourceName(), + $exception->getMessage(), + ); + throw new CakeException($message); + } + } + } + + /** + * Truncates fixture tables. + * + * @param array<\Cake\Datasource\FixtureInterface> $fixtures Test fixtures + * @return void + * @internal + */ + public function truncate(array $fixtures): void + { + $this->runPerConnection(function (ConnectionInterface $connection, array $groupFixtures): void { + if ($connection instanceof Connection) { + $sortedFixtures = null; + if ($connection->getWriteDriver()->supports(DriverFeatureEnum::TRUNCATE_WITH_CONSTRAINTS)) { + $sortedFixtures = $this->sortByConstraint($connection, $groupFixtures); + } + + if ($sortedFixtures !== null) { + $this->truncateConnection($connection, array_reverse($sortedFixtures)); + } else { + $helper = new ConnectionHelper(); + $helper->runWithoutConstraints( + $connection, + fn(Connection $connection) => $this->truncateConnection($connection, $groupFixtures), + ); + } + } else { + $this->truncateConnection($connection, $groupFixtures); + } + }, $fixtures); + } + + /** + * Truncates all fixtures for a connection and provides friendly errors for bad data. + * + * @param \Cake\Datasource\ConnectionInterface $connection Fixture connection + * @param array<\Cake\Datasource\FixtureInterface> $fixtures Connection fixtures + * @return void + */ + protected function truncateConnection(ConnectionInterface $connection, array $fixtures): void + { + foreach ($fixtures as $fixture) { + try { + $fixture->truncate($connection); + } catch (PDOException $exception) { + $message = sprintf( + 'Unable to truncate table `%s`.' + . " Fixture records might have invalid data or unknown constraints.\n%s", + $fixture->sourceName(), + $exception->getMessage(), + ); + throw new CakeException($message); + } + } + } + + /** + * Sort fixtures with foreign constraints last if possible, otherwise returns null. + * + * @param \Cake\Database\Connection $connection Database connection + * @param array<\Cake\Datasource\FixtureInterface> $fixtures Database fixtures + * @return array|null + */ + protected function sortByConstraint(Connection $connection, array $fixtures): ?array + { + $constrained = []; + $unconstrained = []; + foreach ($fixtures as $fixture) { + $references = $this->getForeignReferences($connection, $fixture); + if ($references) { + $constrained[$fixture->sourceName()] = ['references' => $references, 'fixture' => $fixture]; + } else { + $unconstrained[] = $fixture; + } + } + + // Check if any fixtures reference another fixture with constraints + // If they do, then there might be cross-dependencies which we don't support sorting + foreach ($constrained as ['references' => $references]) { + foreach ($references as $reference) { + if (isset($constrained[$reference])) { + return null; + } + } + } + + return array_merge($unconstrained, array_column($constrained, 'fixture')); + } + + /** + * Gets array of foreign references for fixtures table. + * + * @param \Cake\Database\Connection $connection Database connection + * @param \Cake\Datasource\FixtureInterface $fixture Database fixture + * @return array + */ + protected function getForeignReferences(Connection $connection, FixtureInterface $fixture): array + { + /** @var array $schemas */ + static $schemas = []; + + // Get and cache off the schema since TestFixture generates a fake schema based on $fields + $tableName = $fixture->sourceName(); + if (!isset($schemas[$tableName])) { + $schemas[$tableName] = $connection->getSchemaCollection()->describe($tableName); + } + $schema = $schemas[$tableName]; + + $references = []; + foreach ($schema->constraints() as $constraintName) { + $constraint = $schema->getConstraint((string)$constraintName); + + if ($constraint && $constraint['type'] === TableSchema::CONSTRAINT_FOREIGN) { + $references[] = $constraint['references'][0]; + } + } + + return $references; + } +} diff --git a/src/TestSuite/Fixture/FixtureInjector.php b/src/TestSuite/Fixture/FixtureInjector.php deleted file mode 100644 index 74edb48a30e..00000000000 --- a/src/TestSuite/Fixture/FixtureInjector.php +++ /dev/null @@ -1,115 +0,0 @@ -setDebug(in_array('--debug', $_SERVER['argv'])); - } - $this->_fixtureManager = $manager; - $this->_fixtureManager->shutDown(); - } - - /** - * Iterates the tests inside a test suite and creates the required fixtures as - * they were expressed inside each test case. - * - * @param \PHPUnit\Framework\TestSuite $suite The test suite - * @return void - */ - public function startTestSuite(TestSuite $suite) - { - if (empty($this->_first)) { - $this->_first = $suite; - } - } - - /** - * Destroys the fixtures created by the fixture manager at the end of the test - * suite run - * - * @param \PHPUnit\Framework\TestSuite $suite The test suite - * @return void - */ - public function endTestSuite(TestSuite $suite) - { - if ($this->_first === $suite) { - $this->_fixtureManager->shutDown(); - } - } - - /** - * Adds fixtures to a test case when it starts. - * - * @param \PHPUnit\Framework\Test $test The test case - * @return void - */ - public function startTest(Test $test) - { - $test->fixtureManager = $this->_fixtureManager; - if ($test instanceof TestCase) { - $this->_fixtureManager->fixturize($test); - $this->_fixtureManager->load($test); - } - } - - /** - * Unloads fixtures from the test case. - * - * @param \PHPUnit\Framework\Test $test The test case - * @param float $time current time - * @return void - */ - public function endTest(Test $test, $time) - { - if ($test instanceof TestCase) { - $this->_fixtureManager->unload($test); - } - } -} diff --git a/src/TestSuite/Fixture/FixtureManager.php b/src/TestSuite/Fixture/FixtureManager.php deleted file mode 100644 index 61836d793bd..00000000000 --- a/src/TestSuite/Fixture/FixtureManager.php +++ /dev/null @@ -1,502 +0,0 @@ -_debug = $debug; - } - - /** - * Inspects the test to look for unloaded fixtures and loads them - * - * @param \Cake\TestSuite\TestCase $test The test case to inspect. - * @return void - */ - public function fixturize($test) - { - $this->_initDb(); - if (empty($test->fixtures) || !empty($this->_processed[get_class($test)])) { - return; - } - if (!is_array($test->fixtures)) { - $test->fixtures = array_map('trim', explode(',', $test->fixtures)); - } - $this->_loadFixtures($test); - $this->_processed[get_class($test)] = true; - } - - /** - * Get the loaded fixtures. - * - * @return array - */ - public function loaded() - { - return $this->_loaded; - } - - /** - * Add aliases for all non test prefixed connections. - * - * This allows models to use the test connections without - * a pile of configuration work. - * - * @return void - */ - protected function _aliasConnections() - { - $connections = ConnectionManager::configured(); - ConnectionManager::alias('test', 'default'); - $map = []; - foreach ($connections as $connection) { - if ($connection === 'test' || $connection === 'default') { - continue; - } - if (isset($map[$connection])) { - continue; - } - if (strpos($connection, 'test_') === 0) { - $map[$connection] = substr($connection, 5); - } else { - $map['test_' . $connection] = $connection; - } - } - foreach ($map as $testConnection => $normal) { - ConnectionManager::alias($testConnection, $normal); - } - } - - /** - * Initializes this class with a DataSource object to use as default for all fixtures - * - * @return void - */ - protected function _initDb() - { - if ($this->_initialized) { - return; - } - $this->_aliasConnections(); - $this->_initialized = true; - } - - /** - * Looks for fixture files and instantiates the classes accordingly - * - * @param \Cake\TestSuite\TestCase $test The test suite to load fixtures for. - * @return void - * @throws \UnexpectedValueException when a referenced fixture does not exist. - */ - protected function _loadFixtures($test) - { - if (empty($test->fixtures)) { - return; - } - foreach ($test->fixtures as $fixture) { - if (isset($this->_loaded[$fixture])) { - continue; - } - - if (strpos($fixture, '.')) { - list($type, $pathName) = explode('.', $fixture, 2); - $path = explode('/', $pathName); - $name = array_pop($path); - $additionalPath = implode('\\', $path); - - if ($type === 'core') { - $baseNamespace = 'Cake'; - } elseif ($type === 'app') { - $baseNamespace = Configure::read('App.namespace'); - } elseif ($type === 'plugin') { - list($plugin, $name) = explode('.', $pathName); - // Flip vendored plugin separators - $path = implode('\\', explode('/', $plugin)); - $baseNamespace = Inflector::camelize(str_replace('\\', '\ ', $path)); - $additionalPath = null; - } else { - $baseNamespace = ''; - $name = $fixture; - } - - // Tweak subdirectory names, so camelize() can make the correct name - if (strpos($name, '/') > 0) { - $name = implode('\\ ', explode('/', $name)); - } - - $name = Inflector::camelize($name); - $nameSegments = [ - $baseNamespace, - 'Test\Fixture', - $additionalPath, - $name . 'Fixture' - ]; - $className = implode('\\', array_filter($nameSegments)); - } else { - $className = $fixture; - $name = preg_replace('/Fixture\z/', '', substr(strrchr($fixture, '\\'), 1)); - } - - if (class_exists($className)) { - $this->_loaded[$fixture] = new $className(); - $this->_fixtureMap[$name] = $this->_loaded[$fixture]; - } else { - $msg = sprintf( - 'Referenced fixture class "%s" not found. Fixture "%s" was referenced in test case "%s".', - $className, - $fixture, - get_class($test) - ); - throw new UnexpectedValueException($msg); - } - } - } - - /** - * Runs the drop and create commands on the fixtures if necessary. - * - * @param \Cake\Datasource\FixtureInterface $fixture the fixture object to create - * @param \Cake\Database\Connection $db The Connection object instance to use - * @param array $sources The existing tables in the datasource. - * @param bool $drop whether drop the fixture if it is already created or not - * @return void - */ - protected function _setupTable($fixture, $db, array $sources, $drop = true) - { - $configName = $db->configName(); - $isFixtureSetup = $this->isFixtureSetup($configName, $fixture); - if ($isFixtureSetup) { - return; - } - - $table = $fixture->sourceName(); - $exists = in_array($table, $sources); - - $hasSchema = $fixture instanceof TableSchemaInterface && $fixture->schema() instanceof TableSchema - || $fixture instanceof TableSchemaAwareInterface && $fixture->getTableSchema() instanceof TableSchema; - - if (($drop && $exists) || ($exists && !$isFixtureSetup && $hasSchema)) { - $fixture->drop($db); - $fixture->create($db); - } elseif (!$exists) { - $fixture->create($db); - } else { - $fixture->truncate($db); - } - - $this->_insertionMap[$configName][] = $fixture; - } - - /** - * Creates the fixtures tables and inserts data on them. - * - * @param \Cake\TestSuite\TestCase $test The test to inspect for fixture loading. - * @return void - * @throws \Cake\Core\Exception\Exception When fixture records cannot be inserted. - */ - public function load($test) - { - if (empty($test->fixtures)) { - return; - } - - $fixtures = $test->fixtures; - if (empty($fixtures) || !$test->autoFixtures) { - return; - } - - try { - $createTables = function ($db, $fixtures) use ($test) { - $tables = $db->schemaCollection()->listTables(); - $configName = $db->configName(); - if (!isset($this->_insertionMap[$configName])) { - $this->_insertionMap[$configName] = []; - } - - foreach ($fixtures as $name => $fixture) { - if (in_array($fixture->table, $tables)) { - try { - $fixture->dropConstraints($db); - } catch (PDOException $e) { - $msg = sprintf( - 'Unable to drop constraints for fixture "%s" in "%s" test case: ' . "\n" . '%s', - get_class($fixture), - get_class($test), - $e->getMessage() - ); - throw new Exception($msg); - } - } - } - - foreach ($fixtures as $fixture) { - if (!in_array($fixture, $this->_insertionMap[$configName])) { - $this->_setupTable($fixture, $db, $tables, $test->dropTables); - } else { - $fixture->truncate($db); - } - } - - foreach ($fixtures as $name => $fixture) { - try { - $fixture->createConstraints($db); - } catch (PDOException $e) { - $msg = sprintf( - 'Unable to create constraints for fixture "%s" in "%s" test case: ' . "\n" . '%s', - get_class($fixture), - get_class($test), - $e->getMessage() - ); - throw new Exception($msg); - } - } - }; - $this->_runOperation($fixtures, $createTables); - - // Use a separate transaction because of postgres. - $insert = function ($db, $fixtures) use ($test) { - foreach ($fixtures as $fixture) { - try { - $fixture->insert($db); - } catch (PDOException $e) { - $msg = sprintf( - 'Unable to insert fixture "%s" in "%s" test case: ' . "\n" . '%s', - get_class($fixture), - get_class($test), - $e->getMessage() - ); - throw new Exception($msg); - } - } - }; - $this->_runOperation($fixtures, $insert); - } catch (PDOException $e) { - $msg = sprintf( - 'Unable to insert fixtures for "%s" test case. %s', - get_class($test), - $e->getMessage() - ); - throw new Exception($msg); - } - } - - /** - * Run a function on each connection and collection of fixtures. - * - * @param array $fixtures A list of fixtures to operate on. - * @param callable $operation The operation to run on each connection + fixture set. - * @return void - */ - protected function _runOperation($fixtures, $operation) - { - $dbs = $this->_fixtureConnections($fixtures); - foreach ($dbs as $connection => $fixtures) { - $db = ConnectionManager::get($connection); - $logQueries = $db->logQueries(); - if ($logQueries && !$this->_debug) { - $db->logQueries(false); - } - $db->transactional(function ($db) use ($fixtures, $operation) { - $db->disableConstraints(function ($db) use ($fixtures, $operation) { - $operation($db, $fixtures); - }); - }); - if ($logQueries) { - $db->logQueries(true); - } - } - } - - /** - * Get the unique list of connections that a set of fixtures contains. - * - * @param array $fixtures The array of fixtures a list of connections is needed from. - * @return array An array of connection names. - */ - protected function _fixtureConnections($fixtures) - { - $dbs = []; - foreach ($fixtures as $f) { - if (!empty($this->_loaded[$f])) { - $fixture = $this->_loaded[$f]; - $dbs[$fixture->connection()][$f] = $fixture; - } - } - - return $dbs; - } - - /** - * Truncates the fixtures tables - * - * @param \Cake\TestSuite\TestCase $test The test to inspect for fixture unloading. - * @return void - */ - public function unload($test) - { - if (empty($test->fixtures)) { - return; - } - $truncate = function ($db, $fixtures) { - $configName = $db->configName(); - - foreach ($fixtures as $name => $fixture) { - if ($this->isFixtureSetup($configName, $fixture)) { - $fixture->dropConstraints($db); - } - } - - foreach ($fixtures as $fixture) { - if ($this->isFixtureSetup($configName, $fixture)) { - $fixture->truncate($db); - } - } - }; - $this->_runOperation($test->fixtures, $truncate); - } - - /** - * Creates a single fixture table and loads data into it. - * - * @param string $name of the fixture - * @param \Cake\Datasource\ConnectionInterface|null $db Connection instance or leave null to get a Connection from the fixture - * @param bool $dropTables Whether or not tables should be dropped and re-created. - * @return void - * @throws \UnexpectedValueException if $name is not a previously loaded class - */ - public function loadSingle($name, $db = null, $dropTables = true) - { - if (!isset($this->_fixtureMap[$name])) { - throw new UnexpectedValueException(sprintf('Referenced fixture class %s not found', $name)); - } - - $fixture = $this->_fixtureMap[$name]; - if (!$db) { - $db = ConnectionManager::get($fixture->connection()); - } - - if (!$this->isFixtureSetup($db->configName(), $fixture)) { - $sources = $db->schemaCollection()->listTables(); - $this->_setupTable($fixture, $db, $sources, $dropTables); - } - - if (!$dropTables) { - $fixture->dropConstraints($db); - $fixture->truncate($db); - } - - $fixture->createConstraints($db); - $fixture->insert($db); - } - - /** - * Drop all fixture tables loaded by this class - * - * @return void - */ - public function shutDown() - { - $shutdown = function ($db, $fixtures) { - $connection = $db->configName(); - foreach ($fixtures as $fixture) { - if ($this->isFixtureSetup($connection, $fixture)) { - $fixture->drop($db); - $index = array_search($fixture, $this->_insertionMap[$connection]); - unset($this->_insertionMap[$connection][$index]); - } - } - }; - $this->_runOperation(array_keys($this->_loaded), $shutdown); - } - - /** - * Check whether or not a fixture has been inserted in a given connection name. - * - * @param string $connection The connection name. - * @param \Cake\Datasource\FixtureInterface $fixture The fixture to check. - * @return bool - */ - public function isFixtureSetup($connection, $fixture) - { - return isset($this->_insertionMap[$connection]) && in_array($fixture, $this->_insertionMap[$connection]); - } -} diff --git a/src/TestSuite/Fixture/FixtureStrategyInterface.php b/src/TestSuite/Fixture/FixtureStrategyInterface.php new file mode 100644 index 00000000000..f848889eda4 --- /dev/null +++ b/src/TestSuite/Fixture/FixtureStrategyInterface.php @@ -0,0 +1,38 @@ + $fixtureNames Name of fixtures used by test. + * @return void + */ + public function setupTest(array $fixtureNames): void; + + /** + * Called after each test run in each TestCase. + * + * @return void + */ + public function teardownTest(): void; +} diff --git a/src/TestSuite/Fixture/SchemaLoader.php b/src/TestSuite/Fixture/SchemaLoader.php new file mode 100644 index 00000000000..09de58c6465 --- /dev/null +++ b/src/TestSuite/Fixture/SchemaLoader.php @@ -0,0 +1,183 @@ +|string $paths Schema files to load + * @param string $connectionName Connection name + * @param bool $dropTables Drop all tables prior to loading schema files + * @param bool $truncateTables Truncate all tables after loading schema files + * @return void + */ + public function loadSqlFiles( + array|string $paths, + string $connectionName = 'test', + bool $dropTables = true, + bool $truncateTables = false, + ): void { + $files = (array)$paths; + + // Don't create schema if we are in a phpunit separate process test method. + if (isset($GLOBALS['__PHPUNIT_BOOTSTRAP'])) { + return; + } + + if ($dropTables) { + ConnectionHelper::dropTables($connectionName); + } + + /** @var \Cake\Database\Connection $connection */ + $connection = ConnectionManager::get($connectionName); + foreach ($files as $file) { + if (!file_exists($file)) { + throw new InvalidArgumentException(sprintf('Unable to load SQL file `%s`.', $file)); + } + $sql = file_get_contents($file); + if ($sql === false) { + throw new CakeException(sprintf('Cannot read file content of `%s`', $file)); + } + + // Use the underlying PDO connection so we can avoid prepared statements + // which don't support multiple queries in postgres. + $driver = $connection->getWriteDriver(); + $driver->exec($sql); + } + + if ($truncateTables) { + ConnectionHelper::truncateTables($connectionName); + } + } + + /** + * Load and apply CakePHP schema file. + * + * This method will process the array returned by `$file` and treat + * the contents as a list of table schema. + * + * An example table is: + * + * ``` + * return [ + * 'articles' => [ + * 'columns' => [ + * 'id' => [ + * 'type' => 'integer', + * ], + * 'author_id' => [ + * 'type' => 'integer', + * 'null' => true, + * ], + * 'title' => [ + * 'type' => 'string', + * 'null' => true, + * ], + * 'body' => 'text', + * 'published' => [ + * 'type' => 'string', + * 'length' => 1, + * 'default' => 'N', + * ], + * ], + * 'constraints' => [ + * 'primary' => [ + * 'type' => 'primary', + * 'columns' => [ + * 'id', + * ], + * ], + * ], + * ], + * ]; + * ``` + * + * This schema format can be useful for plugins that want to include + * tables to test against but don't need to include production + * ready schema via migrations. Applications should favor using migrations + * or SQL dump files over this format for ease of maintenance. + * + * A more complete example can be found in `tests/schema.php`. + * + * @param string $file Schema file + * @param string $connectionName Connection name + * @throws \InvalidArgumentException For missing table name(s). + * @return void + */ + public function loadInternalFile(string $file, string $connectionName = 'test'): void + { + // Don't reload schema when we are in a separate process state. + if (isset($GLOBALS['__PHPUNIT_BOOTSTRAP'])) { + return; + } + + ConnectionHelper::dropTables($connectionName); + + $tables = include $file; + + /** + * @var \Cake\Database\Connection $connection + */ + $connection = ConnectionManager::get($connectionName); + $connection->disableConstraints(function (Connection $connection) use ($tables): void { + foreach ($tables as $tableName => $table) { + $name = $table['table'] ?? $tableName; + if (!is_string($name)) { + throw new InvalidArgumentException( + sprintf('`%s` is not a valid table name. Either use a string key for the table definition' + . "(`'articles' => [...]`) or define the `table` key in the table definition.", $name), + ); + } + $schema = new TableSchema($name, $table['columns']); + if (isset($table['indexes'])) { + foreach ($table['indexes'] as $key => $index) { + $schema->addIndex($key, $index); + } + } + if (isset($table['constraints'])) { + foreach ($table['constraints'] as $key => $index) { + $schema->addConstraint($key, $index); + } + } + + // Generate SQL for each table. + foreach ($schema->createSql($connection) as $sql) { + $connection->execute($sql); + } + } + }); + } +} diff --git a/src/TestSuite/Fixture/TestFixture.php b/src/TestSuite/Fixture/TestFixture.php index 3b71c7cbcd2..4697d501b6b 100644 --- a/src/TestSuite/Fixture/TestFixture.php +++ b/src/TestSuite/Fixture/TestFixture.php @@ -1,4 +1,6 @@ connection)) { + if ($this->connection) { $connection = $this->connection; - if (strpos($connection, 'test') !== 0) { + if (!str_starts_with($connection, 'test')) { $message = sprintf( - 'Invalid datasource name "%s" for "%s" fixture. Fixture datasource names must begin with "test".', + 'Invalid datasource name `%s` for `%s` fixture. Fixture datasource names must begin with `test`.', $connection, - $this->table + static::class, ); throw new CakeException($message); } @@ -112,17 +110,17 @@ public function __construct() } /** - * {@inheritDoc} + * @inheritDoc */ - public function connection() + public function connection(): string { return $this->connection; } /** - * {@inheritDoc} + * @inheritDoc */ - public function sourceName() + public function sourceName(): string { return $this->table; } @@ -133,280 +131,99 @@ public function sourceName() * @return void * @throws \Cake\ORM\Exception\MissingTableClassException When importing from a table that does not exist. */ - public function init() + public function init(): void { - if ($this->table === null) { - $this->table = $this->_tableFromClass(); - } - - if (empty($this->import) && !empty($this->fields)) { - $this->_schemaFromFields(); + assert(!$this->table || !$this->tableAlias, 'Cannot configure both database table and Cake table alias.'); + if ($this->table) { + $this->tableAlias = Inflector::camelize($this->table); + } elseif (!$this->tableAlias) { + $this->tableAlias = $this->_aliasFromClass(); } - if (!empty($this->import)) { - $this->_schemaFromImport(); - } - - if (empty($this->import) && empty($this->fields)) { - $this->_schemaFromReflection(); - } + $this->_schemaFromReflection(); } /** - * Returns the table name using the fixture class + * Returns the ORM table alias using the fixture class. * - * @return string - */ - protected function _tableFromClass() - { - list(, $class) = namespaceSplit(get_class($this)); - preg_match('/^(.*)Fixture$/', $class, $matches); - $table = $class; - - if (isset($matches[1])) { - $table = $matches[1]; - } - - return Inflector::tableize($table); - } - - /** - * Build the fixtures table schema from the fields property. + * Uses tableize() then camelize() to respect custom Inflector rules + * like uninflected words. * - * @return void - */ - protected function _schemaFromFields() - { - $connection = ConnectionManager::get($this->connection()); - $this->_schema = new TableSchema($this->table); - foreach ($this->fields as $field => $data) { - if ($field === '_constraints' || $field === '_indexes' || $field === '_options') { - continue; - } - $this->_schema->addColumn($field, $data); - } - if (!empty($this->fields['_constraints'])) { - foreach ($this->fields['_constraints'] as $name => $data) { - if (!$connection->supportsDynamicConstraints() || $data['type'] !== TableSchema::CONSTRAINT_FOREIGN) { - $this->_schema->addConstraint($name, $data); - } else { - $this->_constraints[$name] = $data; - } - } - } - if (!empty($this->fields['_indexes'])) { - foreach ($this->fields['_indexes'] as $name => $data) { - $this->_schema->addIndex($name, $data); - } - } - if (!empty($this->fields['_options'])) { - $this->_schema->setOptions($this->fields['_options']); - } - } - - /** - * Build fixture schema from a table in another datasource. + * For plugin fixtures (namespace pattern `{Plugin}\Test\Fixture\`), + * the plugin name is automatically prepended to the alias. * - * @return void - * @throws \Cake\Core\Exception\Exception when trying to import from an empty table. + * @return string */ - protected function _schemaFromImport() + protected function _aliasFromClass(): string { - if (!is_array($this->import)) { - return; - } - $import = $this->import + ['connection' => 'default', 'table' => null, 'model' => null]; + [, $class] = namespaceSplit(static::class); + preg_match('/^(.*)Fixture$/', $class, $matches); + $name = $matches[1] ?? $class; - if (!empty($import['model'])) { - if (!empty($import['table'])) { - throw new CakeException('You cannot define both table and model.'); - } - $import['table'] = TableRegistry::get($import['model'])->getTable(); - } + $alias = Inflector::camelize(Inflector::tableize($name)); - if (empty($import['table'])) { - throw new CakeException('Cannot import from undefined table.'); + // Detect plugin namespace pattern: {Plugin}\Test\Fixture\... + // and prepend plugin name to alias for proper table resolution + $plugin = strstr(static::class, '\\Test\\Fixture\\', before_needle: true); + if ($plugin && Plugin::isLoaded($plugin)) { + return $plugin . '.' . $alias; } - $this->table = $import['table']; - - $db = ConnectionManager::get($import['connection'], false); - $schemaCollection = $db->schemaCollection(); - $table = $schemaCollection->describe($import['table']); - $this->_schema = $table; + return $alias; } /** * Build fixture schema directly from the datasource * * @return void - * @throws \Cake\Core\Exception\Exception when trying to reflect a table that does not exist + * @throws \Cake\Core\Exception\CakeException when trying to reflect a table that does not exist */ - protected function _schemaFromReflection() + protected function _schemaFromReflection(): void { $db = ConnectionManager::get($this->connection()); - $schemaCollection = $db->schemaCollection(); - $tables = $schemaCollection->listTables(); - - if (!in_array($this->table, $tables)) { - throw new CakeException( - sprintf( - 'Cannot describe schema for table `%s` for fixture `%s` : the table does not exist.', - $this->table, - get_class($this) - ) - ); - } - - $this->_schema = $schemaCollection->describe($this->table); - } - - /** - * Gets/Sets the TableSchema instance used by this fixture. - * - * @param \Cake\Database\Schema\TableSchema|null $schema The table to set. - * @return \Cake\Database\Schema\TableSchema|null - * @deprecated 3.5.0 Use getTableSchema/setTableSchema instead. - */ - public function schema(TableSchema $schema = null) - { - if ($schema) { - $this->setTableSchema($schema); - } + assert($db instanceof Connection); + try { + $ormTable = $this->fetchTable($this->tableAlias, ['connection' => $db]); - return $this->getTableSchema(); - } + // Remove the fetched table from the locator to avoid conflicts + // with test cases that need to (re)configure the alias. + $this->getTableLocator()->remove($this->tableAlias); - /** - * {@inheritDoc} - */ - public function create(ConnectionInterface $db) - { - if (empty($this->_schema)) { - return false; - } + if (!$this->table) { + $this->table = $ormTable->getTable(); + } - if (empty($this->import) && empty($this->fields)) { - return true; - } + $schema = $ormTable->getSchema(); + assert($schema instanceof TableSchema); + $this->_schema = $schema; - try { - $queries = $this->_schema->createSql($db); - foreach ($queries as $query) { - $stmt = $db->prepare($query); - $stmt->execute(); - $stmt->closeCursor(); - } - } catch (Exception $e) { - $msg = sprintf( - 'Fixture creation for "%s" failed "%s"', + $this->getTableLocator()->clear(); + } catch (CakeException $e) { + $message = sprintf( + 'Cannot describe schema for table `%s` for fixture `%s`. The table does not exist.', $this->table, - $e->getMessage() + static::class, ); - Log::error($msg); - trigger_error($msg, E_USER_WARNING); - - return false; - } - - return true; - } - - /** - * {@inheritDoc} - */ - public function drop(ConnectionInterface $db) - { - if (empty($this->_schema)) { - return false; - } - - if (empty($this->import) && empty($this->fields)) { - return true; - } - - try { - $sql = $this->_schema->dropSql($db); - foreach ($sql as $stmt) { - $db->execute($stmt)->closeCursor(); - } - } catch (Exception $e) { - return false; + throw new CakeException($message, null, $e); } - - return true; } /** - * {@inheritDoc} + * @inheritDoc */ - public function insert(ConnectionInterface $db) + public function insert(ConnectionInterface $connection): bool { - if (isset($this->records) && !empty($this->records)) { - list($fields, $values, $types) = $this->_getRecords(); - $query = $db->newQuery() + assert($connection instanceof Connection); + if ($this->records) { + [$fields, $values, $types] = $this->_getRecords(); + $query = $connection->insertQuery() ->insert($fields, $types) - ->into($this->table); + ->into($this->sourceName()); foreach ($values as $row) { $query->values($row); } - $statement = $query->execute(); - $statement->closeCursor(); - - return $statement; - } - - return true; - } - - /** - * {@inheritDoc} - */ - public function createConstraints(ConnectionInterface $db) - { - if (empty($this->_constraints)) { - return true; - } - - foreach ($this->_constraints as $name => $data) { - $this->_schema->addConstraint($name, $data); - } - - $sql = $this->_schema->addConstraintSql($db); - - if (empty($sql)) { - return true; - } - - foreach ($sql as $stmt) { - $db->execute($stmt)->closeCursor(); - } - - return true; - } - - /** - * {@inheritDoc} - */ - public function dropConstraints(ConnectionInterface $db) - { - if (empty($this->_constraints)) { - return true; - } - - $sql = $this->_schema->dropConstraintSql($db); - - if (empty($sql)) { - return true; - } - - foreach ($sql as $stmt) { - $db->execute($stmt)->closeCursor(); - } - - foreach ($this->_constraints as $name => $data) { - $this->_schema->dropConstraint($name); + $query->execute(); } return true; @@ -417,16 +234,37 @@ public function dropConstraints(ConnectionInterface $db) * * @return array */ - protected function _getRecords() + protected function _getRecords(): array { - $fields = $values = $types = []; + $fields = []; + $values = []; + $types = []; $columns = $this->_schema->columns(); - foreach ($this->records as $record) { - $fields = array_merge($fields, array_intersect(array_keys($record), $columns)); + foreach ($this->records as $index => $record) { + $recordFields = array_keys($record); + if ($this->strictFields) { + $invalidFields = array_values(array_filter( + $recordFields, + fn(int|string $f) => !in_array($f, $columns, true), + )); + if ($invalidFields !== []) { + throw new CakeException( + "Record #{$index} in fixture has additional fields that do not exist in the schema. " . + 'Remove the following fields: ' . json_encode($invalidFields), + ); + } + } else { + $recordFields = array_intersect($recordFields, $columns); + } + + $fields = array_unique(array_merge($fields, $recordFields)); } - $fields = array_values(array_unique($fields)); + /** @var list $fields */ + $fields = array_values($fields); foreach ($fields as $field) { - $types[$field] = $this->_schema->getColumn($field)['type']; + $column = $this->_schema->getColumn($field); + assert($column !== null); + $types[$field] = $column['type']; } $default = array_fill_keys($fields, null); foreach ($this->records as $record) { @@ -437,33 +275,26 @@ protected function _getRecords() } /** - * {@inheritDoc} + * @inheritDoc */ - public function truncate(ConnectionInterface $db) + public function truncate(ConnectionInterface $connection): bool { - $sql = $this->_schema->truncateSql($db); + assert($connection instanceof Connection); + $sql = $this->_schema->truncateSql($connection); foreach ($sql as $stmt) { - $db->execute($stmt)->closeCursor(); + $connection->execute($stmt); } return true; } /** - * {@inheritDoc} + * Returns the table schema for this fixture. + * + * @return \Cake\Database\Schema\TableSchemaInterface&\Cake\Database\Schema\SqlGeneratorInterface */ - public function getTableSchema() + public function getTableSchema(): TableSchemaInterface&SqlGeneratorInterface { return $this->_schema; } - - /** - * {@inheritDoc} - */ - public function setTableSchema(DatabaseTableSchemaInterface $schema) - { - $this->_schema = $schema; - - return $this; - } } diff --git a/src/TestSuite/Fixture/TransactionStrategy.php b/src/TestSuite/Fixture/TransactionStrategy.php new file mode 100644 index 00000000000..981dbf80f1d --- /dev/null +++ b/src/TestSuite/Fixture/TransactionStrategy.php @@ -0,0 +1,94 @@ + + */ + protected array $fixtures = []; + + /** + * Initialize strategy. + */ + public function __construct() + { + $this->helper = new FixtureHelper(); + } + + /** + * @inheritDoc + */ + public function setupTest(array $fixtureNames): void + { + if (!$fixtureNames) { + return; + } + + $this->fixtures = $this->helper->loadFixtures($fixtureNames); + + $this->helper->runPerConnection(function ($connection): void { + if ($connection instanceof Connection) { + assert( + $connection->inTransaction() === false, + 'Cannot start transaction strategy inside a transaction. ' . + 'Ensure you have closed all open transactions.', + ); + $connection->enableSavePoints(); + if (!$connection->isSavePointsEnabled()) { + throw new DatabaseException( + "Could not enable save points for the `{$connection->configName()}` connection. " . + 'Your database needs to support savepoints in order to use ' . + 'TransactionStrategy.', + ); + } + + $connection->begin(); + $connection->createSavePoint('__fixtures__'); + } + }, $this->fixtures); + + $this->helper->insert($this->fixtures); + } + + /** + * @inheritDoc + */ + public function teardownTest(): void + { + $this->helper->runPerConnection(function (Connection $connection): void { + if ($connection->inTransaction()) { + $connection->rollback(true); + } + }, $this->fixtures); + } +} diff --git a/src/TestSuite/Fixture/TruncateStrategy.php b/src/TestSuite/Fixture/TruncateStrategy.php new file mode 100644 index 00000000000..0b8de8e3c91 --- /dev/null +++ b/src/TestSuite/Fixture/TruncateStrategy.php @@ -0,0 +1,62 @@ + + */ + protected array $fixtures = []; + + /** + * Initialize strategy. + */ + public function __construct() + { + $this->helper = new FixtureHelper(); + } + + /** + * @inheritDoc + */ + public function setupTest(array $fixtureNames): void + { + if (!$fixtureNames) { + return; + } + + $this->fixtures = $this->helper->loadFixtures($fixtureNames); + $this->helper->insert($this->fixtures); + } + + /** + * @inheritDoc + */ + public function teardownTest(): void + { + $this->helper->truncate($this->fixtures); + } +} diff --git a/src/TestSuite/IntegrationTestCase.php b/src/TestSuite/IntegrationTestCase.php deleted file mode 100644 index d7ba1e06594..00000000000 --- a/src/TestSuite/IntegrationTestCase.php +++ /dev/null @@ -1,1176 +0,0 @@ -_useHttpServer = class_exists($namespace . '\Application'); - } - - /** - * Clears the state used for requests. - * - * @return void - */ - public function tearDown() - { - parent::tearDown(); - $this->_request = []; - $this->_session = []; - $this->_cookie = []; - $this->_response = null; - $this->_exception = null; - $this->_controller = null; - $this->_viewName = null; - $this->_layoutName = null; - $this->_requestSession = null; - $this->_appClass = null; - $this->_appArgs = null; - $this->_securityToken = false; - $this->_csrfToken = false; - $this->_retainFlashMessages = false; - $this->_useHttpServer = false; - } - - /** - * Toggle whether or not you want to use the HTTP Server stack. - * - * @param bool $enable Enable/disable the usage of the HTTP Stack. - * @return void - */ - public function useHttpServer($enable) - { - $this->_useHttpServer = (bool)$enable; - } - - /** - * Configure the application class to use in integration tests. - * - * Combined with `useHttpServer()` to customize the class name and constructor arguments - * of your application class. - * - * @param string $class The application class name. - * @param array|null $constructorArgs The constructor arguments for your application class. - * @return void - */ - public function configApplication($class, $constructorArgs) - { - $this->_appClass = $class; - $this->_appArgs = $constructorArgs; - } - - /** - * Calling this method will enable a SecurityComponent - * compatible token to be added to request data. This - * lets you easily test actions protected by SecurityComponent. - * - * @return void - */ - public function enableSecurityToken() - { - $this->_securityToken = true; - } - - /** - * Calling this method will add a CSRF token to the request. - * - * Both the POST data and cookie will be populated when this option - * is enabled. The default parameter names will be used. - * - * @return void - */ - public function enableCsrfToken() - { - $this->_csrfToken = true; - } - - /** - * Calling this method will re-store flash messages into the test session - * after being removed by the FlashHelper - * - * @return void - */ - public function enableRetainFlashMessages() - { - $this->_retainFlashMessages = true; - } - - /** - * Configures the data for the *next* request. - * - * This data is cleared in the tearDown() method. - * - * You can call this method multiple times to append into - * the current state. - * - * @param array $data The request data to use. - * @return void - */ - public function configRequest(array $data) - { - $this->_request = $data + $this->_request; - } - - /** - * Sets session data. - * - * This method lets you configure the session data - * you want to be used for requests that follow. The session - * state is reset in each tearDown(). - * - * You can call this method multiple times to append into - * the current state. - * - * @param array $data The session data to use. - * @return void - */ - public function session(array $data) - { - $this->_session = $data + $this->_session; - } - - /** - * Sets a request cookie for future requests. - * - * This method lets you configure the session data - * you want to be used for requests that follow. The session - * state is reset in each tearDown(). - * - * You can call this method multiple times to append into - * the current state. - * - * @param string $name The cookie name to use. - * @param mixed $value The value of the cookie. - * @return void - */ - public function cookie($name, $value) - { - $this->_cookie[$name] = $value; - } - - /** - * Returns the encryption key to be used. - * - * @return string - */ - protected function _getCookieEncryptionKey() - { - if (isset($this->_cookieEncryptionKey)) { - return $this->_cookieEncryptionKey; - } - - return Security::getSalt(); - } - - /** - * Sets a encrypted request cookie for future requests. - * - * The difference from cookie() is this encrypts the cookie - * value like the CookieComponent. - * - * @param string $name The cookie name to use. - * @param mixed $value The value of the cookie. - * @param string|bool $encrypt Encryption mode to use. - * @param string|null $key Encryption key used. Defaults - * to Security.salt. - * @return void - * @see \Cake\Utility\CookieCryptTrait::_encrypt() - */ - public function cookieEncrypted($name, $value, $encrypt = 'aes', $key = null) - { - $this->_cookieEncryptionKey = $key; - $this->_cookie[$name] = $this->_encrypt($value, $encrypt); - } - - /** - * Performs a GET request using the current request data. - * - * The response of the dispatched request will be stored as - * a property. You can use various assert methods to check the - * response. - * - * @param string|array $url The URL to request. - * @return void - */ - public function get($url) - { - $this->_sendRequest($url, 'GET'); - } - - /** - * Performs a POST request using the current request data. - * - * The response of the dispatched request will be stored as - * a property. You can use various assert methods to check the - * response. - * - * @param string|array $url The URL to request. - * @param array $data The data for the request. - * @return void - */ - public function post($url, $data = []) - { - $this->_sendRequest($url, 'POST', $data); - } - - /** - * Performs a PATCH request using the current request data. - * - * The response of the dispatched request will be stored as - * a property. You can use various assert methods to check the - * response. - * - * @param string|array $url The URL to request. - * @param array $data The data for the request. - * @return void - */ - public function patch($url, $data = []) - { - $this->_sendRequest($url, 'PATCH', $data); - } - - /** - * Performs a PUT request using the current request data. - * - * The response of the dispatched request will be stored as - * a property. You can use various assert methods to check the - * response. - * - * @param string|array $url The URL to request. - * @param array $data The data for the request. - * @return void - */ - public function put($url, $data = []) - { - $this->_sendRequest($url, 'PUT', $data); - } - - /** - * Performs a DELETE request using the current request data. - * - * The response of the dispatched request will be stored as - * a property. You can use various assert methods to check the - * response. - * - * @param string|array $url The URL to request. - * @return void - */ - public function delete($url) - { - $this->_sendRequest($url, 'DELETE'); - } - - /** - * Performs a HEAD request using the current request data. - * - * The response of the dispatched request will be stored as - * a property. You can use various assert methods to check the - * response. - * - * @param string|array $url The URL to request. - * @return void - */ - public function head($url) - { - $this->_sendRequest($url, 'HEAD'); - } - - /** - * Performs an OPTIONS request using the current request data. - * - * The response of the dispatched request will be stored as - * a property. You can use various assert methods to check the - * response. - * - * @param string|array $url The URL to request. - * @return void - */ - public function options($url) - { - $this->_sendRequest($url, 'OPTIONS'); - } - - /** - * Creates and send the request into a Dispatcher instance. - * - * Receives and stores the response for future inspection. - * - * @param string|array $url The URL - * @param string $method The HTTP method - * @param array|null $data The request data. - * @return void - * @throws \Exception - */ - protected function _sendRequest($url, $method, $data = []) - { - $dispatcher = $this->_makeDispatcher(); - try { - $request = $this->_buildRequest($url, $method, $data); - $response = $dispatcher->execute($request); - $this->_requestSession = $request['session']; - if ($this->_retainFlashMessages && $this->_flashMessages) { - $this->_requestSession->write('Flash', $this->_flashMessages); - } - $this->_response = $response; - } catch (PhpUnitException $e) { - throw $e; - } catch (DatabaseException $e) { - throw $e; - } catch (LogicException $e) { - throw $e; - } catch (Exception $e) { - $this->_exception = $e; - $this->_handleError($e); - } - } - - /** - * Get the correct dispatcher instance. - * - * @return \Cake\TestSuite\MiddlewareDispatcher|\Cake\TestSuite\LegacyRequestDispatcher A dispatcher instance - */ - protected function _makeDispatcher() - { - if ($this->_useHttpServer) { - return new MiddlewareDispatcher($this, $this->_appClass, $this->_appArgs); - } - - return new LegacyRequestDispatcher($this); - } - - /** - * Adds additional event spies to the controller/view event manager. - * - * @param \Cake\Event\Event $event A dispatcher event. - * @param \Cake\Controller\Controller|null $controller Controller instance. - * @return void - */ - public function controllerSpy($event, $controller = null) - { - if (!$controller) { - /** @var \Cake\Controller\Controller $controller */ - $controller = $event->getSubject(); - } - $this->_controller = $controller; - $events = $controller->getEventManager(); - $events->on('View.beforeRender', function ($event, $viewFile) use ($controller) { - if (!$this->_viewName) { - $this->_viewName = $viewFile; - } - if ($this->_retainFlashMessages) { - $this->_flashMessages = $controller->request->session()->read('Flash'); - } - }); - $events->on('View.beforeLayout', function ($event, $viewFile) { - $this->_layoutName = $viewFile; - }); - } - - /** - * Attempts to render an error response for a given exception. - * - * This method will attempt to use the configured exception renderer. - * If that class does not exist, the built-in renderer will be used. - * - * @param \Exception $exception Exception to handle. - * @return void - * @throws \Exception - */ - protected function _handleError($exception) - { - $class = Configure::read('Error.exceptionRenderer'); - if (empty($class) || !class_exists($class)) { - $class = 'Cake\Error\ExceptionRenderer'; - } - /** @var \Cake\Error\ExceptionRenderer $instance */ - $instance = new $class($exception); - $this->_response = $instance->render(); - } - - /** - * Creates a request object with the configured options and parameters. - * - * @param string|array $url The URL - * @param string $method The HTTP method - * @param array|null $data The request data. - * @return array The request context - */ - protected function _buildRequest($url, $method, $data) - { - $sessionConfig = (array)Configure::read('Session') + [ - 'defaults' => 'php', - ]; - $session = Session::create($sessionConfig); - $session->write($this->_session); - list ($url, $query) = $this->_url($url); - $tokenUrl = $url; - - parse_str($query, $queryData); - - if ($query) { - $tokenUrl .= '?' . http_build_query($queryData); - } - - $props = [ - 'url' => $url, - 'session' => $session, - 'query' => $queryData - ]; - if (is_string($data)) { - $props['input'] = $data; - } - if (!isset($props['input'])) { - $props['post'] = $this->_addTokens($tokenUrl, $data); - } - $props['cookies'] = $this->_cookie; - - $env = [ - 'REQUEST_METHOD' => $method, - 'QUERY_STRING' => $query, - 'REQUEST_URI' => $url, - ]; - if (isset($this->_request['headers'])) { - foreach ($this->_request['headers'] as $k => $v) { - $name = strtoupper(str_replace('-', '_', $k)); - if (!in_array($name, ['CONTENT_LENGTH', 'CONTENT_TYPE'])) { - $name = 'HTTP_' . $name; - } - $env[$name] = $v; - } - unset($this->_request['headers']); - } - $props['environment'] = $env; - $props = Hash::merge($props, $this->_request); - - return $props; - } - - /** - * Add the CSRF and Security Component tokens if necessary. - * - * @param string $url The URL the form is being submitted on. - * @param array $data The request body data. - * @return array The request body with tokens added. - */ - protected function _addTokens($url, $data) - { - if ($this->_securityToken === true) { - $keys = array_map(function ($field) { - return preg_replace('/(\.\d+)+$/', '', $field); - }, array_keys(Hash::flatten($data))); - $tokenData = $this->_buildFieldToken($url, array_unique($keys)); - $data['_Token'] = $tokenData; - $data['_Token']['debug'] = 'SecurityComponent debug data would be added here'; - } - - if ($this->_csrfToken === true) { - if (!isset($this->_cookie['csrfToken'])) { - $this->_cookie['csrfToken'] = Text::uuid(); - } - if (!isset($data['_csrfToken'])) { - $data['_csrfToken'] = $this->_cookie['csrfToken']; - } - } - - return $data; - } - - /** - * Creates a valid request url and parameter array more like Request::_url() - * - * @param string|array $url The URL - * @return array Qualified URL and the query parameters - */ - protected function _url($url) - { - $url = Router::url($url); - $query = ''; - - if (strpos($url, '?') !== false) { - list($url, $query) = explode('?', $url, 2); - } - - return [$url, $query]; - } - - /** - * Get the response body as string - * - * @return string The response body. - */ - protected function _getBodyAsString() - { - return (string)$this->_response->getBody(); - } - - /** - * Fetches a view variable by name. - * - * If the view variable does not exist, null will be returned. - * - * @param string $name The view variable to get. - * @return mixed The view variable if set. - */ - public function viewVariable($name) - { - if (empty($this->_controller->viewVars)) { - $this->fail('There are no view variables, perhaps you need to run a request?'); - } - if (isset($this->_controller->viewVars[$name])) { - return $this->_controller->viewVars[$name]; - } - - return null; - } - - /** - * Asserts that the response status code is in the 2xx range. - * - * @param string $message Custom message for failure. - * @return void - */ - public function assertResponseOk($message = null) - { - if (empty($message)) { - $message = 'Status code is not between 200 and 204'; - } - $this->_assertStatus(200, 204, $message); - } - - /** - * Asserts that the response status code is in the 2xx/3xx range. - * - * @param string $message Custom message for failure. - * @return void - */ - public function assertResponseSuccess($message = null) - { - if (empty($message)) { - $message = 'Status code is not between 200 and 308'; - } - $this->_assertStatus(200, 308, $message); - } - - /** - * Asserts that the response status code is in the 4xx range. - * - * @param string $message Custom message for failure. - * @return void - */ - public function assertResponseError($message = null) - { - if (empty($message)) { - $message = 'Status code is not between 400 and 429'; - } - $this->_assertStatus(400, 429, $message); - } - - /** - * Asserts that the response status code is in the 5xx range. - * - * @param string $message Custom message for failure. - * @return void - */ - public function assertResponseFailure($message = null) - { - if (empty($message)) { - $message = 'Status code is not between 500 and 505'; - } - $this->_assertStatus(500, 505, $message); - } - - /** - * Asserts a specific response status code. - * - * @param int $code Status code to assert. - * @param string $message Custom message for failure. - * @return void - */ - public function assertResponseCode($code, $message = null) - { - $actual = $this->_response->getStatusCode(); - - if (empty($message)) { - $message = 'Status code is not ' . $code . ' but ' . $actual; - } - - $this->_assertStatus($code, $code, $message); - } - - /** - * Helper method for status assertions. - * - * @param int $min Min status code. - * @param int $max Max status code. - * @param string $message The error message. - * @return void - */ - protected function _assertStatus($min, $max, $message) - { - if (!$this->_response) { - $this->fail('No response set, cannot assert status code.'); - } - $status = $this->_response->getStatusCode(); - - if ($this->_exception && ($status < $min || $status > $max)) { - $this->fail($this->_exception->getMessage()); - } - - $this->assertGreaterThanOrEqual($min, $status, $message); - $this->assertLessThanOrEqual($max, $status, $message); - } - - /** - * Asserts that the Location header is correct. - * - * @param string|array|null $url The URL you expected the client to go to. This - * can either be a string URL or an array compatible with Router::url(). Use null to - * simply check for the existence of this header. - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertRedirect($url = null, $message = '') - { - if (!$this->_response) { - $this->fail('No response set, cannot assert location header. ' . $message); - } - $result = $this->_response->getHeaderLine('Location'); - if ($url === null) { - $this->assertNotEmpty($result, $message); - - return; - } - if (empty($result)) { - $this->fail('No location header set. ' . $message); - } - $this->assertEquals(Router::url($url, ['_full' => true]), $result, $message); - } - - /** - * Asserts that the Location header contains a substring - * - * @param string $url The URL you expected the client to go to. - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertRedirectContains($url, $message = '') - { - if (!$this->_response) { - $this->fail('No response set, cannot assert location header. ' . $message); - } - $result = $this->_response->getHeaderLine('Location'); - if (empty($result)) { - $this->fail('No location header set. ' . $message); - } - $this->assertContains($url, $result, $message); - } - - /** - * Asserts that the Location header is not set. - * - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertNoRedirect($message = '') - { - if (!$this->_response) { - $this->fail('No response set, cannot assert location header. ' . $message); - } - $result = $this->_response->getHeaderLine('Location'); - if (!$message) { - $message = 'Redirect header set'; - } - if (!empty($result)) { - $message .= ': ' . $result; - } - $this->assertEmpty($result, $message); - } - - /** - * Asserts response headers - * - * @param string $header The header to check - * @param string $content The content to check for. - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertHeader($header, $content, $message = '') - { - if (!$this->_response) { - $this->fail('No response set, cannot assert headers. ' . $message); - } - if (!$this->_response->hasHeader($header)) { - $this->fail("The '$header' header is not set. " . $message); - } - $actual = $this->_response->getHeaderLine($header); - $this->assertEquals($content, $actual, $message); - } - - /** - * Asserts response header contains a string - * - * @param string $header The header to check - * @param string $content The content to check for. - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertHeaderContains($header, $content, $message = '') - { - if (!$this->_response) { - $this->fail('No response set, cannot assert headers. ' . $message); - } - if (!$this->_response->hasHeader($header)) { - $this->fail("The '$header' header is not set. " . $message); - } - $actual = $this->_response->getHeaderLine($header); - $this->assertContains($content, $actual, $message); - } - - /** - * Asserts content type - * - * @param string $type The content-type to check for. - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertContentType($type, $message = '') - { - if (!$this->_response) { - $this->fail('No response set, cannot assert content-type. ' . $message); - } - $alias = $this->_response->getMimeType($type); - if ($alias !== false) { - $type = $alias; - } - $result = $this->_response->type(); - $this->assertEquals($type, $result, $message); - } - - /** - * Asserts content exists in the response body. - * - * @param mixed $content The content to check for. - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertResponseEquals($content, $message = '') - { - if (!$this->_response) { - $this->fail('No response set, cannot assert content. ' . $message); - } - $this->assertEquals($content, $this->_getBodyAsString(), $message); - } - - /** - * Asserts content exists in the response body. - * - * @param string $content The content to check for. - * @param string $message The failure message that will be appended to the generated message. - * @param bool $ignoreCase A flag to check whether we should ignore case or not. - * @return void - */ - public function assertResponseContains($content, $message = '', $ignoreCase = false) - { - if (!$this->_response) { - $this->fail('No response set, cannot assert content. ' . $message); - } - $this->assertContains($content, $this->_getBodyAsString(), $message, $ignoreCase); - } - - /** - * Asserts content does not exist in the response body. - * - * @param string $content The content to check for. - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertResponseNotContains($content, $message = '') - { - if (!$this->_response) { - $this->fail('No response set, cannot assert content. ' . $message); - } - $this->assertNotContains($content, $this->_getBodyAsString(), $message); - } - - /** - * Asserts that the response body matches a given regular expression. - * - * @param string $pattern The pattern to compare against. - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertResponseRegExp($pattern, $message = '') - { - if (!$this->_response) { - $this->fail('No response set, cannot assert content. ' . $message); - } - $this->assertRegExp($pattern, $this->_getBodyAsString(), $message); - } - - /** - * Asserts that the response body does not match a given regular expression. - * - * @param string $pattern The pattern to compare against. - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertResponseNotRegExp($pattern, $message = '') - { - if (!$this->_response) { - $this->fail('No response set, cannot assert content. ' . $message); - } - $this->assertNotRegExp($pattern, $this->_getBodyAsString(), $message); - } - - /** - * Assert response content is not empty. - * - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertResponseNotEmpty($message = '') - { - if (!$this->_response) { - $this->fail('No response set, cannot assert content. ' . $message); - } - $this->assertNotEmpty($this->_getBodyAsString(), $message); - } - /** - * Assert response content is empty. - * - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertResponseEmpty($message = '') - { - if (!$this->_response) { - $this->fail('No response set, cannot assert content. ' . $message); - } - $this->assertEmpty($this->_getBodyAsString(), $message); - } - - /** - * Asserts that the search string was in the template name. - * - * @param string $content The content to check for. - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertTemplate($content, $message = '') - { - if (!$this->_viewName) { - $this->fail('No view name stored. ' . $message); - } - $this->assertContains($content, $this->_viewName, $message); - } - - /** - * Asserts that the search string was in the layout name. - * - * @param string $content The content to check for. - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertLayout($content, $message = '') - { - if (!$this->_layoutName) { - $this->fail('No layout name stored. ' . $message); - } - $this->assertContains($content, $this->_layoutName, $message); - } - - /** - * Asserts session contents - * - * @param string $expected The expected contents. - * @param string $path The session data path. Uses Hash::get() compatible notation - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertSession($expected, $path, $message = '') - { - if (empty($this->_requestSession)) { - $this->fail('There is no stored session data. Perhaps you need to run a request?'); - } - $result = $this->_requestSession->read($path); - $this->assertEquals( - $expected, - $result, - 'Session content for "' . $path . '" differs. ' . $message - ); - } - - /** - * Asserts cookie values - * - * @param string $expected The expected contents. - * @param string $name The cookie name. - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertCookie($expected, $name, $message = '') - { - if (!$this->_response) { - $this->fail('Not response set, cannot assert cookies.'); - } - $result = $this->_response->cookie($name); - $this->assertEquals( - $expected, - $result['value'], - 'Cookie "' . $name . '" data differs. ' . $message - ); - } - - /** - * Asserts a cookie has not been set in the response - * - * @param string $cookie The cookie name to check - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertCookieNotSet($cookie, $message = '') - { - if (!$this->_response) { - $this->fail('No response set, cannot assert cookies. ' . $message); - } - - $this->assertCookie(null, $cookie, "Cookie '{$cookie}' has been set. " . $message); - } - - /** - * Disable the error handler middleware. - * - * By using this function, exceptions are no longer caught by the ErrorHandlerMiddleware - * and are instead re-thrown by the TestExceptionRenderer. This can be helpful - * when trying to diagnose/debug unexpected failures in test cases. - * - * @return void - */ - public function disableErrorHandlerMiddleware() - { - Configure::write('Error.exceptionRenderer', TestExceptionRenderer::class); - } - - /** - * Asserts cookie values which are encrypted by the - * CookieComponent. - * - * The difference from assertCookie() is this decrypts the cookie - * value like the CookieComponent for this assertion. - * - * @param string $expected The expected contents. - * @param string $name The cookie name. - * @param string|bool $encrypt Encryption mode to use. - * @param string|null $key Encryption key used. Defaults - * to Security.salt. - * @param string $message The failure message that will be appended to the generated message. - * @return void - * @see \Cake\Utility\CookieCryptTrait::_encrypt() - */ - public function assertCookieEncrypted($expected, $name, $encrypt = 'aes', $key = null, $message = '') - { - if (!$this->_response) { - $this->fail('No response set, cannot assert cookies.'); - } - $result = $this->_response->cookie($name); - $this->_cookieEncryptionKey = $key; - $result['value'] = $this->_decrypt($result['value'], $encrypt); - $this->assertEquals($expected, $result['value'], 'Cookie data differs. ' . $message); - } - - /** - * Asserts that a file with the given name was sent in the response - * - * @param string $expected The file name that should be sent in the response - * @param string $message The failure message that will be appended to the generated message. - * @return void - */ - public function assertFileResponse($expected, $message = '') - { - if ($this->_response === null) { - $this->fail('No response set, cannot assert file.'); - } - $actual = isset($this->_response->getFile()->path) ? $this->_response->getFile()->path : null; - - if ($actual === null) { - $this->fail('No file was sent in this response'); - } - $this->assertEquals($expected, $actual, $message); - } -} diff --git a/src/TestSuite/IntegrationTestTrait.php b/src/TestSuite/IntegrationTestTrait.php new file mode 100644 index 00000000000..47525d66a0e --- /dev/null +++ b/src/TestSuite/IntegrationTestTrait.php @@ -0,0 +1,1684 @@ + + */ + protected array $_unlockedFields = []; + + /** + * The name that will be used when retrieving the csrf token. + * + * @var string + */ + protected string $_csrfKeyName = 'csrfToken'; + + /** + * Clears the state used for requests. + * + * @return void + */ + #[After] + public function cleanup(): void + { + $this->_request = []; + $this->_session = []; + $this->_cookie = []; + $this->_response = null; + $this->_exception = null; + $this->_controller = null; + $this->_viewName = null; + $this->_layoutName = null; + $this->_requestSession = null; + $this->_securityToken = false; + $this->_csrfToken = false; + $this->_retainFlashMessages = false; + $this->_flashMessages = []; + } + + /** + * Calling this method will enable a FormProtectionComponent + * compatible token to be added to request data. This + * lets you easily test actions protected by FormProtectionComponent. + * + * @return void + */ + public function enableSecurityToken(): void + { + $this->_securityToken = true; + } + + /** + * Set list of fields that are excluded from field validation. + * + * @param array $unlockedFields List of fields that are excluded from field validation. + * @return void + */ + public function setUnlockedFields(array $unlockedFields = []): void + { + $this->_unlockedFields = $unlockedFields; + } + + /** + * Calling this method will add a CSRF token to the request. + * + * Both the POST data and cookie will be populated when this option + * is enabled. The default parameter names will be used. + * + * @param string $cookieName The name of the csrf token cookie. + * @return void + */ + public function enableCsrfToken(string $cookieName = 'csrfToken'): void + { + $this->_csrfToken = true; + $this->_csrfKeyName = $cookieName; + } + + /** + * Calling this method will re-store flash messages into the test session + * after being removed by the FlashHelper + * + * @return void + */ + public function enableRetainFlashMessages(): void + { + $this->_retainFlashMessages = true; + } + + /** + * Configures the data for the *next* request merging with existing state. + * + * This data is cleared in the tearDown() method. + * + * You can call this method multiple times to append into + * the current state. Sub-keys will be merged with existing + * state. + * + * @param array $data The request data to use. + * @return void + */ + public function configRequest(array $data): void + { + $this->_request = array_merge_recursive($data, $this->_request); + } + + /** + * Configures the data for the *next* request replacing existing state. + * + * @param array $data The request data to use. + * @return void + */ + public function replaceRequest(array $data): void + { + $this->_request = $data; + } + + /** + * Sets HTTP headers for the *next* request to be identified as JSON request. + * + * @return void + */ + public function requestAsJson(): void + { + $this->configRequest([ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + ]); + } + + /** + * Sets session data. + * + * This method lets you configure the session data + * you want to be used for requests that follow. The session + * state is reset in each tearDown(). + * + * You can call this method multiple times to append into + * the current state. + * + * @param array $data The session data to use. + * @return void + */ + public function session(array $data): void + { + $this->_session = $data + $this->_session; + } + + /** + * Sets a request cookie for future requests. + * + * This method lets you configure the session data + * you want to be used for requests that follow. The session + * state is reset in each tearDown(). + * + * You can call this method multiple times to append into + * the current state. + * + * @param string $name The cookie name to use. + * @param string $value The value of the cookie. + * @return void + */ + public function cookie(string $name, string $value): void + { + $this->_cookie[$name] = $value; + } + + /** + * Returns the encryption key to be used. + * + * @return string + */ + protected function _getCookieEncryptionKey(): string + { + return $this->_cookieEncryptionKey ?? Security::getSalt(); + } + + /** + * Sets a encrypted request cookie for future requests. + * + * The difference from cookie() is this encrypts the cookie + * value like the CookieComponent. + * + * @param string $name The cookie name to use. + * @param array|string $value The value of the cookie. + * @param string|false $encrypt Encryption mode to use. + * @param string|null $key Encryption key used. Defaults + * to Security.salt. + * @return void + * @see \Cake\Utility\CookieCryptTrait::_encrypt() + */ + public function cookieEncrypted( + string $name, + array|string $value, + string|false $encrypt = 'aes', + ?string $key = null, + ): void { + $this->_cookieEncryptionKey = $key; + $this->_cookie[$name] = $this->_encrypt($value, $encrypt); + } + + /** + * Performs a GET request using the current request data. + * + * The response of the dispatched request will be stored as + * a property. You can use various assert methods to check the + * response. + * + * @param array|string $url The URL to request. + * @return void + */ + public function get(array|string $url): void + { + $this->_sendRequest($url, 'GET'); + } + + /** + * Performs a POST request using the current request data. + * + * The response of the dispatched request will be stored as + * a property. You can use various assert methods to check the + * response. + * + * @param array|string $url The URL to request. + * @param array|string $data The data for the request. + * @return void + */ + public function post(array|string $url, array|string $data = []): void + { + $this->_sendRequest($url, 'POST', $data); + } + + /** + * Performs a PATCH request using the current request data. + * + * The response of the dispatched request will be stored as + * a property. You can use various assert methods to check the + * response. + * + * @param array|string $url The URL to request. + * @param array|string $data The data for the request. + * @return void + */ + public function patch(array|string $url, array|string $data = []): void + { + $this->_sendRequest($url, 'PATCH', $data); + } + + /** + * Performs a PUT request using the current request data. + * + * The response of the dispatched request will be stored as + * a property. You can use various assert methods to check the + * response. + * + * @param array|string $url The URL to request. + * @param array|string $data The data for the request. + * @return void + */ + public function put(array|string $url, array|string $data = []): void + { + $this->_sendRequest($url, 'PUT', $data); + } + + /** + * Performs a DELETE request using the current request data. + * + * The response of the dispatched request will be stored as + * a property. You can use various assert methods to check the + * response. + * + * @param array|string $url The URL to request. + * @return void + */ + public function delete(array|string $url): void + { + $this->_sendRequest($url, 'DELETE'); + } + + /** + * Performs a HEAD request using the current request data. + * + * The response of the dispatched request will be stored as + * a property. You can use various assert methods to check the + * response. + * + * @param array|string $url The URL to request. + * @return void + */ + public function head(array|string $url): void + { + $this->_sendRequest($url, 'HEAD'); + } + + /** + * Performs an OPTIONS request using the current request data. + * + * The response of the dispatched request will be stored as + * a property. You can use various assert methods to check the + * response. + * + * @param array|string $url The URL to request. + * @return void + */ + public function options(array|string $url): void + { + $this->_sendRequest($url, 'OPTIONS'); + } + + /** + * Creates and send the request into a Dispatcher instance. + * + * Receives and stores the response for future inspection. + * + * @param array|string $url The URL + * @param string $method The HTTP method + * @param array|string $data The request data. + * @return void + * @throws \PHPUnit\Exception|\Throwable + */ + protected function _sendRequest(array|string $url, string $method, array|string $data = []): void + { + $url = $this->resolveUrl($url); + $dispatcher = $this->_makeDispatcher(); + + try { + $request = $this->_buildRequest($url, $method, $data); + $response = $dispatcher->execute($request); + $this->_requestSession = $request['session']; + if ($this->_retainFlashMessages && $this->_flashMessages) { + $_SESSION['Flash'] = $this->_flashMessages; + $this->_requestSession->write($_SESSION); + } + $this->_response = $response; + } catch (PHPUnitException | DatabaseException $e) { + throw $e; + } catch (Throwable $e) { + $this->_exception = $e; + // Simulate the global exception handler being invoked. + $this->_handleError($e); + } + } + + /** + * Resolve the provided URL into a string. + * + * @param array|string $url The URL array/string to resolve. + * @return string + * @since 5.1.0 + */ + public function resolveUrl(array|string $url): string + { + // If we need to resolve a Route URL but there are no routes, load routes. + if (is_array($url) && Router::getRouteCollection()->routes() === []) { + return $this->resolveRoute($url); + } + + return Router::url($url); + } + + /** + * Convert a URL array into a string URL via routing. + * + * @param array $url The url to resolve + * @return string + * @since 5.1.0 + */ + protected function resolveRoute(array $url): string + { + $app = $this->createApp(); + + // Simulate application bootstrap and route loading. + // We need both to ensure plugins are loaded. + $app->bootstrap(); + if ($app instanceof PluginApplicationInterface) { + $app->pluginBootstrap(); + } + $builder = Router::createRouteBuilder('/'); + + if ($app instanceof RoutingApplicationInterface) { + $app->routes($builder); + } + if ($app instanceof PluginApplicationInterface) { + $app->pluginRoutes($builder); + } + + $out = Router::url($url); + Router::resetRoutes(); + + return $out; + } + + /** + * Get the correct dispatcher instance. + * + * @return \Cake\TestSuite\MiddlewareDispatcher A dispatcher instance + */ + protected function _makeDispatcher(): MiddlewareDispatcher + { + EventManager::instance()->on('Controller.initialize', $this->controllerSpy(...)); + $app = $this->createApp(); + assert($app instanceof HttpApplicationInterface); + + return new MiddlewareDispatcher($app); + } + + /** + * Adds additional event spies to the controller/view event manager. + * + * @param \Cake\Event\EventInterface $event A dispatcher event. + * @param \Cake\Controller\Controller|null $controller Controller instance. + * @return void + */ + public function controllerSpy(EventInterface $event, ?Controller $controller = null): void + { + if (!$controller) { + $controller = $event->getSubject(); + assert($controller instanceof Controller); + } + $this->_controller = $controller; + $events = $controller->getEventManager(); + $flashCapture = function (EventInterface $event): void { + if (!$this->_retainFlashMessages) { + return; + } + $controller = $event->getSubject(); + $this->_flashMessages = Hash::merge( + $this->_flashMessages, + $controller->getRequest()->getSession()->read('Flash'), + ); + }; + $events->on('Controller.beforeRedirect', ['priority' => -100], $flashCapture); + $events->on('Controller.beforeRender', ['priority' => -100], $flashCapture); + $events->on('View.beforeRender', function ($event, $viewFile): void { + if (!$this->_viewName) { + $this->_viewName = $viewFile; + } + }); + $events->on('View.beforeLayout', function ($event, $viewFile): void { + $this->_layoutName = $viewFile; + }); + } + + /** + * Attempts to render an error response for a given exception. + * + * This method will attempt to use the configured exception renderer. + * If that class does not exist, the built-in renderer will be used. + * + * @param \Throwable $exception Exception to handle. + * @return void + */ + protected function _handleError(Throwable $exception): void + { + $class = Configure::read('Error.exceptionRenderer'); + if (!$class || !class_exists($class)) { + $class = WebExceptionRenderer::class; + } + /** @var \Cake\Error\Renderer\WebExceptionRenderer $instance */ + $instance = new $class($exception); + $this->_response = $instance->render(); + } + + /** + * Creates a request object with the configured options and parameters. + * + * @param string $url The URL + * @param string $method The HTTP method + * @param array|string $data The request data. + * @return array The request context + */ + protected function _buildRequest(string $url, string $method, array|string $data = []): array + { + $sessionConfig = (array)Configure::read('Session') + [ + 'defaults' => 'php', + ]; + $session = Session::create($sessionConfig); + [$url, $query, $hostInfo] = $this->_url($url); + $tokenUrl = $url; + + if ($query) { + $tokenUrl .= '?' . $query; + } + + parse_str($query, $queryData); + + $env = [ + 'REQUEST_METHOD' => $method, + 'QUERY_STRING' => $query, + 'REQUEST_URI' => $url, + ]; + if (!empty($hostInfo['https'])) { + $env['HTTPS'] = 'on'; + } + if (isset($hostInfo['host'])) { + $env['HTTP_HOST'] = $hostInfo['host']; + } + if (isset($this->_request['headers'])) { + foreach ($this->_request['headers'] as $k => $v) { + $name = strtoupper(str_replace('-', '_', $k)); + if (!in_array($name, ['CONTENT_LENGTH', 'CONTENT_TYPE'], true)) { + $name = 'HTTP_' . $name; + } + $env[$name] = $v; + } + unset($this->_request['headers']); + } + $props = [ + 'url' => $url, + 'session' => $session, + 'query' => $queryData, + 'files' => [], + 'environment' => $env, + ]; + + if (is_string($data)) { + $props['input'] = $data; + } elseif ( + is_array($data) && + isset($props['environment']['CONTENT_TYPE']) && + $props['environment']['CONTENT_TYPE'] === 'application/x-www-form-urlencoded' + ) { + $props['input'] = http_build_query($data); + } else { + if ($method !== 'GET' || $data !== []) { + $data = $this->_addTokens($tokenUrl, $data, $method); + } + $props['post'] = $this->_castToString($data); + } + + $props['cookies'] = $this->_cookie; + $session->write($this->_session); + + return Hash::merge($props, $this->_request); + } + + /** + * Add the CSRF and FormProtectionComponent tokens if necessary. + * + * @param string $url The URL the form is being submitted on. + * @param array $data The request body data. + * @param string $method The request method. + * @return array The request body with tokens added. + */ + protected function _addTokens(string $url, array $data, string $method): array + { + if ($this->_securityToken === true) { + $fields = array_diff_key($data, array_flip($this->_unlockedFields)); + + $keys = array_map(function (int|string $field) { + return preg_replace('/(\.\d+)+$/', '', (string)$field); + }, array_keys(Hash::flatten($fields))); + + $formProtector = new FormProtector(['unlockedFields' => $this->_unlockedFields]); + foreach ($keys as $field) { + $formProtector->addField($field); + } + $tokenData = $formProtector->buildTokenData($url, 'cli'); + + $data['_Token'] = $tokenData; + + /** @see \Cake\Form\FormProtector::extractToken() */ + if (Configure::read('debug')) { + $data['_Token']['debug'] = 'FormProtector debug data would be added here'; + } elseif (isset($data['_Token']['debug'])) { + unset($data['_Token']['debug']); + } + } + + if ($this->_csrfToken === true) { + $middleware = new CsrfProtectionMiddleware(); + if (!isset($this->_cookie[$this->_csrfKeyName]) && !isset($this->_session[$this->_csrfKeyName])) { + $token = $middleware->createToken(); + } elseif (isset($this->_cookie[$this->_csrfKeyName])) { + $token = $this->_cookie[$this->_csrfKeyName]; + } else { + $token = $this->_session[$this->_csrfKeyName]; + } + + // Add the token to both the session and cookie to cover + // both types of CSRF tokens. We generate the token with the cookie + // middleware as cookie tokens will be accepted by session csrf, but not + // the inverse. + $this->_session[$this->_csrfKeyName] = $token; + $this->_cookie[$this->_csrfKeyName] = $token; + if (!isset($data['_csrfToken']) && !in_array($method, ['GET', 'OPTIONS'], true)) { + $data['_csrfToken'] = $token; + } + } + + return $data; + } + + /** + * Recursively casts all data to string as that is how data would be POSTed in + * the real world + * + * @param array $data POST data + * @return array + */ + protected function _castToString(array $data): array + { + foreach ($data as $key => $value) { + if (is_scalar($value)) { + $data[$key] = $value === false ? '0' : (string)$value; + + continue; + } + + if (is_array($value)) { + $looksLikeFile = isset($value['error'], $value['tmp_name'], $value['size']); + if ($looksLikeFile) { + continue; + } + + $data[$key] = $this->_castToString($value); + } + } + + return $data; + } + + /** + * Creates a valid request url and parameter array more like Request::_url() + * + * @param string $url The URL + * @return array Qualified URL, the query parameters, and host data + */ + protected function _url(string $url): array + { + $uri = new Uri($url); + $path = $uri->getPath(); + $query = $uri->getQuery(); + + $hostData = []; + if ($uri->getHost()) { + $hostData['host'] = $uri->getHost(); + } + if ($uri->getScheme()) { + $hostData['https'] = $uri->getScheme() === 'https'; + } + + return [$path, $query, $hostData]; + } + + /** + * Get the response body as string + * + * @return string The response body. + */ + protected function _getBodyAsString(): string + { + if (!$this->_response) { + $this->fail('No response set, cannot assert content.'); + } + + return (string)$this->_response->getBody(); + } + + /** + * Fetches a view variable by name. + * + * If the view variable does not exist, null will be returned. + * + * @param string $name The view variable to get. + * @return mixed The view variable if set. + */ + public function viewVariable(string $name): mixed + { + return $this->_controller?->viewBuilder()->getVar($name); + } + + /** + * Asserts that the response status code is in the 2xx range. + * + * @param string $message Custom message for failure. + * @return void + */ + public function assertResponseOk(string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat(null, new StatusOk($this->_response), $verboseMessage); + } + + /** + * Asserts that the response status code is in the 2xx/3xx range. + * + * @param string $message Custom message for failure. + * @return void + */ + public function assertResponseSuccess(string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat(null, new StatusSuccess($this->_response), $verboseMessage); + } + + /** + * Asserts that the response status code is in the 4xx range. + * + * @param string $message Custom message for failure. + * @return void + */ + public function assertResponseError(string $message = ''): void + { + $this->assertThat(null, new StatusError($this->_response), $message); + } + + /** + * Asserts that the response status code is in the 5xx range. + * + * @param string $message Custom message for failure. + * @return void + */ + public function assertResponseFailure(string $message = ''): void + { + $this->assertThat(null, new StatusFailure($this->_response), $message); + } + + /** + * Asserts a specific response status code. + * + * @param int $code Status code to assert. + * @param string $message Custom message for failure. + * @return void + */ + public function assertResponseCode(int $code, string $message = ''): void + { + $this->assertThat($code, new StatusCode($this->_response), $message); + } + + /** + * Asserts that the Location header is correct. + * + * This method normalizes both the expected URL and Location header value to absolute URLs + * for comparison. This accommodates differences between authentication plugins and core + * framework behavior, where some parts return relative URLs and others return absolute URLs. + * + * @param array|string|null $url The URL you expected the client to go to. This + * can either be a string URL or an array compatible with Router::url(). Use null to + * simply check for the existence of this header. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertRedirect(array|string|null $url = null, string $message = ''): void + { + if (!$this->_response) { + $this->fail('No response set, cannot assert header.'); + } + + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat(null, new HeaderSet($this->_response, 'Location'), $verboseMessage); + + if ($url) { + // Normalize both URLs to absolute for comparison + $expectedUrl = Router::url($url, true); + $actualUrl = Router::url($this->_response->getHeaderLine('Location'), true); + + // Create a response with the normalized URL for proper error messages + $tempResponse = $this->_response->withHeader('Location', $actualUrl); + + $this->assertThat( + $expectedUrl, + new HeaderEquals($tempResponse, 'Location'), + $verboseMessage, + ); + } + } + + /** + * Assert whether the response is redirecting back to the previous location. + * + * @param int|null $code Specific status code to validate against, defaults to success (2xx-3xx) range. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertRedirectBack(?int $code = null, string $message = ''): void + { + if (!$this->_response) { + $this->fail('No response set, cannot assert header.'); + } + + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat(null, new HeaderSet($this->_response, 'Location'), $verboseMessage); + if ($code !== null) { + $this->assertThat($code, new StatusCode($this->_response), $message); + } else { + $this->assertThat(null, new StatusSuccess($this->_response), $verboseMessage); + } + + $url = $this->_request['url'] ?? null; + if (!$url) { + $this->fail('No `url` set in request, cannot assert header.'); + } + + $this->assertThat( + Router::url($url, true), + new HeaderEquals($this->_response, 'Location'), + $verboseMessage, + ); + } + + /** + * Assert whether the response is redirecting back to the referer. + * + * @param int|null $code Specific status code to validate against, defaults to success (2xx-3xx) range. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertRedirectBackToReferer(?int $code = null, string $message = ''): void + { + if (!$this->_response) { + $this->fail('No response set, cannot assert header.'); + } + + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat(null, new HeaderSet($this->_response, 'Location'), $verboseMessage); + if ($code !== null) { + $this->assertThat($code, new StatusCode($this->_response), $message); + } else { + $this->assertThat(null, new StatusSuccess($this->_response), $verboseMessage); + } + + $referer = $this->_request['environment']['HTTP_REFERER'] ?? null; + if (!$referer) { + $this->fail('No `HTTP_REFERER` set in request environment, cannot assert header.'); + } + + $this->assertThat( + Router::url($referer, true), + new HeaderEquals($this->_response, 'Location'), + $verboseMessage, + ); + } + + /** + * Asserts that the Location header is correct. Comparison is made against exactly the URL provided. + * + * @param array|string|null $url The URL you expected the client to go to. This + * can either be a string URL or an array compatible with Router::url(). Use null to + * simply check for the existence of this header. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertRedirectEquals(array|string|null $url = null, string $message = ''): void + { + if (!$this->_response) { + $this->fail('No response set, cannot assert header.'); + } + + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat(null, new HeaderSet($this->_response, 'Location'), $verboseMessage); + + if ($url) { + // Normalize both URLs to absolute for comparison + $expectedUrl = Router::url($url, true); + $actualUrl = Router::url($this->_response->getHeaderLine('Location'), true); + + // Create a response with the normalized URL for proper error messages + $tempResponse = $this->_response->withHeader('Location', $actualUrl); + + $this->assertThat( + $expectedUrl, + new HeaderEquals($tempResponse, 'Location'), + $verboseMessage, + ); + } + } + + /** + * Asserts that the Location header contains a substring + * + * @param string $url The URL you expected the client to go to. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertRedirectContains(string $url, string $message = ''): void + { + if (!$this->_response) { + $this->fail('No response set, cannot assert header.'); + } + + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat(null, new HeaderSet($this->_response, 'Location'), $verboseMessage); + $this->assertThat($url, new HeaderContains($this->_response, 'Location'), $verboseMessage); + } + + /** + * Asserts that the Location header does not contain a substring + * + * @param string $url The URL you expected the client to go to. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertRedirectNotContains(string $url, string $message = ''): void + { + if (!$this->_response) { + $this->fail('No response set, cannot assert header.'); + } + + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat(null, new HeaderSet($this->_response, 'Location'), $verboseMessage); + $this->assertThat($url, new HeaderNotContains($this->_response, 'Location'), $verboseMessage); + } + + /** + * Asserts that the Location header is not set. + * + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertNoRedirect(string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat(null, new HeaderNotSet($this->_response, 'Location'), $verboseMessage); + } + + /** + * Asserts response headers + * + * @param string $header The header to check + * @param string $content The content to check for. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertHeader(string $header, string $content, string $message = ''): void + { + if (!$this->_response) { + $this->fail('No response set, cannot assert header.'); + } + + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat(null, new HeaderSet($this->_response, $header), $verboseMessage); + $this->assertThat($content, new HeaderEquals($this->_response, $header), $verboseMessage); + } + + /** + * Asserts response header contains a string + * + * @param string $header The header to check + * @param string $content The content to check for. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertHeaderContains(string $header, string $content, string $message = ''): void + { + if (!$this->_response) { + $this->fail('No response set, cannot assert header.'); + } + + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat(null, new HeaderSet($this->_response, $header), $verboseMessage); + $this->assertThat($content, new HeaderContains($this->_response, $header), $verboseMessage); + } + + /** + * Asserts response header does not contain a string + * + * @param string $header The header to check + * @param string $content The content to check for. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertHeaderNotContains(string $header, string $content, string $message = ''): void + { + if (!$this->_response) { + $this->fail('No response set, cannot assert header.'); + } + + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat(null, new HeaderSet($this->_response, $header), $verboseMessage); + $this->assertThat($content, new HeaderNotContains($this->_response, $header), $verboseMessage); + } + + /** + * Asserts content type + * + * @param string $type The content-type to check for. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertContentType(string $type, string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat($type, new ContentType($this->_response), $verboseMessage); + } + + /** + * Asserts content in the response body equals. + * + * @param mixed $content The content to check for. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertResponseEquals(mixed $content, string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + if ($this->isDebug()) { + $verboseMessage .= $this->responseBody(); + } + $this->assertThat($content, new BodyEquals($this->_response), $verboseMessage); + } + + /** + * Asserts content in the response body not equals. + * + * @param mixed $content The content to check for. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertResponseNotEquals(mixed $content, string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + if ($this->isDebug()) { + $verboseMessage .= $this->responseBody(); + } + $this->assertThat($content, new BodyNotEquals($this->_response), $verboseMessage); + } + + /** + * Asserts content exists in the response body. + * + * @param string $content The content to check for. + * @param string $message The failure message that will be appended to the generated message. + * @param bool $ignoreCase A flag to check whether we should ignore case or not. + * @return void + */ + public function assertResponseContains(string $content, string $message = '', bool $ignoreCase = false): void + { + if (!$this->_response) { + $this->fail('No response set, cannot assert content.'); + } + + $verboseMessage = $this->extractVerboseMessage($message); + if ($this->isDebug()) { + $verboseMessage .= $this->responseBody(); + } + $this->assertThat($content, new BodyContains($this->_response, $ignoreCase), $verboseMessage); + } + + /** + * Asserts content does not exist in the response body. + * + * @param string $content The content to check for. + * @param string $message The failure message that will be appended to the generated message. + * @param bool $ignoreCase A flag to check whether we should ignore case or not. + * @return void + */ + public function assertResponseNotContains(string $content, string $message = '', bool $ignoreCase = false): void + { + if (!$this->_response) { + $this->fail('No response set, cannot assert content.'); + } + + $verboseMessage = $this->extractVerboseMessage($message); + if ($this->isDebug()) { + $verboseMessage .= $this->responseBody(); + } + $this->assertThat($content, new BodyNotContains($this->_response, $ignoreCase), $verboseMessage); + } + + /** + * Asserts that the response body matches a given regular expression. + * + * @param string $pattern The pattern to compare against. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertResponseRegExp(string $pattern, string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + if ($this->isDebug()) { + $verboseMessage .= $this->responseBody(); + } + $this->assertThat($pattern, new BodyRegExp($this->_response), $verboseMessage); + } + + /** + * Asserts that the response body does not match a given regular expression. + * + * @param string $pattern The pattern to compare against. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertResponseNotRegExp(string $pattern, string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + if ($this->isDebug()) { + $verboseMessage .= $this->responseBody(); + } + $this->assertThat($pattern, new BodyNotRegExp($this->_response), $verboseMessage); + } + + /** + * Assert response content is not empty. + * + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertResponseNotEmpty(string $message = ''): void + { + if ($this->isDebug()) { + $message .= $this->responseBody(); + } + $this->assertThat(null, new BodyNotEmpty($this->_response), $message); + } + + /** + * Assert response content is empty. + * + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertResponseEmpty(string $message = ''): void + { + if ($this->isDebug()) { + $message .= $this->responseBody(); + } + $this->assertThat(null, new BodyEmpty($this->_response), $message); + } + + /** + * Asserts that the search string was in the template name. + * + * @param string $content The content to check for. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertTemplate(string $content, string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat($content, new TemplateFileEquals($this->_viewName), $verboseMessage); + } + + /** + * Asserts that the search string was in the layout name. + * + * @param string $content The content to check for. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertLayout(string $content, string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat($content, new LayoutFileEquals($this->_layoutName), $verboseMessage); + } + + /** + * Asserts session contents + * + * @param mixed $expected The expected contents. + * @param string $path The session data path. Uses Hash::get() compatible notation + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertSession(mixed $expected, string $path, string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat($expected, new SessionEquals($path), $verboseMessage); + } + + /** + * Asserts session key exists. + * + * @param string $path The session data path. Uses Hash::get() compatible notation. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertSessionHasKey(string $path, string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat($path, new SessionHasKey($path), $verboseMessage); + } + + /** + * Asserts a session key does not exist. + * + * @param string $path The session data path. Uses Hash::get() compatible notation. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertSessionNotHasKey(string $path, string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat($path, $this->logicalNot(new SessionHasKey($path)), $verboseMessage); + } + + /** + * Asserts a flash message was set + * + * @param string $expected Expected message + * @param string $key Flash key + * @param string $message Assertion failure message + * @return void + */ + public function assertFlashMessage(string $expected, string $key = 'flash', string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat($expected, new FlashParamEquals($this->_requestSession, $key, 'message'), $verboseMessage); + } + + /** + * Asserts a flash message was set at a certain index + * + * @param int $at Flash index + * @param string $expected Expected message + * @param string $key Flash key + * @param string $message Assertion failure message + * @return void + */ + public function assertFlashMessageAt(int $at, string $expected, string $key = 'flash', string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat( + $expected, + new FlashParamEquals($this->_requestSession, $key, 'message', $at), + $verboseMessage, + ); + } + + /** + * Asserts a flash message contains a substring + * + * @param string $expected Expected substring in message + * @param string $key Flash key + * @param string $message Assertion failure message + * @param bool $ignoreCase Whether to ignore case + * @return void + */ + public function assertFlashMessageContains( + string $expected, + string $key = 'flash', + string $message = '', + bool $ignoreCase = false, + ): void { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat( + $expected, + new FlashParamContains($this->_requestSession, $key, 'message', null, $ignoreCase), + $verboseMessage, + ); + } + + /** + * Asserts a flash message contains a substring at a certain index + * + * @param int $at Flash index + * @param string $expected Expected substring in message + * @param string $key Flash key + * @param string $message Assertion failure message + * @param bool $ignoreCase Whether to ignore case + * @return void + */ + public function assertFlashMessageContainsAt( + int $at, + string $expected, + string $key = 'flash', + string $message = '', + bool $ignoreCase = false, + ): void { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat( + $expected, + new FlashParamContains($this->_requestSession, $key, 'message', $at, $ignoreCase), + $verboseMessage, + ); + } + + /** + * Asserts a flash element was set + * + * @param string $expected Expected element name + * @param string $key Flash key + * @param string $message Assertion failure message + * @return void + */ + public function assertFlashElement(string $expected, string $key = 'flash', string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat( + $expected, + new FlashParamEquals($this->_requestSession, $key, 'element'), + $verboseMessage, + ); + } + + /** + * Asserts a flash element was set at a certain index + * + * @param int $at Flash index + * @param string $expected Expected element name + * @param string $key Flash key + * @param string $message Assertion failure message + * @return void + */ + public function assertFlashElementAt(int $at, string $expected, string $key = 'flash', string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat( + $expected, + new FlashParamEquals($this->_requestSession, $key, 'element', $at), + $verboseMessage, + ); + } + + /** + * Asserts cookie values + * + * @param mixed $expected The expected contents. + * @param string $name The cookie name. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertCookie(mixed $expected, string $name, string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat($name, new CookieSet($this->_response), $verboseMessage); + $this->assertThat($expected, new CookieEquals($this->_response, $name), $verboseMessage); + } + + /** + * Asserts that a cookie is set. + * + * Useful when you're working with cookies that have obfuscated values + * but the cookie being set is important. + * + * @param string $name The cookie name. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertCookieIsSet(string $name, string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat($name, new CookieSet($this->_response), $verboseMessage); + } + + /** + * Asserts a cookie has not been set in the response + * + * @param string $cookie The cookie name to check + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertCookieNotSet(string $cookie, string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat($cookie, new CookieNotSet($this->_response), $verboseMessage); + } + + /** + * Disable the error handler middleware. + * + * By using this function, exceptions are no longer caught by the ErrorHandlerMiddleware + * and are instead re-thrown by the TestExceptionRenderer. This can be helpful + * when trying to diagnose/debug unexpected failures in test cases. + * + * @return void + */ + public function disableErrorHandlerMiddleware(): void + { + Configure::write('Error.exceptionRenderer', TestExceptionRenderer::class); + } + + /** + * Asserts cookie values which are encrypted by the + * CookieComponent. + * + * The difference from assertCookie() is this decrypts the cookie + * value like the CookieComponent for this assertion. + * + * @param mixed $expected The expected contents. + * @param string $name The cookie name. + * @param string $encrypt Encryption mode to use. + * @param string|null $key Encryption key used. Defaults + * to Security.salt. + * @param string $message The failure message that will be appended to the generated message. + * @return void + * @see \Cake\Utility\CookieCryptTrait::_encrypt() + */ + public function assertCookieEncrypted( + mixed $expected, + string $name, + string $encrypt = 'aes', + ?string $key = null, + string $message = '', + ): void { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat($name, new CookieSet($this->_response), $verboseMessage); + + $this->_cookieEncryptionKey = $key; + $this->assertThat( + $expected, + new CookieEncryptedEquals($this->_response, $name, $encrypt, $this->_getCookieEncryptionKey()), + ); + } + + /** + * Asserts that a file with the given name was sent in the response + * + * @param string $expected The absolute file path that should be sent in the response. + * @param string $message The failure message that will be appended to the generated message. + * @return void + */ + public function assertFileResponse(string $expected, string $message = ''): void + { + $verboseMessage = $this->extractVerboseMessage($message); + $this->assertThat(null, new FileSent($this->_response), $verboseMessage); + $this->assertThat($expected, new FileSentAs($this->_response), $verboseMessage); + + if (!$this->_response) { + return; + } + $this->_response->getBody()->close(); + } + + /** + * Inspect controller to extract possible causes of the failed assertion + * + * @param string $message Original message to use as a base + * @return string + */ + protected function extractVerboseMessage(string $message): string + { + if ($this->_exception instanceof Exception) { + $message .= $this->extractExceptionMessage($this->_exception); + } + if ($this->_controller === null) { + return $message; + } + $error = $this->_controller->viewBuilder()->getVar('error'); + if ($error instanceof Exception) { + $message .= $this->extractExceptionMessage($this->viewVariable('error')); + } + + return $message; + } + + /** + * Extract verbose message for existing exception + * + * @param \Exception $exception Exception to extract + * @return string + */ + protected function extractExceptionMessage(Exception $exception): string + { + $exceptions = [$exception]; + $previous = $exception->getPrevious(); + while ($previous !== null) { + $exceptions[] = $previous; + $previous = $previous->getPrevious(); + } + $message = PHP_EOL; + foreach ($exceptions as $i => $error) { + if ($i === 0) { + $message .= sprintf('Possibly related to `%s`: "%s"', $error::class, $error->getMessage()); + $message .= PHP_EOL; + } else { + $message .= sprintf('Caused by `%s`: "%s"', $error::class, $error->getMessage()); + $message .= PHP_EOL; + } + $message .= $error->getTraceAsString(); + $message .= PHP_EOL; + } + + return $message; + } + + /** + * @return \Cake\TestSuite\TestSession + */ + protected function getSession(): TestSession + { + return new TestSession($_SESSION); + } + + /** + * Checks if debug flag is set. + * + * Flag is set via `--debug`. + * Allows additional stuff like non-mocking when enabling debug. Or displaying of response body. + * + * @return bool Success + */ + protected function isDebug(): bool + { + return !empty($_SERVER['argv']) && in_array('--debug', $_SERVER['argv'], true); + } + + /** + * Debug content of response body. + * + * @return string + */ + protected function responseBody(): string + { + return PHP_EOL . '------' . PHP_EOL . $this->_response->getBody() . PHP_EOL . '------' . PHP_EOL; + } +} diff --git a/src/TestSuite/LegacyCommandRunner.php b/src/TestSuite/LegacyCommandRunner.php deleted file mode 100644 index 9674c9f74c0..00000000000 --- a/src/TestSuite/LegacyCommandRunner.php +++ /dev/null @@ -1,42 +0,0 @@ -dispatch(); - } -} diff --git a/src/TestSuite/LegacyRequestDispatcher.php b/src/TestSuite/LegacyRequestDispatcher.php deleted file mode 100644 index 06640b208a9..00000000000 --- a/src/TestSuite/LegacyRequestDispatcher.php +++ /dev/null @@ -1,63 +0,0 @@ -_test = $test; - } - - /** - * Run a request and get the response. - * - * @param array $request The request context to execute. - * @return string|null The generated response. - */ - public function execute($request) - { - $request = new ServerRequest($request); - $response = new Response(); - $dispatcher = DispatcherFactory::create(); - $dispatcher->getEventManager()->on( - 'Dispatcher.invokeController', - ['priority' => 999], - [$this->_test, 'controllerSpy'] - ); - - return $dispatcher->dispatch($request, $response); - } -} diff --git a/src/TestSuite/LegacyShellDispatcher.php b/src/TestSuite/LegacyShellDispatcher.php deleted file mode 100644 index 404b585b061..00000000000 --- a/src/TestSuite/LegacyShellDispatcher.php +++ /dev/null @@ -1,56 +0,0 @@ -_io = $io; - parent::__construct($args, $bootstrap); - } - - /** - * Injects mock and stub io components into the shell - * - * @param string $className Class name - * @param string $shortName Short name - * @return \Cake\Console\Shell - */ - protected function _createShell($className, $shortName) - { - list($plugin) = pluginSplit($shortName); - $instance = new $className($this->_io); - $instance->plugin = trim($plugin, '.'); - - return $instance; - } -} diff --git a/src/TestSuite/LogTestTrait.php b/src/TestSuite/LogTestTrait.php new file mode 100644 index 00000000000..d9aaae042cb --- /dev/null +++ b/src/TestSuite/LogTestTrait.php @@ -0,0 +1,171 @@ + $levelConfig) { + if (is_int($levelName) && is_string($levelConfig)) { + // string value = level name. + Log::setConfig("test-{$levelConfig}", [ + 'className' => 'Array', + 'levels' => [$levelConfig], + ]); + } + if (is_array($levelConfig)) { + $levelConfig['className'] = 'Array'; + $levelConfig['levels'] ??= $levelName; + $name = $levelConfig['name'] ?? "test-{$levelName}"; + Log::setConfig($name, $levelConfig); + } + } + } + + /** + * Ensure that no log messages of a given level were captured by test loggers. + * + * @param string $level The level of the expected message + * @param string $failMsg The error message if the message was not in the log engine + * @return void + */ + public function assertLogAbsent(string $level, string $failMsg = ''): void + { + foreach (Log::configured() as $engineName) { + $engineObj = Log::engine($engineName); + if (!$engineObj instanceof ArrayLog) { + continue; + } + $levels = $engineObj->levels(); + if (in_array($level, $levels)) { + $this->assertEquals(0, count($engineObj->read()), $failMsg); + } + } + } + + /** + * @param string $level The level of the expected message + * @param string $expectedMessage The message which should be inside the log engine + * @param string|null $scope The scope of the expected message. If a message has + * multiple scopes, the provided scope must be within the message's set. + * @param string $failMsg The error message if the message was not in the log engine + * @return void + */ + public function assertLogMessage( + string $level, + string $expectedMessage, + ?string $scope = null, + string $failMsg = '', + ): void { + $this->_expectLogMessage($level, $expectedMessage, $scope, $failMsg); + } + + /** + * @param string $level The level which should receive a log message + * @param string $expectedMessage The message which should be inside the log engine + * @param string|null $scope The scope of the expected message. If a message has + * multiple scopes, the provided scope must be within the message's set. + * @param string $failMsg The error message if the message was not in the log engine + * @return void + */ + public function assertLogMessageContains( + string $level, + string $expectedMessage, + ?string $scope = null, + string $failMsg = '', + ): void { + $this->_expectLogMessage($level, $expectedMessage, $scope, $failMsg, true); + } + + /** + * @param string $level The level which should receive a log message + * @param string $expectedMessage The message which should be inside the log engine + * @param string|null $scope The scope of the expected message. If a message has + * multiple scopes, the provided scope must be within the message's set. + * @param string $failMsg The error message if the message was not in the log engine + * @param bool $contains Flag to decide if the expectedMessage can only be part of the logged message + * @return void + */ + protected function _expectLogMessage( + string $level, + string $expectedMessage, + ?string $scope, + string $failMsg = '', + bool $contains = false, + ): void { + $messageFound = false; + $levelPrefix = $level . ': '; + foreach (Log::configured() as $engineName) { + $engineObj = Log::engine($engineName); + if (!$engineObj instanceof ArrayLog) { + continue; + } + $messages = $engineObj->read(); + $engineScopes = (array)$engineObj->scopes(); + // No overlapping scopes + if ($scope !== null && !in_array($scope, $engineScopes, true)) { + continue; + } + foreach ($messages as $message) { + if (!str_starts_with($message, $levelPrefix)) { + continue; + } + $loggedMessage = substr($message, strlen($levelPrefix)); + $matches = $contains + ? str_contains($loggedMessage, $expectedMessage) + : $loggedMessage === $expectedMessage; + if ($matches) { + $messageFound = true; + break; + } + } + } + if (!$messageFound) { + $failMsg = "Could not find the message `{$expectedMessage}` for level `{$level}` in logs. " . $failMsg; + $this->fail($failMsg); + } + $this->assertTrue(true); + } +} diff --git a/src/TestSuite/MiddlewareDispatcher.php b/src/TestSuite/MiddlewareDispatcher.php index e3a77cd638e..51e053921cc 100644 --- a/src/TestSuite/MiddlewareDispatcher.php +++ b/src/TestSuite/MiddlewareDispatcher.php @@ -1,4 +1,6 @@ app = $app; + } /** - * Constructor + * Resolve the provided URL into a string. * - * @param \Cake\TestSuite\IntegrationTestCase $test The test case to run. - * @param string|null $class The application class name. Defaults to App\Application. - * @param array|null $constructorArgs The constructor arguments for your application class. - * Defaults to `['./config']` + * @param array|string $url The URL array/string to resolve. + * @return string + * @deprecated 5.1.0 Use IntegrationTestTrait::resolveUrl() instead. */ - public function __construct($test, $class = null, $constructorArgs = null) + public function resolveUrl(array|string $url): string { - $this->_test = $test; - $this->_class = $class ?: Configure::read('App.namespace') . '\Application'; - $this->_constructorArgs = $constructorArgs ?: [CONFIG]; + deprecationWarning( + '5.1.0', + 'MiddlewareDispatcher::resolveUrl() is deprecated. Use IntegrationTestTrait::resolveUrl() instead.', + ); + + // If we need to resolve a Route URL but there are no routes, load routes. + if (is_array($url) && Router::getRouteCollection()->routes() === []) { + return $this->resolveRoute($url); + } + + return Router::url($url); } /** - * Run a request and get the response. + * Convert a URL array into a string URL via routing. * - * @param \Cake\Http\ServerRequest $request The request to execute. - * @return \Psr\Http\Message\ResponseInterface The generated response. + * @param array $url The url to resolve + * @return string + * @deprecated 5.1.0 Use IntegrationTestTrait::resolveRouter() instead. */ - public function execute($request) + protected function resolveRoute(array $url): string { - try { - $reflect = new ReflectionClass($this->_class); - $app = $reflect->newInstanceArgs($this->_constructorArgs); - } catch (ReflectionException $e) { - throw new LogicException(sprintf( - 'Cannot load "%s" for use in integration testing.', - $this->_class - )); + deprecationWarning( + '5.1.0', + 'MiddlewareDispatcher::resolveRoute() is deprecated. Use IntegrationTestTrait::resolveRoute() instead.', + ); + + // Simulate application bootstrap and route loading. + // We need both to ensure plugins are loaded. + $this->app->bootstrap(); + if ($this->app instanceof PluginApplicationInterface) { + $this->app->pluginBootstrap(); } + $builder = Router::createRouteBuilder('/'); - // Spy on the controller using the initialize hook instead - // of the dispatcher hooks as those will be going away one day. - EventManager::instance()->on( - 'Controller.initialize', - [$this->_test, 'controllerSpy'] - ); + if ($this->app instanceof RoutingApplicationInterface) { + $this->app->routes($builder); + } + if ($this->app instanceof PluginApplicationInterface) { + $this->app->pluginRoutes($builder); + } - $server = new Server($app); - $psrRequest = $this->_createRequest($request); + $out = Router::url($url); + Router::resetRoutes(); - return $server->run($psrRequest); + return $out; } /** * Create a PSR7 request from the request spec. * - * @param array $spec The request spec. - * @return \Psr\Http\Message\RequestInterface + * @param array $spec The request spec. + * @return \Cake\Http\ServerRequest */ - protected function _createRequest($spec) + protected function _createRequest(array $spec): ServerRequest { if (isset($spec['input'])) { $spec['post'] = []; + $spec['environment']['CAKEPHP_INPUT'] = $spec['input']; + } + $environment = array_merge( + array_merge($_SERVER, ['REQUEST_URI' => $spec['url']]), + $spec['environment'], + ); + /** @phpstan-ignore offsetAccess.notFound */ + if (str_contains($environment['PHP_SELF'], 'phpunit')) { + $environment['PHP_SELF'] = '/'; } $request = ServerRequestFactory::fromGlobals( - array_merge($_SERVER, $spec['environment'], ['REQUEST_URI' => $spec['url']]), + $environment, $spec['query'], $spec['post'], - $spec['cookies'] + $spec['cookies'], + $spec['files'], ); - $request = $request->withAttribute('session', $spec['session']); - if (isset($spec['input'])) { - $stream = new Stream('php://memory', 'rw'); - $stream->write($spec['input']); - $stream->rewind(); - $request = $request->withBody($stream); - } + return $request + ->withAttribute('session', $spec['session']) + ->withAttribute('flash', new FlashMessage($spec['session'])); + } + + /** + * Run a request and get the response. + * + * @param array $requestSpec The request spec to execute. + * @return \Psr\Http\Message\ResponseInterface The generated response. + * @throws \LogicException + */ + public function execute(array $requestSpec): ResponseInterface + { + $server = new Server($this->app); - return $request; + return $server->run($this->_createRequest($requestSpec)); } } diff --git a/src/TestSuite/PHPUnitConsecutiveTrait.php b/src/TestSuite/PHPUnitConsecutiveTrait.php new file mode 100644 index 00000000000..5c3f1b8d97a --- /dev/null +++ b/src/TestSuite/PHPUnitConsecutiveTrait.php @@ -0,0 +1,65 @@ + $argument) { + yield new Callback( + static function (mixed $actualArgument) use ( + $argumentList, + &$mockedMethodCall, + &$callbackCall, + $index, + $numberOfArguments, + ): bool { + $expected = $argumentList[$index][$mockedMethodCall] ?? null; + + $callbackCall++; + $mockedMethodCall = (int)($callbackCall / $numberOfArguments); + + if ($expected instanceof Constraint) { + self::assertThat($actualArgument, $expected); + } else { + self::assertEquals($expected, $actualArgument); + } + + return true; + }, + ); + } + } +} diff --git a/src/TestSuite/StringCompareTrait.php b/src/TestSuite/StringCompareTrait.php index b6a939090d6..68663e9d567 100644 --- a/src/TestSuite/StringCompareTrait.php +++ b/src/TestSuite/StringCompareTrait.php @@ -1,4 +1,6 @@ _compareBasePath . $path; } - if ($this->_updateComparisons === null) { - $this->_updateComparisons = env('UPDATE_TEST_COMPARISON_FILES'); - } + $this->_updateComparisons ??= (bool)env('UPDATE_TEST_COMPARISON_FILES'); if ($this->_updateComparisons) { - $file = new File($path, true); - $file->write($result); + file_put_contents($path, $result); } $expected = file_get_contents($path); - $this->assertTextEquals($expected, $result); + $this->assertTextEquals($expected, $result, 'Content does not match file ' . $path); } } diff --git a/src/TestSuite/Stub/ConsoleOutput.php b/src/TestSuite/Stub/ConsoleOutput.php deleted file mode 100644 index f9c2b76b06b..00000000000 --- a/src/TestSuite/Stub/ConsoleOutput.php +++ /dev/null @@ -1,71 +0,0 @@ -_out[] = $line; - } - - $newlines--; - while ($newlines > 0) { - $this->_out[] = ''; - $newlines--; - } - } - - /** - * Get the buffered output. - * - * @return array - */ - public function messages() - { - return $this->_out; - } -} diff --git a/src/TestSuite/Stub/Response.php b/src/TestSuite/Stub/Response.php deleted file mode 100644 index 1d8b5125234..00000000000 --- a/src/TestSuite/Stub/Response.php +++ /dev/null @@ -1,38 +0,0 @@ -hasHeader('Location') && $this->_status === 200) { - $this->statusCode(302); - } - $this->_setContentType(); - - return $this; - } -} diff --git a/src/TestSuite/Stub/TestExceptionRenderer.php b/src/TestSuite/Stub/TestExceptionRenderer.php index 5d194cf1d68..e26f91b1836 100644 --- a/src/TestSuite/Stub/TestExceptionRenderer.php +++ b/src/TestSuite/Stub/TestExceptionRenderer.php @@ -1,4 +1,6 @@ */ - public $fixtureManager; + protected array $fixtures = []; /** - * By default, all fixtures attached to this class will be truncated and reloaded after each test. - * Set this to false to handle manually - * - * @var bool + * @var \Cake\TestSuite\Fixture\FixtureStrategyInterface|null */ - public $autoFixtures = true; + protected ?FixtureStrategyInterface $fixtureStrategy = null; /** - * Control table create/drops on each test method. - * - * If true, tables will still be dropped at the - * end of each test runner execution. + * Configure values to restore at end of test. * - * @var bool + * @var array */ - public $dropTables = false; + protected array $_configure = []; /** - * Configure values to restore at end of test. + * Plugins to be loaded after app instance is created ContainerStubTrait::creatApp() * * @var array */ - protected $_configure = []; + protected array $appPluginsToLoad = []; /** - * Path settings to restore at the end of the test. - * - * @var array + * @var \Cake\Error\PhpError|null */ - protected $_pathRestore = []; + private ?PhpError $_capturedError = null; /** * Overrides SimpleTestCase::skipIf to provide a boolean return value * - * @param bool $shouldSkip Whether or not the test should be skipped. + * @param bool $shouldSkip Whether the test should be skipped. * @param string $message The message to display. * @return bool */ - public function skipIf($shouldSkip, $message = '') + public function skipIf(bool $shouldSkip, string $message = ''): bool { if ($shouldSkip) { $this->markTestSkipped($message); @@ -87,6 +102,136 @@ public function skipIf($shouldSkip, $message = '') return $shouldSkip; } + /** + * Helper method for tests that needs to use error_reporting() + * + * @param int $errorLevel value of error_reporting() that needs to use + * @param callable $callable callable function that will receive asserts + * @return void + */ + public function withErrorReporting(int $errorLevel, callable $callable): void + { + $default = error_reporting(); + error_reporting($errorLevel); + try { + $callable(); + } finally { + error_reporting($default); + } + } + + /** + * Capture errors from $callable so that you can do assertions on the error. + * + * If no error is captured an assertion will fail. + * + * @param int $errorLevel The value of error_reporting() to use. + * @param \Closure $callable A closure to capture errors from. + * @return \Cake\Error\PhpError The captured error. + */ + public function captureError(int $errorLevel, Closure $callable): PhpError + { + $default = error_reporting(); + error_reporting($errorLevel); + + $this->_capturedError = null; + set_error_handler( + function (int $code, string $description, string $file, int $line) { + $trace = Debugger::trace(['start' => 1, 'format' => 'points']); + assert(is_array($trace)); + $this->_capturedError = new PhpError($code, $description, $file, $line, $trace); + + return true; + }, + $errorLevel, + ); + + try { + $callable(); + } finally { + restore_error_handler(); + error_reporting($default); + } + if ($this->_capturedError === null) { + $this->fail('No error was captured'); + } + /** @var \Cake\Error\PhpError $this->_capturedError */ + return $this->_capturedError; + } + + /** + * Helper method for check deprecation methods + * + * @param \Closure $callable callable function that will receive asserts. + * @param int $type Error level to expect, E_DEPRECATED or E_USER_DEPRECATED. + * @param string|null $phpVersion If set, only applies to this version forward, e.g. `8.4`. + * @return void + */ + public function deprecated(Closure $callable, int $type = E_USER_DEPRECATED, ?string $phpVersion = null): void + { + if ($phpVersion !== null && version_compare(PHP_VERSION, $phpVersion, '<')) { + $callable(); + + return; + } + + $duplicate = Configure::read('Error.allowDuplicateDeprecations'); + Configure::write('Error.allowDuplicateDeprecations', true); + /** @var bool $deprecation Expand type for psalm */ + $deprecation = false; + + $previousHandler = set_error_handler( + function ( + $code, + $message, + $file, + $line, + $context = null, + ) use ( + &$previousHandler, + &$deprecation, + $type, + ): bool { + if ($code === $type) { + $deprecation = true; + + return true; + } + if ($previousHandler) { + return $previousHandler($code, $message, $file, $line, $context); + } + + return false; + }, + ); + try { + $callable(); + } finally { + restore_error_handler(); + if ($duplicate !== Configure::read('Error.allowDuplicateDeprecations')) { + Configure::write('Error.allowDuplicateDeprecations', $duplicate); + } + } + $this->assertTrue($deprecation, 'Should have at least one deprecation warning'); + } + + /** + * This method is called between test and tearDown(). + * + * Gets the count of expectations on the mocks produced through Mockery. + * + * @return void + */ + protected function assertPostConditions(): void + { + parent::assertPostConditions(); + + if (class_exists(Mockery::class)) { + // @phpstan-ignore method.internal, argument.type + $this->addToAssertionCount(Mockery::getContainer()->mockery_getExpectationCount()); + } + } + /** * Setup the test case, backup the static object values so they can be restored. * Specifically backs up the contents of Configure and paths in App if they have @@ -94,18 +239,25 @@ public function skipIf($shouldSkip, $message = '') * * @return void */ - public function setUp() + protected function setUp(): void { parent::setUp(); + $this->setupFixtures(); if (!$this->_configure) { $this->_configure = Configure::read(); } - if (class_exists('Cake\Routing\Router', false)) { + if (class_exists(Router::class, false)) { Router::reload(); } EventManager::instance(new EventManager()); + + /** @var int|false $errorLevelOverwrite */ + $errorLevelOverwrite = Configure::read('TestSuite.errorLevel', E_ALL); + if ($errorLevelOverwrite !== false) { + error_reporting($errorLevelOverwrite); + } } /** @@ -113,53 +265,236 @@ public function setUp() * * @return void */ - public function tearDown() + protected function tearDown(): void { parent::tearDown(); + $this->teardownFixtures(); + if ($this->_configure) { Configure::clear(); Configure::write($this->_configure); } - TableRegistry::clear(); + $this->getTableLocator()->clear(); + $this->_configure = []; + $this->_tableLocator = null; + if (class_exists(Mockery::class)) { + Mockery::close(); + } } /** - * Chooses which fixtures to load for a given test + * Initialized and loads any use fixtures. * - * Each parameter is a model name that corresponds to a fixture, i.e. 'Posts', 'Authors', etc. - * Passing no parameters will cause all fixtures on the test case to load. + * @return void + */ + protected function setupFixtures(): void + { + $fixtureNames = $this->getFixtures(); + + $this->fixtureStrategy = $this->getFixtureStrategy(); + $this->fixtureStrategy->setupTest($fixtureNames); + } + + /** + * Unloads any use fixtures. + * + * @return void + */ + protected function teardownFixtures(): void + { + if ($this->fixtureStrategy) { + $this->fixtureStrategy->teardownTest(); + $this->fixtureStrategy = null; + } + } + + /** + * Returns fixture strategy used by these tests. + * + * @return \Cake\TestSuite\Fixture\FixtureStrategyInterface + */ + protected function getFixtureStrategy(): FixtureStrategyInterface + { + /** @var class-string<\Cake\TestSuite\Fixture\FixtureStrategyInterface> $className */ + $className = Configure::read('TestSuite.fixtureStrategy') ?: TruncateStrategy::class; + + return new $className(); + } + + /** + * Load routes for the application. + * + * If no application class can be found an exception will be raised. + * Routes for plugins will *not* be loaded. Use `loadPlugins()` or use + * `Cake\TestSuite\IntegrationTestCaseTrait` to better simulate all routes + * and plugins being loaded. * + * @param array|null $appArgs Constructor parameters for the application class. * @return void - * @see \Cake\TestSuite\TestCase::$autoFixtures - * @throws \Exception when no fixture manager is available. + * @since 4.0.1 + */ + public function loadRoutes(?array $appArgs = null): void + { + $appArgs ??= [rtrim(CONFIG, DIRECTORY_SEPARATOR)]; + /** @var class-string $className */ + $className = Configure::read('App.namespace') . '\\Application'; + try { + $reflect = new ReflectionClass($className); + $app = $reflect->newInstanceArgs($appArgs); + assert($app instanceof RoutingApplicationInterface); + } catch (ReflectionException $e) { + throw new LogicException(sprintf('Cannot load `%s` to load routes from.', $className), 0, $e); + } + $builder = Router::createRouteBuilder('/'); + $app->routes($builder); + } + + /** + * Load plugins into a simulated application. + * + * Useful to test how plugins being loaded/not loaded interact with other + * elements in CakePHP or applications. + * + * @param array $plugins List of Plugins to load. + * @return \Cake\Http\BaseApplication + */ + public function loadPlugins(array $plugins = []): BaseApplication + { + $this->appPluginsToLoad = $plugins; + + $app = new class ('') extends BaseApplication + { + /** + * @param \Cake\Http\MiddlewareQueue $middlewareQueue + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + return $middlewareQueue; + } + }; + + foreach ($plugins as $pluginName => $config) { + if (is_array($config)) { + $app->addPlugin($pluginName, $config); + } else { + $app->addPlugin($config); + } + } + $app->pluginBootstrap(); + $builder = Router::createRouteBuilder('/'); + $app->pluginRoutes($builder); + + return $app; + } + + /** + * Load all plugins from the application's plugins.php config file. + * + * This method allows tests to load all plugins that would normally be loaded + * in the application, ensuring consistent behavior between test and production + * environments. + * + * Use this method in your test's setUp() or in individual test methods when + * you need to test functionality that depends on plugins being loaded. + * + * Example: + * ``` + * public function setUp(): void + * { + * parent::setUp(); + * $this->loadAllPlugins(); + * } + * ``` + * + * Or load from a specific config directory: + * ``` + * $this->loadAllPlugins('/path/to/config/'); + * ``` + * + * @param string|null $configPath The path to the config directory. + * If not provided, uses Configure::read('Test.plugins') or defaults to CONFIG. + * @return $this For method chaining + * @since 5.3.0 */ - public function loadFixtures() + public function loadAllPlugins(?string $configPath = null) { - if ($this->fixtureManager === null) { - throw new Exception('No fixture manager to load the test fixture'); + $plugins = []; + + if ($configPath !== null) { + // Load from specified path + $pluginsFile = rtrim($configPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'plugins.php'; + if (file_exists($pluginsFile)) { + $plugins = require $pluginsFile; + } + } else { + // Try configured plugins first + $plugins = Configure::read('Test.plugins'); + + // Fall back to default CONFIG path + if ($plugins === null && defined('CONFIG')) { + $pluginsFile = CONFIG . 'plugins.php'; + if (file_exists($pluginsFile)) { + /** @phpstan-ignore-next-line */ + $plugins = require $pluginsFile; + } + } } - $args = func_get_args(); - foreach ($args as $class) { - $this->fixtureManager->loadSingle($class, null, $this->dropTables); + + // Ensure we have an array + if (!is_array($plugins)) { + $plugins = []; } - if (empty($args)) { - $autoFixtures = $this->autoFixtures; - $this->autoFixtures = true; - $this->fixtureManager->load($this); - $this->autoFixtures = $autoFixtures; + // If using IntegrationTestTrait, set the plugins to be loaded + /** @phpstan-ignore-next-line */ + if (property_exists($this, 'appPluginsToLoad')) { + $this->appPluginsToLoad = $plugins; + } else { + // Otherwise, use the existing loadPlugins method + $this->loadPlugins($plugins); } + + return $this; + } + + /** + * Remove plugins from the global plugin collection. + * + * Useful in test case teardown methods. + * + * @param array $names A list of plugins you want to remove. + * @return void + */ + public function removePlugins(array $names = []): void + { + $collection = Plugin::getCollection(); + foreach ($names as $name) { + $collection->remove($name); + } + } + + /** + * Clear all plugins from the global plugin collection. + * + * Useful in test case teardown methods. + * + * @return void + */ + public function clearPlugins(): void + { + Plugin::getCollection()->clear(); } /** * Asserts that a global event was fired. You must track events in your event manager for this assertion to work * * @param string $name Event name - * @param EventManager|null $eventManager Event manager to check, defaults to global event manager + * @param \Cake\Event\EventManager|null $eventManager Event manager to check, defaults to global event manager * @param string $message Assertion failure message * @return void */ - public function assertEventFired($name, $eventManager = null, $message = '') + public function assertEventFired(string $name, ?EventManager $eventManager = null, string $message = ''): void { if (!$eventManager) { $eventManager = EventManager::instance(); @@ -174,13 +509,18 @@ public function assertEventFired($name, $eventManager = null, $message = '') * * @param string $name Event name * @param string $dataKey Data key - * @param string $dataValue Data value - * @param EventManager|null $eventManager Event manager to check, defaults to global event manager + * @param mixed $dataValue Data value + * @param \Cake\Event\EventManager|null $eventManager Event manager to check, defaults to global event manager * @param string $message Assertion failure message * @return void */ - public function assertEventFiredWith($name, $dataKey, $dataValue, $eventManager = null, $message = '') - { + public function assertEventFiredWith( + string $name, + string $dataKey, + mixed $dataValue, + ?EventManager $eventManager = null, + string $message = '', + ): void { if (!$eventManager) { $eventManager = EventManager::instance(); } @@ -196,7 +536,7 @@ public function assertEventFiredWith($name, $dataKey, $dataValue, $eventManager * @param string $message The message to use for failure. * @return void */ - public function assertTextNotEquals($expected, $result, $message = '') + public function assertTextNotEquals(string $expected, string $result, string $message = ''): void { $expected = str_replace(["\r\n", "\r"], "\n", $expected); $result = str_replace(["\r\n", "\r"], "\n", $result); @@ -212,7 +552,7 @@ public function assertTextNotEquals($expected, $result, $message = '') * @param string $message The message to use for failure. * @return void */ - public function assertTextEquals($expected, $result, $message = '') + public function assertTextEquals(string $expected, string $result, string $message = ''): void { $expected = str_replace(["\r\n", "\r"], "\n", $expected); $result = str_replace(["\r\n", "\r"], "\n", $result); @@ -223,15 +563,16 @@ public function assertTextEquals($expected, $result, $message = '') * Asserts that a string starts with a given prefix, ignoring differences in newlines. * Helpful for doing cross platform tests of blocks of text. * - * @param string $prefix The prefix to check for. + * @param non-empty-string $prefix The prefix to check for. * @param string $string The string to search in. * @param string $message The message to use for failure. * @return void */ - public function assertTextStartsWith($prefix, $string, $message = '') + public function assertTextStartsWith(string $prefix, string $string, string $message = ''): void { $prefix = str_replace(["\r\n", "\r"], "\n", $prefix); $string = str_replace(["\r\n", "\r"], "\n", $string); + $this->assertNotEmpty($prefix); $this->assertStringStartsWith($prefix, $string, $message); } @@ -239,15 +580,16 @@ public function assertTextStartsWith($prefix, $string, $message = '') * Asserts that a string starts not with a given prefix, ignoring differences in newlines. * Helpful for doing cross platform tests of blocks of text. * - * @param string $prefix The prefix to not find. + * @param non-empty-string $prefix The prefix to not find. * @param string $string The string to search. * @param string $message The message to use for failure. * @return void */ - public function assertTextStartsNotWith($prefix, $string, $message = '') + public function assertTextStartsNotWith(string $prefix, string $string, string $message = ''): void { $prefix = str_replace(["\r\n", "\r"], "\n", $prefix); $string = str_replace(["\r\n", "\r"], "\n", $string); + $this->assertNotEmpty($prefix); $this->assertStringStartsNotWith($prefix, $string, $message); } @@ -255,15 +597,16 @@ public function assertTextStartsNotWith($prefix, $string, $message = '') * Asserts that a string ends with a given prefix, ignoring differences in newlines. * Helpful for doing cross platform tests of blocks of text. * - * @param string $suffix The suffix to find. + * @param non-empty-string $suffix The suffix to find. * @param string $string The string to search. * @param string $message The message to use for failure. * @return void */ - public function assertTextEndsWith($suffix, $string, $message = '') + public function assertTextEndsWith(string $suffix, string $string, string $message = ''): void { $suffix = str_replace(["\r\n", "\r"], "\n", $suffix); $string = str_replace(["\r\n", "\r"], "\n", $string); + $this->assertNotEmpty($suffix); $this->assertStringEndsWith($suffix, $string, $message); } @@ -271,15 +614,16 @@ public function assertTextEndsWith($suffix, $string, $message = '') * Asserts that a string ends not with a given prefix, ignoring differences in newlines. * Helpful for doing cross platform tests of blocks of text. * - * @param string $suffix The suffix to not find. + * @param non-empty-string $suffix The suffix to not find. * @param string $string The string to search. * @param string $message The message to use for failure. * @return void */ - public function assertTextEndsNotWith($suffix, $string, $message = '') + public function assertTextEndsNotWith(string $suffix, string $string, string $message = ''): void { $suffix = str_replace(["\r\n", "\r"], "\n", $suffix); $string = str_replace(["\r\n", "\r"], "\n", $string); + $this->assertNotEmpty($suffix); $this->assertStringEndsNotWith($suffix, $string, $message); } @@ -290,14 +634,23 @@ public function assertTextEndsNotWith($suffix, $string, $message = '') * @param string $needle The string to search for. * @param string $haystack The string to search through. * @param string $message The message to display on failure. - * @param bool $ignoreCase Whether or not the search should be case-sensitive. + * @param bool $ignoreCase Whether the search should be case-sensitive. * @return void */ - public function assertTextContains($needle, $haystack, $message = '', $ignoreCase = false) - { + public function assertTextContains( + string $needle, + string $haystack, + string $message = '', + bool $ignoreCase = false, + ): void { $needle = str_replace(["\r\n", "\r"], "\n", $needle); $haystack = str_replace(["\r\n", "\r"], "\n", $haystack); - $this->assertContains($needle, $haystack, $message, $ignoreCase); + + if ($ignoreCase) { + $this->assertStringContainsStringIgnoringCase($needle, $haystack, $message); + } else { + $this->assertStringContainsString($needle, $haystack, $message); + } } /** @@ -307,32 +660,58 @@ public function assertTextContains($needle, $haystack, $message = '', $ignoreCas * @param string $needle The string to search for. * @param string $haystack The string to search through. * @param string $message The message to display on failure. - * @param bool $ignoreCase Whether or not the search should be case-sensitive. + * @param bool $ignoreCase Whether the search should be case-sensitive. * @return void */ - public function assertTextNotContains($needle, $haystack, $message = '', $ignoreCase = false) - { + public function assertTextNotContains( + string $needle, + string $haystack, + string $message = '', + bool $ignoreCase = false, + ): void { $needle = str_replace(["\r\n", "\r"], "\n", $needle); $haystack = str_replace(["\r\n", "\r"], "\n", $haystack); - $this->assertNotContains($needle, $haystack, $message, $ignoreCase); + + if ($ignoreCase) { + $this->assertStringNotContainsStringIgnoringCase($needle, $haystack, $message); + } else { + $this->assertStringNotContainsString($needle, $haystack, $message); + } } /** - * Asserts HTML tags. + * Assert that a string matches SQL with db-specific characters like quotes removed. * - * @param string $string An HTML/XHTML/XML string - * @param array $expected An array, see above - * @param bool $fullDebug Whether or not more verbose output should be used. + * @param string $expected The expected sql + * @param string $actual The sql to compare + * @param string $message The message to display on failure + * @return void + */ + public function assertEqualsSql( + string $expected, + string $actual, + string $message = '', + ): void { + $this->assertEquals($expected, preg_replace('/[`"\[\]]/', '', $actual), $message); + } + + /** + * Assertion for comparing a regex pattern against a query having its identifiers + * quoted. It accepts queries quoted with the characters `<` and `>`. If the third + * parameter is set to true, it will alter the pattern to both accept quoted and + * unquoted queries + * + * @param string $pattern The expected sql pattern + * @param string $actual The sql to compare + * @param bool $optional Whether quote characters (marked with <>) are optional * @return void - * @deprecated 3.0. Use assertHtml() instead. */ - public function assertTags($string, $expected, $fullDebug = false) + public function assertRegExpSql(string $pattern, string $actual, bool $optional = false): void { - trigger_error( - 'assertTags() is deprecated, use assertHtml() instead.', - E_USER_DEPRECATED - ); - $this->assertHtml($expected, $string, $fullDebug); + $optional = $optional ? '?' : ''; + $pattern = str_replace('<', '[`"\[]' . $optional, $pattern); + $pattern = str_replace('>', '[`"\]]' . $optional, $pattern); + $this->assertMatchesRegularExpression('#' . $pattern . '#', $actual); } /** @@ -376,14 +755,14 @@ public function assertTags($string, $expected, $fullDebug = false) * * @param array $expected An array, see above * @param string $string An HTML/XHTML/XML string - * @param bool $fullDebug Whether or not more verbose output should be used. + * @param bool $fullDebug Whether more verbose output should be used. * @return bool */ - public function assertHtml($expected, $string, $fullDebug = false) + public function assertHtml(array $expected, string $string, bool $fullDebug = false): bool { $regex = []; $normalized = []; - foreach ((array)$expected as $key => $val) { + foreach ($expected as $key => $val) { if (!is_numeric($key)) { $normalized[] = [$key => $val]; } else { @@ -396,13 +775,13 @@ public function assertHtml($expected, $string, $fullDebug = false) $tags = (string)$tags; } $i++; - if (is_string($tags) && $tags{0} === '<') { + if (is_string($tags) && str_starts_with($tags, '<')) { $tags = [substr($tags, 1) => []]; } elseif (is_string($tags)) { $tagsTrimmed = preg_replace('/\s+/m', '', $tags); if (preg_match('/^\*?\//', $tags, $match) && $tagsTrimmed !== '//') { - $prefix = [null, null]; + $prefix = ['', '']; if ($match[0] === '*/') { $prefix = ['Anything, ', '.*?']; @@ -414,7 +793,7 @@ public function assertHtml($expected, $string, $fullDebug = false) ]; continue; } - if (!empty($tags) && preg_match('/^preg\:\/(.+)\/$/i', $tags, $matches)) { + if ($tags && preg_match('/^preg\:\/(.+)\/$/i', $tags, $matches)) { $tags = $matches[1]; $type = 'Regex matches'; } else { @@ -422,7 +801,7 @@ public function assertHtml($expected, $string, $fullDebug = false) $type = 'Text equals'; } $regex[] = [ - sprintf('%s "%s"', $type, $tags), + sprintf('%s `%s`', $type, $tags), $tags, $i, ]; @@ -441,28 +820,29 @@ public function assertHtml($expected, $string, $fullDebug = false) $explanations = []; $i = 1; foreach ($attributes as $attr => $val) { - if (is_numeric($attr) && preg_match('/^preg\:\/(.+)\/$/i', $val, $matches)) { + if (is_numeric($attr) && preg_match('/^preg:\/(.+)\/$/i', (string)$val, $matches)) { $attrs[] = $matches[1]; - $explanations[] = sprintf('Regex "%s" matches', $matches[1]); + $explanations[] = sprintf('Regex `%s` matches', $matches[1]); continue; } + $val = (string)$val; $quotes = '["\']'; if (is_numeric($attr)) { $attr = $val; $val = '.+?'; - $explanations[] = sprintf('Attribute "%s" present', $attr); - } elseif (!empty($val) && preg_match('/^preg\:\/(.+)\/$/i', $val, $matches)) { + $explanations[] = sprintf('Attribute `%s` present', $attr); + } elseif ($val && preg_match('/^preg:\/(.+)\/$/i', $val, $matches)) { $val = str_replace( ['.*', '.+'], ['.*?', '.+?'], - $matches[1] + $matches[1], ); $quotes = $val !== $matches[1] ? '["\']' : '["\']?'; - $explanations[] = sprintf('Attribute "%s" matches "%s"', $attr, $val); + $explanations[] = sprintf('Attribute `%s` matches `%s`', $attr, $val); } else { - $explanations[] = sprintf('Attribute "%s" == "%s"', $attr, $val); + $explanations[] = sprintf('Attribute `%s` == `%s`', $attr, $val); $val = preg_quote($val, '/'); } $attrs[] = '[\s]+' . preg_quote($attr, '/') . '=' . $quotes . $val . $quotes; @@ -481,33 +861,46 @@ public function assertHtml($expected, $string, $fullDebug = false) ]; } } + foreach ($regex as $i => $assertion) { $matches = false; if (isset($assertion['attrs'])) { + /** + * @var array $assertion + * @var string $string + */ $string = $this->_assertAttributes($assertion, $string, $fullDebug, $regex); - if ($fullDebug === true && $string === false) { + if ($fullDebug && $string === false) { debug($string, true); debug($regex, true); } continue; } - list($description, $expressions, $itemNum) = $assertion; - $expression = null; + // If 'attrs' is not present then the array is just a regular int-offset one + /** + * @var array $assertion + */ + [$description, $expressions, $itemNum] = $assertion; + $expression = ''; foreach ((array)$expressions as $expression) { $expression = sprintf('/^%s/s', $expression); - if (preg_match($expression, $string, $match)) { + if ($string && preg_match($expression, $string, $match)) { $matches = true; $string = substr($string, strlen($match[0])); break; } } if (!$matches) { - if ($fullDebug === true) { + if ($fullDebug) { debug($string); debug($regex); } - $this->assertRegExp($expression, $string, sprintf('Item #%d / regex #%d failed: %s', $itemNum, $i, $description)); + $this->assertMatchesRegularExpression( + $expression, + (string)$string, + sprintf('Item #%d / regex #%d failed: %s', $itemNum, $i, $description), + ); return false; } @@ -521,14 +914,18 @@ public function assertHtml($expected, $string, $fullDebug = false) /** * Check the attributes as part of an assertTags() check. * - * @param array $assertions Assertions to run. + * @param array $assertions Assertions to run. * @param string $string The HTML string to check. - * @param bool $fullDebug Whether or not more verbose output should be used. + * @param bool $fullDebug Whether more verbose output should be used. * @param array|string $regex Full regexp from `assertHtml` - * @return string|bool + * @return string|false */ - protected function _assertAttributes($assertions, $string, $fullDebug = false, $regex = '') - { + protected function _assertAttributes( + array $assertions, + string $string, + bool $fullDebug = false, + array|string $regex = '', + ): string|false { $asserts = $assertions['attrs']; $explains = $assertions['explains']; do { @@ -544,7 +941,7 @@ protected function _assertAttributes($assertions, $string, $fullDebug = false, $ } } if ($matches === false) { - if ($fullDebug === true) { + if ($fullDebug) { debug($string); debug($regex); } @@ -562,12 +959,12 @@ protected function _assertAttributes($assertions, $string, $fullDebug = false, $ * @param string $path Path separated by "/" slash. * @return string Normalized path separated by DIRECTORY_SEPARATOR. */ - protected function _normalizePath($path) + protected function _normalizePath(string $path): string { return str_replace('/', DIRECTORY_SEPARATOR, $path); } -// @codingStandardsIgnoreStart +// phpcs:disable /** * Compatibility function to test if a value is between an acceptable range. @@ -582,7 +979,7 @@ protected static function assertWithinRange($expected, $result, $margin, $messag { $upper = $result + $margin; $lower = $result - $margin; - static::assertTrue(($expected <= $upper) && ($expected >= $lower), $message); + self::assertTrue(($expected <= $upper) && ($expected >= $lower), $message); } /** @@ -598,7 +995,7 @@ protected static function assertNotWithinRange($expected, $result, $margin, $mes { $upper = $result + $margin; $lower = $result - $margin; - static::assertTrue(($expected > $upper) || ($expected < $lower), $message); + self::assertTrue(($expected > $upper) || ($expected < $lower), $message); } /** @@ -613,7 +1010,7 @@ protected static function assertPathEquals($expected, $result, $message = '') { $expected = str_replace(DIRECTORY_SEPARATOR, '/', $expected); $result = str_replace(DIRECTORY_SEPARATOR, '/', $result); - static::assertEquals($expected, $result, $message); + self::assertEquals($expected, $result, $message); } /** @@ -632,47 +1029,108 @@ protected function skipUnless($condition, $message = '') return $condition; } -// @codingStandardsIgnoreEnd +// phpcs:enable /** - * Mock a model, maintain fixtures and table association + * Mock a model with PHPUnit mocks, maintain fixtures and table association * * @param string $alias The model to get a mock for. - * @param array $methods The list of methods to mock - * @param array $options The config data for the mock's constructor. + * @param array $methods The list of methods to mock + * @param array $options The config data for the mock's constructor. * @throws \Cake\ORM\Exception\MissingTableClassException - * @return \Cake\ORM\Table|\PHPUnit_Framework_MockObject_MockObject + * @return \Cake\ORM\Table|\PHPUnit\Framework\MockObject\MockObject */ - public function getMockForModel($alias, array $methods = [], array $options = []) + public function getMockForModel(string $alias, array $methods = [], array $options = []): Table|MockObject { - if (empty($options['className'])) { - $class = Inflector::camelize($alias); - $className = App::className($class, 'Model/Table', 'Table'); - if (!$className) { - throw new MissingTableClassException([$alias]); + $className = $this->_getTableClassName($alias, $options); + $connectionName = $className::defaultConnectionName(); + $connection = ConnectionManager::get($connectionName); + + $locator = $this->getTableLocator(); + + [, $baseClass] = pluginSplit($alias); + $options += ['alias' => $baseClass, 'connection' => $connection]; + $options += $locator->getConfig($alias); + $reflection = new ReflectionClass($className); + $classMethods = array_map(function (ReflectionMethod $method) { + return $method->name; + }, $reflection->getMethods()); + + $existingMethods = array_intersect($classMethods, $methods); + /** @var list $nonExistingMethods */ + $nonExistingMethods = array_diff($methods, $existingMethods); + + $builder = $this->getMockBuilder($className) + ->setConstructorArgs([$options]); + + if ($existingMethods || !$nonExistingMethods) { + $builder->onlyMethods($existingMethods); + } + + if ($nonExistingMethods) { + trigger_error( + sprintf( + 'Adding non-existent methods (%s) to model `%s` ' . + 'when mocking will not work in future PHPUnit versions.', + implode(',', $nonExistingMethods), + $alias, + ), + E_USER_DEPRECATED, + ); + $builder->addMethods($nonExistingMethods); + } + + $mock = $builder->getMock(); + assert($mock instanceof Table); + + if (empty($options['entityClass']) && $mock->getEntityClass() === Entity::class) { + $parts = explode('\\', $className); + $entityAlias = Inflector::classify(Inflector::underscore(substr(array_pop($parts), 0, -5))); + $entityClass = implode('\\', array_slice($parts, 0, -1)) . '\\Entity\\' . $entityAlias; + if (class_exists($entityClass)) { + $mock->setEntityClass($entityClass); } - $options['className'] = $className; } - $connectionName = $options['className']::defaultConnectionName(); + if (stripos($mock->getTable(), 'mock') === 0) { + $mock->setTable(Inflector::tableize($baseClass)); + } + + $locator->set($baseClass, $mock); + $locator->set($alias, $mock); + + return $mock; + } + + /** + * Mock a model with Mockery mocks, maintain fixtures and table association + * + * @template T of \Cake\ORM\Table + * @param string|class-string $alias The alias or the FQCN of the model to get a mock for. + * @param array $options The config data for the mock's constructor. + * @return (T|\Cake\ORM\Table)&\Mockery\LegacyMockInterface + */ + public function mockModel(string $alias, array $options = []): Table&LegacyMockInterface + { + $className = $this->_getTableClassName($alias, $options); + $connectionName = $className::defaultConnectionName(); $connection = ConnectionManager::get($connectionName); - list(, $baseClass) = pluginSplit($alias); + $locator = $this->getTableLocator(); + + [, $baseClass] = pluginSplit($alias); $options += ['alias' => $baseClass, 'connection' => $connection]; - $options += TableRegistry::config($alias); - - /** @var \Cake\ORM\Table|\PHPUnit_Framework_MockObject_MockObject $mock */ - $mock = $this->getMockBuilder($options['className']) - ->setMethods($methods) - ->setConstructorArgs([$options]) - ->getMock(); - - if (empty($options['entityClass']) && $mock->entityClass() === '\Cake\ORM\Entity') { - $parts = explode('\\', $options['className']); - $entityAlias = Inflector::singularize(substr(array_pop($parts), 0, -5)); - $entityClass = implode('\\', array_slice($parts, 0, -1)) . '\Entity\\' . $entityAlias; + $options += $locator->getConfig($alias); + + $mock = Mockery::mock(new $className($options))->makePartial(); + assert($mock instanceof Table); + + if (empty($options['entityClass']) && $mock->getEntityClass() === Entity::class) { + $parts = explode('\\', $className); + $entityAlias = Inflector::classify(Inflector::underscore(substr(array_pop($parts), 0, -5))); + $entityClass = implode('\\', array_slice($parts, 0, -1)) . '\\Entity\\' . $entityAlias; if (class_exists($entityClass)) { - $mock->entityClass($entityClass); + $mock->setEntityClass($entityClass); } } @@ -680,20 +1138,143 @@ public function getMockForModel($alias, array $methods = [], array $options = [] $mock->setTable(Inflector::tableize($baseClass)); } - TableRegistry::set($baseClass, $mock); - TableRegistry::set($alias, $mock); + $locator->set($baseClass, $mock); + $locator->set($alias, $mock); return $mock; } + /** + * Gets the class name for the table. + * + * @param string $alias The model to get a mock for. + * @param array $options The config data for the mock's constructor. + * @return class-string<\Cake\ORM\Table> + * @throws \Cake\ORM\Exception\MissingTableClassException + */ + protected function _getTableClassName(string $alias, array $options): string + { + if (empty($options['className'])) { + $class = Inflector::camelize($alias); + /** @var class-string<\Cake\ORM\Table>|null $className */ + $className = App::className($class, 'Model/Table', 'Table'); + if (!$className) { + throw new MissingTableClassException([$alias]); + } + $options['className'] = $className; + } + + return $options['className']; + } + /** * Set the app namespace * * @param string $appNamespace The app namespace, defaults to "TestApp". - * @return void + * @return string|null The previous app namespace or null if not set. */ - public static function setAppNamespace($appNamespace = 'TestApp') + public static function setAppNamespace(string $appNamespace = 'TestApp'): ?string { + $previous = Configure::read('App.namespace'); Configure::write('App.namespace', $appNamespace); + + return $previous; + } + + /** + * Adds a fixture to this test case. + * + * Examples: + * - core.Tags + * - app.MyRecords + * - plugin.MyPluginName.MyModelName + * + * Use this method inside your test cases' {@link getFixtures()} method + * to build up the fixture list. + * + * @param string $fixture Fixture + * @return $this + */ + protected function addFixture(string $fixture) + { + $this->fixtures[] = $fixture; + + return $this; + } + + /** + * Get the fixtures this test should use. + * + * @return array + */ + public function getFixtures(): array + { + return $this->fixtures; + } + + /** + * @param string $regex A regex to match against the warning message + * @param \Closure $callable Callable which should trigger the warning + * @return void + * @throws \Exception + */ + public function expectNoticeMessageMatches(string $regex, Closure $callable): void + { + $this->expectErrorHandlerMessageMatches($regex, $callable, E_USER_NOTICE); + } + + /** + * @param string $regex A regex to match against the deprecation message + * @param \Closure $callable Callable which should trigger the warning + * @return void + * @throws \Exception + */ + public function expectDeprecationMessageMatches(string $regex, Closure $callable): void + { + $this->expectErrorHandlerMessageMatches($regex, $callable, E_USER_DEPRECATED); + } + + /** + * @param string $regex A regex to match against the warning message + * @param \Closure $callable Callable which should trigger the warning + * @return void + * @throws \Exception + */ + public function expectWarningMessageMatches(string $regex, Closure $callable): void + { + $this->expectErrorHandlerMessageMatches($regex, $callable, E_USER_WARNING); + } + + /** + * @param string $regex A regex to match against the error message + * @param \Closure $callable Callable which should trigger the warning + * @return void + * @throws \Exception + */ + public function expectErrorMessageMatches(string $regex, Closure $callable): void + { + $this->expectErrorHandlerMessageMatches($regex, $callable, E_ERROR | E_USER_ERROR); + } + + /** + * @param string $regex A regex to match against the warning message + * @param \Closure $callable Callable which should trigger the warning + * @param int $errorLevel The error level to listen to + * @return void + * @throws \Exception + */ + protected function expectErrorHandlerMessageMatches(string $regex, Closure $callable, int $errorLevel): void + { + set_error_handler(static function (int $errno, string $errstr): never { + throw new Exception($errstr, $errno); + }, $errorLevel); + + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches($regex); + try { + $callable(); + } finally { + restore_error_handler(); + } } } diff --git a/src/TestSuite/TestEmailTransport.php b/src/TestSuite/TestEmailTransport.php new file mode 100644 index 00000000000..5ad9a2a6a0a --- /dev/null +++ b/src/TestSuite/TestEmailTransport.php @@ -0,0 +1,86 @@ + + */ + public static function getMessages(): array + { + return static::$messages; + } + + /** + * Clears list of emails that have been sent + * + * @return void + */ + public static function clearMessages(): void + { + static::$messages = []; + } +} diff --git a/src/TestSuite/TestSession.php b/src/TestSuite/TestSession.php new file mode 100644 index 00000000000..8b5f7e77081 --- /dev/null +++ b/src/TestSuite/TestSession.php @@ -0,0 +1,79 @@ +session = $session; + } + + /** + * Returns true if given variable name is set in session. + * + * @param string|null $name Variable name to check for + * @return bool True if variable is there + */ + public function check(?string $name = null): bool + { + if ($this->session === null) { + return false; + } + + if ($name === null) { + return (bool)$this->session; + } + + return Hash::get($this->session, $name) !== null; + } + + /** + * Returns given session variable, or all of them, if no parameters given. + * + * @param string|null $name The name of the session variable (or a path as sent to Hash.extract) + * @return mixed The value of the session variable, null if session not available, + * session not started, or provided name not found in the session. + */ + public function read(?string $name = null): mixed + { + if ($this->session === null) { + return null; + } + + if ($name === null) { + return $this->session ?: []; + } + + return Hash::get($this->session, $name); + } +} diff --git a/src/TestSuite/TestSuite.php b/src/TestSuite/TestSuite.php deleted file mode 100644 index 1c27a5bb2c2..00000000000 --- a/src/TestSuite/TestSuite.php +++ /dev/null @@ -1,65 +0,0 @@ -read(true, true, true); - - foreach ($files as $file) { - if (substr($file, -4) === '.php') { - $this->addTestFile($file); - } - } - } - - /** - * Recursively adds all the files in a directory to the test suite. - * - * @param string $directory The directory subtree to add tests from. - * @return void - */ - public function addTestDirectoryRecursive($directory = '.') - { - $Folder = new Folder($directory); - $files = $Folder->tree(null, true, 'files'); - - foreach ($files as $file) { - if (substr($file, -4) === '.php') { - $this->addTestFile($file); - } - } - } -} diff --git a/src/TestSuite/functions.php b/src/TestSuite/functions.php new file mode 100644 index 00000000000..a1383a470a3 --- /dev/null +++ b/src/TestSuite/functions.php @@ -0,0 +1,62 @@ + */ - protected $_validCiphers = ['aes', 'rijndael']; + protected array $_validCiphers = ['aes']; /** * Returns the encryption key to be used. * * @return string */ - abstract protected function _getCookieEncryptionKey(); + abstract protected function _getCookieEncryptionKey(): string; /** * Encrypts $value using public $type method in Security class * - * @param string $value Value to encrypt - * @param string|bool $encrypt Encryption mode to use. False + * @param array|string $value Value to encrypt + * @param string|false $encrypt Encryption mode to use. False * disabled encryption. * @param string|null $key Used as the security salt if specified. * @return string Encoded values */ - protected function _encrypt($value, $encrypt, $key = null) + protected function _encrypt(array|string $value, string|false $encrypt, ?string $key = null): string { if (is_array($value)) { $value = $this->_implode($value); @@ -59,13 +58,8 @@ protected function _encrypt($value, $encrypt, $key = null) } $this->_checkCipher($encrypt); $prefix = 'Q2FrZQ==.'; - $cipher = null; - if ($key === null) { - $key = $this->_getCookieEncryptionKey(); - } - if ($encrypt === 'rijndael') { - $cipher = Security::rijndael($value, $key, 'encrypt'); - } + $cipher = ''; + $key ??= $this->_getCookieEncryptionKey(); if ($encrypt === 'aes') { $cipher = Security::encrypt($value, $key); } @@ -80,26 +74,26 @@ protected function _encrypt($value, $encrypt, $key = null) * @return void * @throws \RuntimeException When an invalid cipher is provided. */ - protected function _checkCipher($encrypt) + protected function _checkCipher(string $encrypt): void { - if (!in_array($encrypt, $this->_validCiphers)) { + if (!in_array($encrypt, $this->_validCiphers, true)) { $msg = sprintf( - 'Invalid encryption cipher. Must be one of %s.', - implode(', ', $this->_validCiphers) + 'Invalid encryption cipher. Must be one of %s or false.', + implode(', ', $this->_validCiphers), ); - throw new RuntimeException($msg); + throw new InvalidArgumentException($msg); } } /** * Decrypts $value using public $type method in Security class * - * @param array $values Values to decrypt - * @param string|bool $mode Encryption mode + * @param array|string $values Values to decrypt + * @param string|false $mode Encryption mode * @param string|null $key Used as the security salt if specified. - * @return string|array Decrypted values + * @return array|string Decrypted values */ - protected function _decrypt($values, $mode, $key = null) + protected function _decrypt(array|string $values, string|false $mode, ?string $key = null): array|string { if (is_string($values)) { return $this->_decode($values, $mode, $key); @@ -119,38 +113,48 @@ protected function _decrypt($values, $mode, $key = null) * @param string $value The value to decode & decrypt. * @param string|false $encrypt The encryption cipher to use. * @param string|null $key Used as the security salt if specified. - * @return string|array Decoded values. + * @return array|string Decoded values. */ - protected function _decode($value, $encrypt, $key) + protected function _decode(string $value, string|false $encrypt, ?string $key): array|string { if (!$encrypt) { return $this->_explode($value); } $this->_checkCipher($encrypt); $prefix = 'Q2FrZQ==.'; - $value = base64_decode(substr($value, strlen($prefix))); - if ($key === null) { - $key = $this->_getCookieEncryptionKey(); + $prefixLength = strlen($prefix); + + if (strncmp($value, $prefix, $prefixLength) !== 0) { + return ''; } - if ($encrypt === 'rijndael') { - $value = Security::rijndael($value, $key, 'decrypt'); + + $value = base64_decode(substr($value, $prefixLength), true); + + if ($value === false || $value === '') { + return ''; } + + $key ??= $this->_getCookieEncryptionKey(); if ($encrypt === 'aes') { $value = Security::decrypt($value, $key); } + if ($value === null) { + return ''; + } + return $this->_explode($value); } /** - * Implode method to keep keys are multidimensional arrays + * Implode method to keep keys in multidimensional arrays * * @param array $array Map of key and values - * @return string A json encoded string. + * @return string A JSON encoded string. */ - protected function _implode(array $array) + protected function _implode(array $array): string { - return json_encode($array); + return json_encode($array, JSON_THROW_ON_ERROR); } /** @@ -158,15 +162,13 @@ protected function _implode(array $array) * Maintains reading backwards compatibility with 1.x CookieComponent::_implode(). * * @param string $string A string containing JSON encoded data, or a bare string. - * @return string|array Map of key and values + * @return array|string Map of key and values */ - protected function _explode($string) + protected function _explode(string $string): array|string { $first = substr($string, 0, 1); if ($first === '{' || $first === '[') { - $ret = json_decode($string, true); - - return ($ret !== null) ? $ret : $string; + return json_decode($string, true) ?? $string; } $array = []; foreach (explode(',', $string) as $pair) { diff --git a/src/Utility/Crypto/Mcrypt.php b/src/Utility/Crypto/Mcrypt.php deleted file mode 100644 index 23594f2744a..00000000000 --- a/src/Utility/Crypto/Mcrypt.php +++ /dev/null @@ -1,114 +0,0 @@ -mkdir($dir); + } + + $exists = file_exists($filename); + + if ($this->isStream($filename)) { + // phpcs:ignore + $success = @file_put_contents($filename, $content); + } else { + // phpcs:ignore + $success = @file_put_contents($filename, $content, LOCK_EX); + } + + if ($success === false) { + throw new CakeException(sprintf('Failed dumping content to file `%s`', $dir)); + } + + if (!$exists) { + chmod($filename, 0666 & ~umask()); + } + } + + /** + * Create directory. + * + * @param string $dir Directory path. + * @param int $mode Octal mode passed to mkdir(). Defaults to 0777. + * @return void + * @throws \Cake\Core\Exception\CakeException When directory creation fails. + */ + public function mkdir(string $dir, int $mode = 0777): void + { + if (is_dir($dir)) { + return; + } + + $old = umask(0); + // phpcs:ignore + if (@mkdir($dir, $mode, true) === false) { + umask($old); + throw new CakeException(sprintf('Failed to create directory `%s`', $dir)); + } + + umask($old); + } + + /** + * Delete directory along with all its contents. + * + * @param string $path Directory path. + * @return bool + * @throws \Cake\Core\Exception\CakeException If path is not a directory. + */ + public function deleteDir(string $path): bool + { + if (!file_exists($path)) { + return true; + } + + if (!is_dir($path)) { + throw new CakeException(sprintf('`%s` is not a directory', $path)); + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + $result = true; + /** @var \SplFileInfo $fileInfo */ + foreach ($iterator as $fileInfo) { + $isWindowsLink = DIRECTORY_SEPARATOR === '\\' && $fileInfo->getType() === 'link'; + if ($fileInfo->getType() === self::TYPE_DIR || $isWindowsLink) { + // phpcs:ignore + $result = $result && @rmdir($fileInfo->getPathname()); + unset($fileInfo); + continue; + } + + // phpcs:ignore + $result = $result && @unlink($fileInfo->getPathname()); + // possible inner iterators need to be unset too in order for locks on parents to be released + unset($fileInfo); + } + + // unsetting iterators helps releasing possible locks in certain environments, + // which could otherwise make `rmdir()` fail + unset($iterator); + + // phpcs:ignore + return $result && @rmdir($path); + } + + /** + * Copies directory with all its contents. + * + * @param string $source Source path. + * @param string $destination Destination path. + * @return bool + */ + public function copyDir(string $source, string $destination): bool + { + $destination = (new SplFileInfo($destination))->getPathname(); + + if (!is_dir($destination)) { + $this->mkdir($destination); + } + + /** @var \FilesystemIterator<\SplFileInfo> $iterator */ + $iterator = new FilesystemIterator($source); + + $result = true; + foreach ($iterator as $fileInfo) { + if ($fileInfo->isDir()) { + $result = $result && $this->copyDir( + $fileInfo->getPathname(), + $destination . DIRECTORY_SEPARATOR . $fileInfo->getFilename(), + ); + } else { + // phpcs:ignore + $result = $result && @copy( + $fileInfo->getPathname(), + $destination . DIRECTORY_SEPARATOR . $fileInfo->getFilename(), + ); + } + } + + return $result; + } + + /** + * Check whether the given path is a stream path. + * + * @param string $path Path. + * @return bool + */ + public function isStream(string $path): bool + { + return str_contains($path, '://'); + } +} diff --git a/src/Utility/Fs/Enum/DepthOperator.php b/src/Utility/Fs/Enum/DepthOperator.php new file mode 100644 index 00000000000..7c2addbae82 --- /dev/null +++ b/src/Utility/Fs/Enum/DepthOperator.php @@ -0,0 +1,55 @@ +) + */ + case GREATER_THAN = '>'; + + /** + * Less than or equal to (<=) + */ + case LESS_THAN_OR_EQUAL = '<='; + + /** + * Greater than or equal to (>=) + */ + case GREATER_THAN_OR_EQUAL = '>='; +} diff --git a/src/Utility/Fs/Enum/FinderMode.php b/src/Utility/Fs/Enum/FinderMode.php new file mode 100644 index 00000000000..1fbf5a9b7fc --- /dev/null +++ b/src/Utility/Fs/Enum/FinderMode.php @@ -0,0 +1,40 @@ +in('src') + * ->name('*.php') + * ->exclude('vendor') + * ->files(); + * + * foreach ($files as $file) { + * echo $file->getPathname(); + * } + * + * // Find directories + * $directories = (new Finder()) + * ->in('src') + * ->exclude('vendor') + * ->directories(); + * + * // Find both files and directories + * $all = (new Finder()) + * ->in('src') + * ->all(); + * ``` + */ +class Finder +{ + /** + * Base paths to search in + * + * @var array + */ + protected array $paths = []; + + /** + * Name patterns to match + * + * @var array + */ + protected array $names = []; + + /** + * Name patterns to exclude + * + * @var array + */ + protected array $notNames = []; + + /** + * Directories to exclude + * + * @var array + */ + protected array $exclude = []; + + /** + * Path patterns to include + * + * @var array + */ + protected array $pathPatterns = []; + + /** + * Path patterns to exclude + * + * @var array + */ + protected array $notPathPatterns = []; + + /** + * Glob patterns for full path matching + * + * @var array + */ + protected array $globPatterns = []; + + /** + * Depth conditions + * + * @var array + */ + protected array $depths = []; + + /** + * Whether to ignore hidden files + * + * @var bool + */ + protected bool $ignoreHiddenFiles = true; + + /** + * Whether to search recursively + * + * @var bool + */ + protected bool $recursive = true; + + /** + * The iteration mode (files, directories, or all) + * + * @var \Cake\Utility\Fs\Enum\FinderMode|null + */ + protected ?FinderMode $mode = null; + + /** + * Custom filter callbacks + * + * @var array<\Closure(\SplFileInfo, string): bool> + */ + protected array $filters = []; + + /** + * Add a path to search in. + * + * @param string $path The directory path + * @return $this + */ + public function in(string $path) + { + $this->paths[] = $path; + + return $this; + } + + /** + * Add a name pattern to match. + * + * @param string $pattern Glob pattern (e.g., '*.php') + * @return $this + */ + public function name(string $pattern) + { + $this->names[] = $pattern; + + return $this; + } + + /** + * Add a name pattern that must not be matched. + * + * @param string $pattern Glob pattern to exclude (e.g., '*.rb', '*Test.php') + * @return $this + */ + public function notName(string $pattern) + { + $this->notNames[] = $pattern; + + return $this; + } + + /** + * Exclude a directory from the search. + * + * @param string $directory Directory name to exclude + * @return $this + */ + public function exclude(string $directory) + { + $this->exclude[] = $directory; + + return $this; + } + + /** + * Add a path pattern that must be matched. + * + * @param string $pattern Path pattern (e.g., 'Controller') + * @return $this + */ + public function path(string $pattern) + { + $this->pathPatterns[] = $pattern; + + return $this; + } + + /** + * Add a path pattern that must not be matched. + * + * @param string $pattern Path pattern to exclude + * @return $this + */ + public function notPath(string $pattern) + { + $this->notPathPatterns[] = $pattern; + + return $this; + } + + /** + * Add a glob pattern for full path matching. + * + * Supports wildcards like `src/**\/*.php` for recursive matching. + * + * @param string $pattern Glob pattern (e.g., 'src/**\/*.php', 'tests/**\/*Test.php') + * @return $this + */ + public function pattern(string $pattern) + { + $this->globPatterns[] = $pattern; + + return $this; + } + + /** + * Add a custom filter callback. + * + * The callback receives the SplFileInfo object and relative path, + * and should return true to include the file. + * + * Example: + * ```php + * $finder->filter(fn(\SplFileInfo $file) => $file->getSize() > 1024); + * ``` + * + * @param \Closure(\SplFileInfo, string): bool $callback Filter callback + * @return $this + */ + public function filter(Closure $callback) + { + $this->filters[] = $callback; + + return $this; + } + + /** + * Add a depth condition. + * + * @param int $level The depth level (0 = top-level directory) + * @param \Cake\Utility\Fs\Enum\DepthOperator $operator The comparison operator (default: EQUAL) + * @return $this + */ + public function depth(int $level, DepthOperator $operator = DepthOperator::EQUAL) + { + $this->depths[] = [$operator, $level]; + + return $this; + } + + /** + * Set whether to ignore hidden files and directories. + * + * @param bool $ignore Whether to ignore hidden files + * @return $this + */ + public function ignoreHiddenFiles(bool $ignore = true) + { + $this->ignoreHiddenFiles = $ignore; + + return $this; + } + + /** Set whether to search recursively into subdirectories. + * + * @param bool $recursive Whether to search recursively (default: true) + * @return $this + */ + public function recursive(bool $recursive = true) + { + $this->recursive = $recursive; + + return $this; + } + + /** + * Get files matching the criteria. + * + * @return \Iterator<\SplFileInfo> + */ + public function files(): Iterator + { + $this->mode = FinderMode::FILES; + + return $this->iterate(); + } + + /** + * Get directories matching the criteria. + * + * @return \Iterator<\SplFileInfo> + */ + public function directories(): Iterator + { + $this->mode = FinderMode::DIRECTORIES; + + return $this->iterate(); + } + + /** + * Get both files and directories matching the criteria. + * + * @return \Iterator<\SplFileInfo> + */ + public function all(): Iterator + { + $this->mode = FinderMode::ALL; + + return $this->iterate(); + } + + /** + * Iterate over items matching the criteria. + * + * @return \Iterator<\SplFileInfo> + */ + protected function iterate(): Iterator + { + // Combine results from all paths + if (count($this->paths) === 1) { + return $this->buildIterator($this->paths[0]); + } + + // Multiple paths - use AppendIterator + $append = new AppendIterator(); + foreach ($this->paths as $path) { + $append->append($this->buildIterator($path)); + } + + return $append; + } + + /** + * Build an iterator chain with all configured filters. + * + * @param string $path The directory path + * @return \Iterator<\SplFileInfo> + */ + protected function buildIterator(string $path): Iterator + { + $flags = FilesystemIterator::KEY_AS_PATHNAME + | FilesystemIterator::CURRENT_AS_FILEINFO + | FilesystemIterator::SKIP_DOTS; + + $directory = new RecursiveDirectoryIterator($path, $flags); + + // Apply hidden file filtering + if ($this->ignoreHiddenFiles) { + $directory = new HiddenFileFilterIterator($directory); + } + + // Apply directory exclusions + if ($this->exclude !== []) { + $directory = new ExcludeDirectoryFilterIterator($directory, $this->exclude); + } + + // Apply path pattern exclusions during recursion + if ($this->notPathPatterns !== []) { + $directory = new ContainsPathFilterIterator($directory, $this->notPathPatterns, negate: true); + } + + // Apply path pattern inclusions during recursion (non-regex patterns only) + if ($this->pathPatterns !== []) { + $directory = new ContainsPathFilterIterator($directory, $this->pathPatterns); + } + + // Use SELF_FIRST when looking for directories to include them in iteration + // Use LEAVES_ONLY when looking for files only for optimization + $iteratorMode = $this->mode === FinderMode::FILES + ? RecursiveIteratorIterator::LEAVES_ONLY + : RecursiveIteratorIterator::SELF_FIRST; + + $iterator = new RecursiveIteratorIterator($directory, $iteratorMode); + + // Apply file type filtering + if ($this->mode !== null && $this->mode !== FinderMode::ALL) { + $iterator = new FileTypeFilterIterator($iterator, $this->mode); + } + + // Apply filename filtering + if ($this->names !== []) { + $iterator = new FilenameFilterIterator($iterator, $this->names); + } + if ($this->notNames !== []) { + $iterator = new FilenameFilterIterator($iterator, $this->notNames, negate: true); + } + + // Apply depth filtering (handles non-recursive mode when recursive=false) + if (!$this->recursive) { + $iterator = new DepthFilterIterator($iterator, DepthOperator::EQUAL, 0); + } + foreach ($this->depths as [$operator, $level]) { + $iterator = new DepthFilterIterator($iterator, $operator, $level); + } + + // Apply glob pattern filtering + if ($this->globPatterns !== []) { + $iterator = new GlobFilterIterator($iterator, $this->globPatterns, $path); + } + + // Apply custom filters + foreach ($this->filters as $callback) { + $iterator = new CallbackFilterIterator($iterator, $callback, $path); + } + + return $iterator; + } +} diff --git a/src/Utility/Fs/Iterator/CallbackFilterIterator.php b/src/Utility/Fs/Iterator/CallbackFilterIterator.php new file mode 100644 index 00000000000..f9a7a6889d7 --- /dev/null +++ b/src/Utility/Fs/Iterator/CallbackFilterIterator.php @@ -0,0 +1,60 @@ +> + */ +final class CallbackFilterIterator extends FilterIterator +{ + /** + * @param \Iterator $iterator The iterator to filter + * @param \Closure(\SplFileInfo, string): bool $callback Filter callback + * @param string $basePath Base path for calculating relative paths + */ + public function __construct( + Iterator $iterator, + protected Closure $callback, + protected readonly string $basePath, + ) { + parent::__construct($iterator); + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + /** @var \SplFileInfo $file */ + $file = $this->current(); + + // Calculate relative path + $relativePath = Path::makeRelative( + Path::normalize($file->getPathname()), + Path::normalize($this->basePath), + ); + + return ($this->callback)($file, $relativePath); + } +} diff --git a/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php b/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php new file mode 100644 index 00000000000..2c89004aac5 --- /dev/null +++ b/src/Utility/Fs/Iterator/ContainsPathFilterIterator.php @@ -0,0 +1,139 @@ + + */ + protected array $stringPatterns; + + /** + * Regex patterns (pattern matching, normalized) + * + * @var array + */ + protected array $regexPatterns; + + /** + * @param \RecursiveIterator $iterator The iterator to filter + * @param array $patterns Path patterns to match (string or regex) + * @param bool $negate When true, inverts the filter (excludes matching paths) + */ + public function __construct( + RecursiveIterator $iterator, + array $patterns, + protected readonly bool $negate = false, + ) { + parent::__construct($iterator); + + // Separate regex patterns from string patterns + $regexPatterns = []; + $stringPatterns = []; + + foreach ($patterns as $pattern) { + if (preg_match('/^[\/#~]/', $pattern)) { + $regexPatterns[] = $pattern; + } else { + $stringPatterns[] = $pattern; + } + } + + // Normalize string patterns for cross-platform compatibility + $this->stringPatterns = array_map(fn(string $p) => Path::normalize($p), $stringPatterns); + + // Normalize regex patterns (normalize the paths they'll match against) + $this->regexPatterns = $regexPatterns; + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + $current = $this->current(); + + // Always accept directories to allow traversal + if ($current->isDir()) { + return true; + } + + // If no patterns at all, accept everything (no-op) + if ($this->stringPatterns === [] && $this->regexPatterns === []) { + return true; + } + + // For files, check if path matches patterns + $path = Path::normalize($current->getPathname()); + $matches = false; + + // Check string patterns (substring matching) + foreach ($this->stringPatterns as $pattern) { + if (str_contains($path, $pattern)) { + $matches = true; + break; + } + } + + // Check regex patterns if no string match found + if (!$matches) { + foreach ($this->regexPatterns as $pattern) { + if (preg_match($pattern, $path)) { + $matches = true; + break; + } + } + } + + return $this->negate ? !$matches : $matches; + } + + /** + * @inheritDoc + */ + public function getChildren(): self + { + /** @var \RecursiveIterator $inner */ + $inner = $this->getInnerIterator(); + + // Pass all original patterns through (constructor will separate them again) + $allPatterns = array_merge($this->stringPatterns, $this->regexPatterns); + + return new self($inner->getChildren(), $allPatterns, $this->negate); + } +} diff --git a/src/Utility/Fs/Iterator/DepthFilterIterator.php b/src/Utility/Fs/Iterator/DepthFilterIterator.php new file mode 100644 index 00000000000..4c1f796bb42 --- /dev/null +++ b/src/Utility/Fs/Iterator/DepthFilterIterator.php @@ -0,0 +1,87 @@ += value + * + * @extends \FilterIterator> + */ +final class DepthFilterIterator extends FilterIterator +{ + /** + * @param \Iterator $iterator The iterator to filter (typically RecursiveIteratorIterator) + * @param \Cake\Utility\Fs\Enum\DepthOperator $operator Comparison operator + * @param int $value Depth value to compare against + */ + public function __construct( + Iterator $iterator, + protected readonly DepthOperator $operator, + protected readonly int $value, + ) { + parent::__construct($iterator); + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + $inner = $this->getInnerIterator(); + + // If the inner iterator is a RecursiveIteratorIterator, use its getDepth() + if ($inner instanceof RecursiveIteratorIterator) { + $depth = $inner->getDepth(); + } else { + // For other iterators wrapped in callbacks/filters, we need to unwrap + // until we find the RecursiveIteratorIterator + $current = $inner; + while ($current instanceof FilterIterator) { + $current = $current->getInnerIterator(); + } + + if ($current instanceof RecursiveIteratorIterator) { + $depth = $current->getDepth(); + } else { + // Fallback: can't determine depth, accept everything + return true; + } + } + + return match ($this->operator) { + DepthOperator::EQUAL => $depth === $this->value, + DepthOperator::NOT_EQUAL => $depth !== $this->value, + DepthOperator::LESS_THAN => $depth < $this->value, + DepthOperator::LESS_THAN_OR_EQUAL => $depth <= $this->value, + DepthOperator::GREATER_THAN => $depth > $this->value, + DepthOperator::GREATER_THAN_OR_EQUAL => $depth >= $this->value, + }; + } +} diff --git a/src/Utility/Fs/Iterator/ExcludeDirectoryFilterIterator.php b/src/Utility/Fs/Iterator/ExcludeDirectoryFilterIterator.php new file mode 100644 index 00000000000..d7d7cb4f540 --- /dev/null +++ b/src/Utility/Fs/Iterator/ExcludeDirectoryFilterIterator.php @@ -0,0 +1,79 @@ + $iterator The iterator to filter + * @param array $excludeDirs Array of directory names to exclude + */ + public function __construct( + RecursiveIterator $iterator, + protected readonly array $excludeDirs, + ) { + parent::__construct($iterator); + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + /** @var \SplFileInfo $current */ + $current = $this->current(); + + // Always accept files + if (!$current->isDir()) { + return true; + } + + // Check if directory name matches any excluded names + $filename = $current->getFilename(); + foreach ($this->excludeDirs as $excluded) { + if ($filename === $excluded) { + return false; + } + } + + return true; + } + + /** + * @inheritDoc + */ + public function getChildren(): self + { + /** @var \RecursiveIterator $inner */ + $inner = $this->getInnerIterator(); + + return new self($inner->getChildren(), $this->excludeDirs); + } +} diff --git a/src/Utility/Fs/Iterator/FileTypeFilterIterator.php b/src/Utility/Fs/Iterator/FileTypeFilterIterator.php new file mode 100644 index 00000000000..1802675173b --- /dev/null +++ b/src/Utility/Fs/Iterator/FileTypeFilterIterator.php @@ -0,0 +1,56 @@ +> + */ +class FileTypeFilterIterator extends FilterIterator +{ + /** + * @param \Iterator $iterator The iterator to filter + * @param \Cake\Utility\Fs\Enum\FinderMode $mode The mode (FILES, DIRECTORIES, or ALL) + */ + public function __construct( + Iterator $iterator, + protected readonly FinderMode $mode, + ) { + parent::__construct($iterator); + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + /** @var \SplFileInfo $current */ + $current = $this->current(); + + return match ($this->mode) { + FinderMode::FILES => $current->isFile(), + FinderMode::DIRECTORIES => $current->isDir(), + FinderMode::ALL => true, + }; + } +} diff --git a/src/Utility/Fs/Iterator/FilenameFilterIterator.php b/src/Utility/Fs/Iterator/FilenameFilterIterator.php new file mode 100644 index 00000000000..cd0c0450b09 --- /dev/null +++ b/src/Utility/Fs/Iterator/FilenameFilterIterator.php @@ -0,0 +1,69 @@ +> + */ +final class FilenameFilterIterator extends FilterIterator +{ + /** + * @param \Iterator $iterator The iterator to filter + * @param array $patterns Glob patterns to match against + * @param bool $negate When true, inverts the filter (excludes matching files) + */ + public function __construct( + Iterator $iterator, + protected readonly array $patterns, + protected readonly bool $negate = false, + ) { + parent::__construct($iterator); + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + $filename = $this->current()->getFilename(); + + $matches = false; + foreach ($this->patterns as $pattern) { + if (Path::matches($pattern, $filename)) { + $matches = true; + break; + } + } + + return $this->negate ? !$matches : $matches; + } +} diff --git a/src/Utility/Fs/Iterator/GlobFilterIterator.php b/src/Utility/Fs/Iterator/GlobFilterIterator.php new file mode 100644 index 00000000000..61d3c59f2f7 --- /dev/null +++ b/src/Utility/Fs/Iterator/GlobFilterIterator.php @@ -0,0 +1,66 @@ +> + */ +final class GlobFilterIterator extends FilterIterator +{ + /** + * @param \Iterator $iterator The iterator to filter + * @param array $patterns Glob patterns to match + * @param string $basePath Base path to calculate relative paths from + */ + public function __construct( + Iterator $iterator, + protected readonly array $patterns, + protected readonly string $basePath, + ) { + parent::__construct($iterator); + } + + /** + * @inheritDoc + */ + public function accept(): bool + { + $relativePath = Path::makeRelative( + $this->current()->getPathname(), + $this->basePath, + ); + + foreach ($this->patterns as $pattern) { + if (Path::matches($pattern, $relativePath)) { + return true; + } + } + + return false; + } +} diff --git a/src/Utility/Fs/Iterator/HiddenFileFilterIterator.php b/src/Utility/Fs/Iterator/HiddenFileFilterIterator.php new file mode 100644 index 00000000000..3d605204d39 --- /dev/null +++ b/src/Utility/Fs/Iterator/HiddenFileFilterIterator.php @@ -0,0 +1,49 @@ +current(); + + return !str_starts_with($current->getFilename(), '.'); + } + + /** + * @inheritDoc + */ + public function getChildren(): self + { + /** @var \RecursiveIterator $inner */ + $inner = $this->getInnerIterator(); + + return new self($inner->getChildren()); + } +} diff --git a/src/Utility/Fs/Path.php b/src/Utility/Fs/Path.php new file mode 100644 index 00000000000..6e60a2b76cd --- /dev/null +++ b/src/Utility/Fs/Path.php @@ -0,0 +1,148 @@ +|array $data Array of data or object implementing * \ArrayAccess interface to operate on. - * @param string|array $path The path being searched for. Either a dot - * separated string, or an array of path segments. - * @param mixed $default The return value when the path does not exist + * @param array|string|int|null $path The path being searched for. Either a dot + * separated string, or an array of path segments. If null, returns $default. + * @param mixed $default The return value when the path does not exist or is null. * @throws \InvalidArgumentException - * @return mixed The value fetched from the array, or null. - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::get + * @return mixed The value fetched from the array, or $default if path doesn't exist, is null, + * or $data is empty. + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-get + * @psalm-taint-specialize Psalm tracks taint per call site instead of globally, preventing + * false positives where taint from one caller (e.g. request body) bleeds into unrelated + * callers of this generic utility (e.g. Configure::read, getParam). */ - public static function get($data, $path, $default = null) + public static function get(ArrayAccess|array $data, array|string|int|null $path, mixed $default = null): mixed { - if (!(is_array($data) || $data instanceof ArrayAccess)) { - throw new InvalidArgumentException( - 'Invalid data type, must be an array or \ArrayAccess instance.' - ); - } - - if (empty($data) || $path === null) { + if (!$data || $path === null) { return $default; } - if (is_string($path) || is_numeric($path)) { - $parts = explode('.', $path); + if (is_string($path) || is_int($path)) { + $parts = explode('.', (string)$path); } else { - if (!is_array($path)) { - throw new InvalidArgumentException(sprintf( - 'Invalid Parameter %s, should be dot separated path or array.', - $path - )); - } - $parts = $path; } switch (count($parts)) { case 1: - return isset($data[$parts[0]]) ? $data[$parts[0]] : $default; + return $data[$parts[0]] ?? $default; case 2: - return isset($data[$parts[0]][$parts[1]]) ? $data[$parts[0]][$parts[1]] : $default; + return $data[$parts[0]][$parts[1]] ?? $default; case 3: - return isset($data[$parts[0]][$parts[1]][$parts[2]]) ? $data[$parts[0]][$parts[1]][$parts[2]] : $default; + return $data[$parts[0]][$parts[1]][$parts[2]] ?? $default; default: foreach ($parts as $key) { if ((is_array($data) || $data instanceof ArrayAccess) && isset($data[$key])) { @@ -106,7 +105,7 @@ public static function get($data, $path, $default = null) * - `>`, `<`, `>=`, `<=` Value comparison. * - `=/.../` Regular expression pattern match. * - * Given a set of User array data, from a `$User->find('all')` call: + * Given a set of User array data, from a `$usersTable->find('all')` call: * * - `1.User.name` Get the name of the user at index 1. * - `{n}.User.name` Get the name of every user in the set of users. @@ -115,35 +114,29 @@ public static function get($data, $path, $default = null) * - `{n}.User[username=/^paul/]` Get User elements with username matching `^paul`. * - `{n}.User[id=1].name` Get the Users name with id matching `1`. * - * @param array|\ArrayAccess $data The data to extract from. + * @param \ArrayAccess|array $data The data to extract from. * @param string $path The path to extract. - * @return array|\ArrayAccess An array of the extracted values. Returns an empty array + * @return ($path is non-empty-string ? array : \ArrayAccess|array) An array of the extracted values. Returns an empty array * if there are no matches. - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::extract + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-extract */ - public static function extract($data, $path) + public static function extract(ArrayAccess|array $data, string $path): ArrayAccess|array { - if (!(is_array($data) || $data instanceof ArrayAccess)) { - throw new InvalidArgumentException( - 'Invalid data type, must be an array or \ArrayAccess instance.' - ); - } - - if (empty($path)) { + if (!$path) { return $data; } // Simple paths. if (!preg_match('/[{\[]/', $path)) { $data = static::get($data, $path); - if ($data !== null && !(is_array($data) || $data instanceof ArrayAccess)) { + if ($data !== null && (!is_array($data) && !$data instanceof ArrayAccess)) { return [$data]; } return $data !== null ? (array)$data : []; } - if (strpos($path, '[') === false) { + if (!str_contains($path, '[')) { $tokens = explode('.', $path); } else { $tokens = Text::tokenize($path, '.', '[', ']'); @@ -156,7 +149,7 @@ public static function extract($data, $path) foreach ($tokens as $token) { $next = []; - list($token, $conditions) = self::_splitConditions($token); + [$token, $conditions] = self::_splitConditions($token); foreach ($context[$_key] as $item) { if (is_object($item) && method_exists($item, 'toArray')) { @@ -174,7 +167,11 @@ public static function extract($data, $path) if ($conditions) { $filter = []; foreach ($next as $item) { - if ((is_array($item) || $item instanceof ArrayAccess) && + if ( + ( + is_array($item) || + $item instanceof ArrayAccess + ) && static::_matches($item, $conditions) ) { $filter[] = $item; @@ -191,10 +188,10 @@ public static function extract($data, $path) /** * Split token conditions * - * @param string $token the token being splitted. - * @return array [token, conditions] with token splitted + * @param string $token the token being split. + * @return array [token, conditions] with token split */ - protected static function _splitConditions($token) + protected static function _splitConditions(string $token): array { $conditions = false; $position = strpos($token, '['); @@ -209,64 +206,64 @@ protected static function _splitConditions($token) /** * Check a key against a token. * - * @param string $key The key in the array being searched. + * @param mixed $key The key in the array being searched. * @param string $token The token being matched. * @return bool */ - protected static function _matchToken($key, $token) + protected static function _matchToken(mixed $key, string $token): bool { - switch ($token) { - case '{n}': - return is_numeric($key); - case '{s}': - return is_string($key); - case '{*}': - return true; - default: - return is_numeric($token) ? ($key == $token) : $key === $token; - } + return match ($token) { + '{n}' => is_numeric($key), + '{s}' => is_string($key), + '{*}' => true, + default => is_numeric($token) ? ($key == $token) : $key === $token, + }; } /** - * Checks whether or not $data matches the attribute patterns + * Checks whether $data matches the attribute patterns * - * @param array|\ArrayAccess $data Array of data to match. + * @param \ArrayAccess|array $data Array of data to match. * @param string $selector The patterns to match. * @return bool Fitness of expression. */ - protected static function _matches($data, $selector) + protected static function _matches(ArrayAccess|array $data, string $selector): bool { preg_match_all( '/(\[ (?P[^=>[><]) \s* (?P(?:\/.*?\/ | [^\]]+)) )? \])/x', $selector, $conditions, - PREG_SET_ORDER + PREG_SET_ORDER, ); foreach ($conditions as $cond) { $attr = $cond['attr']; - $op = isset($cond['op']) ? $cond['op'] : null; - $val = isset($cond['val']) ? $cond['val'] : null; + $op = $cond['op'] ?? null; + $val = $cond['val'] ?? null; // Presence test. - if (empty($op) && empty($val) && !isset($data[$attr])) { + if (!$op && !$val && !isset($data[$attr])) { return false; } + if (is_array($data)) { + $attrPresent = array_key_exists($attr, $data); + } else { + $attrPresent = $data->offsetExists($attr); + } // Empty attribute = fail. - if (!(isset($data[$attr]) || array_key_exists($attr, $data))) { + if (!$attrPresent) { return false; } - $prop = null; - if (isset($data[$attr])) { - $prop = $data[$attr]; - } + $prop = $data[$attr] ?? ''; $isBool = is_bool($prop); if ($isBool && is_numeric($val)) { $prop = $prop ? '1' : '0'; } elseif ($isBool) { $prop = $prop ? 'true' : 'false'; + } elseif (is_numeric($prop)) { + $prop = (string)$prop; } // Pattern matches and other operators. @@ -274,12 +271,15 @@ protected static function _matches($data, $selector) if (!preg_match($val, $prop)) { return false; } - } elseif (($op === '=' && $prop != $val) || + // phpcs:disable + } elseif ( + ($op === '=' && $prop != $val) || ($op === '!=' && $prop == $val) || ($op === '>' && $prop <= $val) || ($op === '<' && $prop >= $val) || ($op === '>=' && $prop < $val) || ($op === '<=' && $prop > $val) + // phpcs:enable ) { return false; } @@ -292,16 +292,20 @@ protected static function _matches($data, $selector) * Insert $values into an array with the given $path. You can use * `{n}` and `{s}` elements to insert $data multiple times. * - * @param array $data The data to insert into. + * @template T of \ArrayAccess|array + * @param T $data The data to insert into. * @param string $path The path to insert at. - * @param array|null $values The values to insert. - * @return array The data with $values inserted. - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::insert + * @param mixed $values The values to insert. + * @return (T is array ? array : \ArrayAccess) The data with $values inserted. + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-insert + * @psalm-taint-specialize Psalm tracks taint per call site instead of globally, preventing + * false positives where taint from one caller (e.g. ServerRequest::withData) bleeds into + * unrelated callers of this generic utility (e.g. Configure::write). */ - public static function insert(array $data, $path, $values = null) + public static function insert(ArrayAccess|array $data, string $path, mixed $values = null): ArrayAccess|array { - $noTokens = strpos($path, '[') === false; - if ($noTokens && strpos($path, '.') === false) { + $noTokens = !str_contains($path, '['); + if ($noTokens && !str_contains($path, '.')) { $data[$path] = $values; return $data; @@ -313,22 +317,28 @@ public static function insert(array $data, $path, $values = null) $tokens = Text::tokenize($path, '.', '[', ']'); } - if ($noTokens && strpos($path, '{') === false) { + if ($noTokens && !str_contains($path, '{')) { return static::_simpleOp('insert', $data, $tokens, $values); } + if (!is_iterable($data)) { + throw new CakeException('Cannot use path tokens of type `{}` or `[]` for non-iterable objects.'); + } + + /** @var string $token */ $token = array_shift($tokens); $nextPath = implode('.', $tokens); - list($token, $conditions) = static::_splitConditions($token); + [$token, $conditions] = static::_splitConditions($token); foreach ($data as $k => $v) { - if (static::_matchToken($k, $token)) { - if (!$conditions || static::_matches($v, $conditions)) { - $data[$k] = $nextPath - ? static::insert($v, $nextPath, $values) - : array_merge($v, (array)$values); - } + if ( + static::_matchToken($k, $token) && + (!$conditions || ((is_array($v) || $v instanceof ArrayAccess) && static::_matches($v, $conditions))) + ) { + $data[$k] = $nextPath + ? static::insert($v, $nextPath, $values) + : array_merge($v, (array)$values); } } @@ -339,14 +349,22 @@ public static function insert(array $data, $path, $values = null) * Perform a simple insert/remove operation. * * @param string $op The operation to do. - * @param array $data The data to operate on. - * @param array $path The path to work on. + * @param \ArrayAccess|array $data The data to operate on. + * @param array $path The path to work on. * @param mixed $values The values to insert when doing inserts. - * @return array data. + * @return \ArrayAccess|array + * @psalm-taint-specialize Psalm tracks taint per call site instead of globally. Without this, + * the ArrayAccess|array $data parameter causes Psalm to dispatch taint through every + * ArrayAccess::offsetSet implementation in the codebase (e.g. Validator) when this method + * is called with tainted request data. */ - protected static function _simpleOp($op, $data, $path, $values = null) - { - $_list =& $data; + protected static function _simpleOp( + string $op, + ArrayAccess|array $data, + array $path, + mixed $values = null, + ): ArrayAccess|array { + $_list = &$data; $count = count($path); $last = $count - 1; @@ -357,16 +375,14 @@ protected static function _simpleOp($op, $data, $path, $values = null) return $data; } - if (!isset($_list[$key])) { - $_list[$key] = []; - } - $_list =& $_list[$key]; - if (!is_array($_list)) { + $_list[$key] ??= []; + $_list = &$_list[$key]; + if (!is_array($_list) && !$_list instanceof ArrayAccess) { $_list = []; } } elseif ($op === 'remove') { if ($i === $last) { - if (is_array($_list)) { + if (is_array($_list) || $_list instanceof ArrayAccess) { unset($_list[$key]); } @@ -375,9 +391,11 @@ protected static function _simpleOp($op, $data, $path, $values = null) if (!isset($_list[$key])) { return $data; } - $_list =& $_list[$key]; + $_list = &$_list[$key]; } } + + return $data; } /** @@ -385,17 +403,21 @@ protected static function _simpleOp($op, $data, $path, $values = null) * You can use `{n}` and `{s}` to remove multiple elements * from $data. * - * @param array $data The data to operate on + * @template T of \ArrayAccess|array + * @param T $data The data to operate on * @param string $path A path expression to use to remove. - * @return array The modified array. - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::remove + * @return (T is array ? array : \ArrayAccess) The modified array. + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-remove + * @psalm-taint-specialize Psalm tracks taint per call site instead of globally, preventing + * false positives where taint from one caller (e.g. ServerRequest::withoutData) bleeds into + * unrelated callers of this generic utility (e.g. Configure::delete). */ - public static function remove(array $data, $path) + public static function remove(ArrayAccess|array $data, string $path): ArrayAccess|array { - $noTokens = strpos($path, '[') === false; - $noExpansion = strpos($path, '{') === false; + $noTokens = !str_contains($path, '['); + $noExpansion = !str_contains($path, '{'); - if ($noExpansion && $noTokens && strpos($path, '.') === false) { + if ($noExpansion && $noTokens && !str_contains($path, '.')) { unset($data[$path]); return $data; @@ -407,14 +429,20 @@ public static function remove(array $data, $path) return static::_simpleOp('remove', $data, $tokens); } + if (!is_iterable($data)) { + throw new CakeException('Cannot use path tokens of type `{}` or `[]` for non-iterable objects.'); + } + + /** @var string $token */ $token = array_shift($tokens); $nextPath = implode('.', $tokens); - list($token, $conditions) = self::_splitConditions($token); + [$token, $conditions] = self::_splitConditions($token); foreach ($data as $k => $v) { $match = static::_matchToken($k, $token); - if ($match && is_array($v)) { + if ($match && (is_array($v) || $v instanceof ArrayAccess)) { + /** @var \ArrayAccess|array $v */ if ($conditions) { if (static::_matches($v, $conditions)) { if ($nextPath !== '') { @@ -444,69 +472,80 @@ public static function remove(array $data, $path) * following the path specified in `$groupPath`. * * @param array $data Array from where to extract keys and values - * @param string $keyPath A dot-separated string. - * @param string|null $valuePath A dot-separated string. + * @param array|string|null $keyPath A dot-separated string. + * @param array|string|null $valuePath A dot-separated string. * @param string|null $groupPath A dot-separated string. * @return array Combined array - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::combine - * @throws \RuntimeException When keys and values count is unequal. + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-combine + * @throws \InvalidArgumentException When keys and values count is unequal. */ - public static function combine(array $data, $keyPath, $valuePath = null, $groupPath = null) - { - if (empty($data)) { + public static function combine( + array $data, + array|string|null $keyPath, + array|string|null $valuePath = null, + ?string $groupPath = null, + ): array { + if (!$data) { return []; } if (is_array($keyPath)) { + /** @var string $format */ $format = array_shift($keyPath); $keys = static::format($data, $keyPath, $format); + assert(is_array($keys)); + } elseif ($keyPath === null) { + $keys = $keyPath; } else { $keys = static::extract($data, $keyPath); + assert(is_array($keys)); } - if (empty($keys)) { + if ($keyPath !== null && empty($keys)) { return []; } $vals = null; - if (!empty($valuePath) && is_array($valuePath)) { + if ($valuePath && is_array($valuePath)) { $format = array_shift($valuePath); $vals = static::format($data, $valuePath, $format); - } elseif (!empty($valuePath)) { + assert(is_array($vals)); + } elseif ($valuePath) { $vals = static::extract($data, $valuePath); + assert(is_array($vals)); } - if (empty($vals)) { - $vals = array_fill(0, count($keys), null); + if (!$vals) { + $vals = array_fill(0, $keys === null ? count($data) : count($keys), null); } - if (count($keys) !== count($vals)) { - throw new RuntimeException( - 'Hash::combine() needs an equal number of keys + values.' + if (is_array($keys) && count($keys) !== count($vals)) { + throw new InvalidArgumentException( + '`Hash::combine()` needs an equal number of keys + values.', ); } if ($groupPath !== null) { $group = static::extract($data, $groupPath); - if (!empty($group)) { - $c = count($keys); + if ($group) { + $c = is_array($keys) ? count($keys) : count($vals); $out = []; for ($i = 0; $i < $c; $i++) { - if (!isset($group[$i])) { - $group[$i] = 0; - } - if (!isset($out[$group[$i]])) { - $out[$group[$i]] = []; + $group[$i] ??= 0; + $out[$group[$i]] ??= []; + if ($keys === null) { + $out[$group[$i]][] = $vals[$i]; + } else { + $out[$group[$i]][$keys[$i]] = $vals[$i]; } - $out[$group[$i]][$keys[$i]] = $vals[$i]; } return $out; } } - if (empty($vals)) { + if (!$vals) { return []; } - return array_combine($keys, $vals); + return array_combine($keys ?? range(0, count($vals) - 1), $vals); } /** @@ -522,19 +561,20 @@ public static function combine(array $data, $keyPath, $valuePath = null, $groupP * The `$format` string can use any format options that `vsprintf()` and `sprintf()` do. * * @param array $data Source array from which to extract the data - * @param array $paths An array containing one or more Hash::extract()-style key paths + * @param array $paths An array containing one or more Hash::extract()-style key paths * @param string $format Format string into which values will be inserted, see sprintf() - * @return array|null An array of strings extracted from `$path` and formatted with `$format` - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::format + * @return ($paths is non-empty-array ? array : null) An array of strings extracted from `$path` and formatted with `$format`, + * or null if $paths is empty. + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-format * @see sprintf() * @see \Cake\Utility\Hash::extract() */ - public static function format(array $data, array $paths, $format) + public static function format(array $data, array $paths, string $format): ?array { $extracted = []; $count = count($paths); - if (!$count) { + if ($count === 0) { return null; } @@ -542,6 +582,7 @@ public static function format(array $data, array $paths, $format) $extracted[] = static::extract($data, $paths[$i]); } $out = []; + /** @var array $data */ $data = $extracted; $count = count($data[0]); @@ -563,18 +604,18 @@ public static function format(array $data, array $paths, $format) * Determines if one array contains the exact keys and values of another. * * @param array $data The data to search through. - * @param array $needle The values to file in $data + * @param array $needle The values to find in $data * @return bool true If $data contains $needle, false otherwise - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::contains + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-contains */ - public static function contains(array $data, array $needle) + public static function contains(array $data, array $needle): bool { - if (empty($data) || empty($needle)) { + if (!$data || !$needle) { return false; } $stack = []; - while (!empty($needle)) { + while ($needle) { $key = key($needle); $val = $needle[$key]; unset($needle[$key]); @@ -583,15 +624,15 @@ public static function contains(array $data, array $needle) $next = $data[$key]; unset($data[$key]); - if (!empty($val)) { + if ($val) { $stack[] = [$val, $next]; } } elseif (!array_key_exists($key, $data) || $data[$key] != $val) { return false; } - if (empty($needle) && !empty($stack)) { - list($needle, $data) = array_pop($stack); + if (!$needle && $stack) { + [$needle, $data] = array_pop($stack); } } @@ -599,7 +640,7 @@ public static function contains(array $data, array $needle) } /** - * Test whether or not a given path exists in $data. + * Test whether a given path exists in $data. * This method uses the same path syntax as Hash::extract() * * Checking for paths that could target more than one element will @@ -609,28 +650,28 @@ public static function contains(array $data, array $needle) * @param string $path The path to check for. * @return bool Existence of path. * @see \Cake\Utility\Hash::extract() - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::check + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-check */ - public static function check(array $data, $path) + public static function check(array $data, string $path): bool { $results = static::extract($data, $path); if (!is_array($results)) { return false; } - return count($results) > 0; + return $results !== []; } /** * Recursively filters a data set. * * @param array $data Either an array to filter, or value when in callback - * @param callable|array $callback A function to filter the data with. Defaults to - * `static::_filter()` Which strips out all non-zero empty values. + * @param callable|null $callback A function to filter the data with. Defaults to + * all non-empty or zero values. * @return array Filtered array - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::filter + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-filter */ - public static function filter(array $data, $callback = ['self', '_filter']) + public static function filter(array $data, ?callable $callback = null): array { foreach ($data as $k => $v) { if (is_array($v)) { @@ -638,7 +679,7 @@ public static function filter(array $data, $callback = ['self', '_filter']) } } - return array_filter($data, $callback); + return array_filter($data, $callback ?? static::_filter(...)); } /** @@ -647,35 +688,33 @@ public static function filter(array $data, $callback = ['self', '_filter']) * @param mixed $var Array to filter. * @return bool */ - protected static function _filter($var) + protected static function _filter(mixed $var): bool { - return $var === 0 || $var === 0.0 || $var === '0' || !empty($var); + return in_array($var, [0, 0.0, '0'], true) || !empty($var); } /** * Collapses a multi-dimensional array into a single dimension, using a delimited array path for - * each array element's key, i.e. [['Foo' => ['Bar' => 'Far']]] becomes - * ['0.Foo.Bar' => 'Far'].) + * each array element's key, i.e. `[['Foo' => ['Bar' => 'Far']]]` becomes `['0.Foo.Bar' => 'Far']`. * * @param array $data Array to flatten * @param string $separator String used to separate array key elements in a path, defaults to '.' * @return array - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::flatten + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-flatten */ - public static function flatten(array $data, $separator = '.') + public static function flatten(array $data, string $separator = '.'): array { $result = []; $stack = []; - $path = null; + $path = ''; - reset($data); while (!empty($data)) { - $key = key($data); + $key = array_key_first($data); $element = $data[$key]; unset($data[$key]); - if (is_array($element) && !empty($element)) { - if (!empty($data)) { + if (is_array($element) && $element !== []) { + if ($data) { $stack[] = [$data, $path]; } $data = $element; @@ -685,8 +724,8 @@ public static function flatten(array $data, $separator = '.') $result[$path . $key] = $element; } - if (empty($data) && !empty($stack)) { - list($data, $path) = array_pop($stack); + if (!$data && $stack) { + [$data, $path] = array_pop($stack); reset($data); } } @@ -702,31 +741,34 @@ public static function flatten(array $data, $separator = '.') * `[['Foo' => ['Bar' => 'Far']]]`. * * @param array $data Flattened array - * @param string $separator The delimiter used + * @param non-empty-string $separator The delimiter used * @return array - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::expand + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-expand */ - public static function expand(array $data, $separator = '.') + public static function expand(array $data, string $separator = '.'): array { - $result = []; - foreach ($data as $flat => $value) { - $keys = explode($separator, $flat); - $keys = array_reverse($keys); - $child = [ - $keys[0] => $value - ]; - array_shift($keys); - foreach ($keys as $k) { - $child = [ - $k => $child - ]; + $hash = []; + foreach ($data as $path => $value) { + $keys = explode($separator, (string)$path); + if (count($keys) === 1) { + $hash[$path] = $value; + continue; } - $stack = [[$child, &$result]]; - static::_merge($stack, $result); + $valueKey = end($keys); + $keys = array_slice($keys, 0, -1); + + $keyHash = &$hash; + foreach ($keys as $key) { + if (!array_key_exists($key, $keyHash)) { + $keyHash[$key] = []; + } + $keyHash = &$keyHash[$key]; + } + $keyHash[$valueKey] = $value; } - return $result; + return $hash; } /** @@ -736,14 +778,18 @@ public static function expand(array $data, $separator = '.') * Hash::merge() will behave in a recursive fashion (unlike `array_merge`). But it will not act recursively for * keys that contain scalar values (unlike `array_merge_recursive`). * - * Note: This function will work with an unlimited amount of arguments and typecasts non-array parameters into arrays. + * This function will work with an unlimited amount of arguments and typecasts non-array parameters into arrays. * * @param array $data Array to be merged * @param mixed $merge Array to merge with. The argument and all trailing arguments will be array cast when merged * @return array Merged array - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::merge + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-merge + * @psalm-taint-specialize Psalm tracks taint per call site instead of globally, preventing + * false positives where taint from one caller (e.g. ServerRequestFactory merging POST body + * with uploaded files) bleeds into unrelated callers (e.g. InstanceConfigTrait merging + * developer configuration, ServerRequest::addDetector merging detector definitions). */ - public static function merge(array $data, $merge) + public static function merge(array $data, mixed $merge): array { $args = array_slice(func_get_args(), 1); $return = $data; @@ -765,16 +811,23 @@ public static function merge(array $data, $merge) * @param array $return The return value to operate on. * @return void */ - protected static function _merge($stack, &$return) + protected static function _merge(array $stack, array &$return): void { - while (!empty($stack)) { + while ($stack !== []) { foreach ($stack as $curKey => &$curMerge) { foreach ($curMerge[0] as $key => &$val) { - $isArray = is_array($curMerge[1]); - if ($isArray && !empty($curMerge[1][$key]) && (array)$curMerge[1][$key] === $curMerge[1][$key] && (array)$val === $val) { + if (!is_array($curMerge[1])) { + continue; + } + + if ( + !empty($curMerge[1][$key]) + && (array)$curMerge[1][$key] === $curMerge[1][$key] + && (array)$val === $val + ) { // Recurse into the current merge data as it is an array. $stack[] = [&$val, &$curMerge[1][$key]]; - } elseif ((int)$key === $key && $isArray && isset($curMerge[1][$key])) { + } elseif ((int)$key === $key && isset($curMerge[1][$key])) { $curMerge[1][] = $val; } else { $curMerge[1][$key] = $val; @@ -791,11 +844,11 @@ protected static function _merge($stack, &$return) * * @param array $data The array to check. * @return bool true if values are numeric, false otherwise - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::numeric + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-numeric */ - public static function numeric(array $data) + public static function numeric(array $data): bool { - if (empty($data)) { + if (!$data) { return false; } @@ -811,11 +864,11 @@ public static function numeric(array $data) * * @param array $data Array to count dimensions on * @return int The number of dimensions in $data - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::dimensions + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-dimensions */ - public static function dimensions(array $data) + public static function dimensions(array $data): int { - if (empty($data)) { + if (!$data) { return 0; } reset($data); @@ -838,22 +891,20 @@ public static function dimensions(array $data) * * @param array $data Array to count dimensions on * @return int The maximum number of dimensions in $data - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::maxDimensions + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-maxdimensions */ - public static function maxDimensions(array $data) + public static function maxDimensions(array $data): int { $depth = []; - if (is_array($data) && !empty($data)) { - foreach ($data as $value) { - if (is_array($value)) { - $depth[] = static::maxDimensions($value) + 1; - } else { - $depth[] = 1; - } + foreach ($data as $value) { + if (is_array($value)) { + $depth[] = static::maxDimensions($value) + 1; + } else { + $depth[] = 1; } } - return empty($depth) ? 0 : max($depth); + return $depth === [] ? 0 : max($depth); } /** @@ -864,9 +915,9 @@ public static function maxDimensions(array $data) * @param string $path The path to extract for mapping over. * @param callable $function The function to call on each extracted value. * @return array An array of the modified values. - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::map + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-map */ - public static function map(array $data, $path, $function) + public static function map(array $data, string $path, callable $function): array { $values = (array)static::extract($data, $path); @@ -880,9 +931,9 @@ public static function map(array $data, $path, $function) * @param string $path The path to extract from $data. * @param callable $function The function to call on each extracted value. * @return mixed The reduced value. - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::reduce + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-reduce */ - public static function reduce(array $data, $path, $function) + public static function reduce(array $data, string $path, callable $function): mixed { $values = (array)static::extract($data, $path); @@ -912,13 +963,13 @@ public static function reduce(array $data, $path, $function) * @param string $path The path to extract from $data. * @param callable $function The function to call on each extracted value. * @return mixed The results of the applied method. - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::apply + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-apply */ - public static function apply(array $data, $path, $function) + public static function apply(array $data, string $path, callable $function): mixed { $values = (array)static::extract($data, $path); - return call_user_func($function, $values); + return $function($values); } /** @@ -926,8 +977,8 @@ public static function apply(array $data, $path, $function) * * ### Sort directions * - * - `asc` Sort ascending. - * - `desc` Sort descending. + * - `asc` or \SORT_ASC Sort ascending. + * - `desc` or \SORT_DESC Sort descending. * * ### Sort types * @@ -949,14 +1000,18 @@ public static function apply(array $data, $path, $function) * * @param array $data An array of data to sort * @param string $path A Set-compatible path to the array value - * @param string $dir See directions above. Defaults to 'asc'. - * @param array|string $type See direction types above. Defaults to 'regular'. + * @param string|int $dir See directions above. Defaults to 'asc'. + * @param array|string $type See direction types above. Defaults to 'regular'. * @return array Sorted array of data - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::sort + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-sort */ - public static function sort(array $data, $path, $dir = 'asc', $type = 'regular') - { - if (empty($data)) { + public static function sort( + array $data, + string $path, + string|int $dir = 'asc', + array|string $type = 'regular', + ): array { + if (!$data) { return []; } $originalKeys = array_keys($data); @@ -965,6 +1020,7 @@ public static function sort(array $data, $path, $dir = 'asc', $type = 'regular') $data = array_values($data); } $sortValues = static::extract($data, $path); + assert(is_array($sortValues)); $dataCount = count($data); // Make sortValues match the data length, as some keys could be missing @@ -981,9 +1037,16 @@ public static function sort(array $data, $path, $dir = 'asc', $type = 'regular') } $result = static::_squash($sortValues); $keys = static::extract($result, '{n}.id'); + $values = static::extract($result, '{n}.value'); - $dir = strtolower($dir); + if (is_string($dir)) { + $dir = strtolower($dir); + } + if (!in_array($dir, [SORT_ASC, SORT_DESC], true)) { + $dir = $dir === 'asc' ? SORT_ASC : SORT_DESC; + } + $ignoreCase = false; // $type can be overloaded for case insensitive sort @@ -994,11 +1057,6 @@ public static function sort(array $data, $path, $dir = 'asc', $type = 'regular') } $type = strtolower($type); - if ($dir === 'asc') { - $dir = SORT_ASC; - } else { - $dir = SORT_DESC; - } if ($type === 'numeric') { $type = SORT_NUMERIC; } elseif ($type === 'string') { @@ -1037,10 +1095,10 @@ public static function sort(array $data, $path, $dir = 'asc', $type = 'regular') * Squashes an array to a single hash so it can be sorted. * * @param array $data The data to squash. - * @param string|null $key The key for the data. + * @param string|int|null $key The key for the data. * @return array */ - protected static function _squash(array $data, $key = null) + protected static function _squash(array $data, string|int|null $key = null): array { $stack = []; foreach ($data as $k => $r) { @@ -1048,7 +1106,7 @@ protected static function _squash(array $data, $key = null) if ($key !== null) { $id = $key; } - if (is_array($r) && !empty($r)) { + if (is_array($r) && $r !== []) { $stack = array_merge($stack, static::_squash($r, $id)); } else { $stack[] = ['id' => $id, 'value' => $r]; @@ -1067,15 +1125,15 @@ protected static function _squash(array $data, $key = null) * @param array $compare Second value * @return array Returns the key => value pairs that are not common in $data and $compare * The expression for this function is ($data - $compare) + ($compare - ($data - $compare)) - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::diff + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-diff */ - public static function diff(array $data, array $compare) + public static function diff(array $data, array $compare): array { - if (empty($data)) { - return (array)$compare; + if (!$data) { + return $compare; } - if (empty($compare)) { - return (array)$data; + if (!$compare) { + return $data; } $intersection = array_intersect_key($data, $compare); while (($key = key($intersection)) !== null) { @@ -1094,21 +1152,21 @@ public static function diff(array $data, array $compare) * @param array $data The data to append onto. * @param array $compare The data to compare and append onto. * @return array The merged array. - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::mergeDiff + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-mergediff */ - public static function mergeDiff(array $data, array $compare) + public static function mergeDiff(array $data, array $compare): array { - if (empty($data) && !empty($compare)) { + if (!$data && $compare !== []) { return $compare; } - if (empty($compare)) { + if (!$compare) { return $data; } foreach ($compare as $key => $value) { if (!array_key_exists($key, $data)) { $data[$key] = $value; - } elseif (is_array($value)) { - $data[$key] = static::mergeDiff($data[$key], $compare[$key]); + } elseif (is_array($value) && is_array($data[$key])) { + $data[$key] = static::mergeDiff($data[$key], $value); } } @@ -1120,10 +1178,11 @@ public static function mergeDiff(array $data, array $compare) * * @param array $data List to normalize * @param bool $assoc If true, $data will be converted to an associative array. + * @param mixed $default The default value to use when a top level numeric key is converted to associative form. * @return array - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::normalize + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-normalize */ - public static function normalize(array $data, $assoc = true) + public static function normalize(array $data, bool $assoc = true, mixed $default = null): array { $keys = array_keys($data); $count = count($keys); @@ -1141,7 +1200,7 @@ public static function normalize(array $data, $assoc = true) $newList = []; for ($i = 0; $i < $count; $i++) { if (is_int($keys[$i])) { - $newList[$data[$keys[$i]]] = null; + $newList[$data[$keys[$i]]] = $default; } else { $newList[$keys[$i]] = $data[$keys[$i]]; } @@ -1157,7 +1216,7 @@ public static function normalize(array $data, $assoc = true) * * ### Options: * - * - `children` The key name to use in the resultset for children. + * - `children` The key name to use in the result set for children. * - `idPath` The path to a key that identifies each entry. Should be * compatible with Hash::extract(). Defaults to `{n}.$alias.id` * - `parentPath` The path to a key that identifies the parent of each entry. @@ -1165,13 +1224,13 @@ public static function normalize(array $data, $assoc = true) * - `root` The id of the desired top-most result. * * @param array $data The data to nest. - * @param array $options Options are: - * @return array of results, nested + * @param array{idPath?: string, parentPath?: string, children?: string, root?: string|null} $options Options. + * @return array of results, nested * @see \Cake\Utility\Hash::extract() * @throws \InvalidArgumentException When providing invalid data. - * @link https://book.cakephp.org/3.0/en/core-libraries/hash.html#Cake\Utility\Hash::nest + * @link https://book.cakephp.org/5/en/core-libraries/hash.html#hash-nest */ - public static function nest(array $data, array $options = []) + public static function nest(array $data, array $options = []): array { if (!$data) { return $data; @@ -1179,14 +1238,15 @@ public static function nest(array $data, array $options = []) $alias = key(current($data)); $options += [ - 'idPath' => "{n}.$alias.id", - 'parentPath' => "{n}.$alias.parent_id", + 'idPath' => "{n}.{$alias}.id", + 'parentPath' => "{n}.{$alias}.parent_id", 'children' => 'children', - 'root' => null + 'root' => null, ]; - - $return = $idMap = []; + $return = []; + $idMap = []; $ids = static::extract($data, $options['idPath']); + assert(is_array($ids)); $idKeys = explode('.', $options['idPath']); array_shift($idKeys); @@ -1201,14 +1261,14 @@ public static function nest(array $data, array $options = []) $parentId = static::get($result, $parentKeys); if (isset($idMap[$id][$options['children']])) { - $idMap[$id] = array_merge($result, (array)$idMap[$id]); + $idMap[$id] = array_merge($result, $idMap[$id]); } else { $idMap[$id] = array_merge($result, [$options['children'] => []]); } if (!$parentId || !in_array($parentId, $ids)) { - $return[] =& $idMap[$id]; + $return[] = &$idMap[$id]; } else { - $idMap[$parentId][$options['children']][] =& $idMap[$id]; + $idMap[$parentId][$options['children']][] = &$idMap[$id]; } } @@ -1225,11 +1285,12 @@ public static function nest(array $data, array $options = []) foreach ($return as $i => $result) { $id = static::get($result, $idKeys); $parentId = static::get($result, $parentKeys); - if ($id !== $root && $parentId != $root) { + if ($id !== $root && $parentId !== $root) { unset($return[$i]); } } + /** @var array */ return array_values($return); } } diff --git a/src/Utility/Inflector.php b/src/Utility/Inflector.php index dc5185309a2..f9cbbef4c59 100644 --- a/src/Utility/Inflector.php +++ b/src/Utility/Inflector.php @@ -1,4 +1,6 @@ */ - protected static $_plural = [ + protected static array $_plural = [ '/(s)tatus$/i' => '\1tatuses', '/(quiz)$/i' => '\1zes', '/^(ox)$/i' => '\1\2en', '/([m|l])ouse$/i' => '\1ice', - '/(matr|vert|ind)(ix|ex)$/i' => '\1ices', + '/(matr|vert)(ix|ex)$/i' => '\1ices', '/(x|ch|ss|sh)$/i' => '\1es', '/([^aeiouy]|qu)y$/i' => '\1ies', '/(hive)$/i' => '\1s', @@ -59,16 +60,16 @@ class Inflector /** * Singular inflector rules * - * @var array + * @var array */ - protected static $_singular = [ + protected static array $_singular = [ '/(s)tatuses$/i' => '\1\2tatus', '/^(.*)(menu)s$/i' => '\1\2', '/(quiz)zes$/i' => '\\1', '/(matr)ices$/i' => '\1ix', '/(vert|ind)ices$/i' => '\1ex', '/^(ox)en/i' => '\1', - '/(alias)(es)*$/i' => '\1', + '/(alias|lens)(es)*$/i' => '\1', '/(alumn|bacill|cact|foc|fung|nucle|radi|stimul|syllab|termin|viri?)i$/i' => '\1us', '/([ftw]ax)es/i' => '\1', '/(cris|ax|test)es$/i' => '\1is', @@ -80,6 +81,7 @@ class Inflector '/(x|ch|ss|sh)es$/i' => '\1', '/(m)ovies$/i' => '\1\2ovie', '/(s)eries$/i' => '\1\2eries', + '/(s)pecies$/i' => '\1\2pecies', '/([^aeiouy]|qu)ies$/i' => '\1y', '/(tive)s$/i' => '\1', '/(hive)s$/i' => '\1', @@ -95,15 +97,15 @@ class Inflector '/(n)ews$/i' => '\1\2ews', '/eaus$/' => 'eau', '/^(.*us)$/' => '\\1', - '/s$/i' => '' + '/s$/i' => '', ]; /** * Irregular rules * - * @var array + * @var array */ - protected static $_irregular = [ + protected static array $_irregular = [ 'atlas' => 'atlases', 'beef' => 'beefs', 'brief' => 'briefs', @@ -144,282 +146,46 @@ class Inflector 'goose' => 'geese', 'foot' => 'feet', 'foe' => 'foes', - 'sieve' => 'sieves' + 'sieve' => 'sieves', + 'cache' => 'caches', ]; /** * Words that should not be inflected * - * @var array + * @var array */ - protected static $_uninflected = [ + protected static array $_uninflected = [ '.*[nrlm]ese', '.*data', '.*deer', '.*fish', '.*measles', '.*ois', '.*pox', '.*sheep', 'people', 'feedback', 'stadia', '.*?media', 'chassis', 'clippers', 'debris', 'diabetes', 'equipment', 'gallows', 'graffiti', 'headquarters', 'information', 'innings', 'news', 'nexus', - 'pokemon', 'proceedings', 'research', 'sea[- ]bass', 'series', 'species', 'weather' - ]; - - /** - * Default map of accented and special characters to ASCII characters - * - * @var array - */ - protected static $_transliteration = [ - 'ä' => 'ae', - 'æ' => 'ae', - 'ǽ' => 'ae', - 'ö' => 'oe', - 'œ' => 'oe', - 'ü' => 'ue', - 'Ä' => 'Ae', - 'Ü' => 'Ue', - 'Ö' => 'Oe', - 'À' => 'A', - 'Á' => 'A', - 'Â' => 'A', - 'Ã' => 'A', - 'Å' => 'A', - 'Ǻ' => 'A', - 'Ā' => 'A', - 'Ă' => 'A', - 'Ą' => 'A', - 'Ǎ' => 'A', - 'à' => 'a', - 'á' => 'a', - 'â' => 'a', - 'ã' => 'a', - 'å' => 'a', - 'ǻ' => 'a', - 'ā' => 'a', - 'ă' => 'a', - 'ą' => 'a', - 'ǎ' => 'a', - 'ª' => 'a', - 'Ç' => 'C', - 'Ć' => 'C', - 'Ĉ' => 'C', - 'Ċ' => 'C', - 'Č' => 'C', - 'ç' => 'c', - 'ć' => 'c', - 'ĉ' => 'c', - 'ċ' => 'c', - 'č' => 'c', - 'Ð' => 'D', - 'Ď' => 'D', - 'Đ' => 'D', - 'ð' => 'd', - 'ď' => 'd', - 'đ' => 'd', - 'È' => 'E', - 'É' => 'E', - 'Ê' => 'E', - 'Ë' => 'E', - 'Ē' => 'E', - 'Ĕ' => 'E', - 'Ė' => 'E', - 'Ę' => 'E', - 'Ě' => 'E', - 'è' => 'e', - 'é' => 'e', - 'ê' => 'e', - 'ë' => 'e', - 'ē' => 'e', - 'ĕ' => 'e', - 'ė' => 'e', - 'ę' => 'e', - 'ě' => 'e', - 'Ĝ' => 'G', - 'Ğ' => 'G', - 'Ġ' => 'G', - 'Ģ' => 'G', - 'Ґ' => 'G', - 'ĝ' => 'g', - 'ğ' => 'g', - 'ġ' => 'g', - 'ģ' => 'g', - 'ґ' => 'g', - 'Ĥ' => 'H', - 'Ħ' => 'H', - 'ĥ' => 'h', - 'ħ' => 'h', - 'І' => 'I', - 'Ì' => 'I', - 'Í' => 'I', - 'Î' => 'I', - 'Ї' => 'Yi', - 'Ï' => 'I', - 'Ĩ' => 'I', - 'Ī' => 'I', - 'Ĭ' => 'I', - 'Ǐ' => 'I', - 'Į' => 'I', - 'İ' => 'I', - 'і' => 'i', - 'ì' => 'i', - 'í' => 'i', - 'î' => 'i', - 'ï' => 'i', - 'ї' => 'yi', - 'ĩ' => 'i', - 'ī' => 'i', - 'ĭ' => 'i', - 'ǐ' => 'i', - 'į' => 'i', - 'ı' => 'i', - 'Ĵ' => 'J', - 'ĵ' => 'j', - 'Ķ' => 'K', - 'ķ' => 'k', - 'Ĺ' => 'L', - 'Ļ' => 'L', - 'Ľ' => 'L', - 'Ŀ' => 'L', - 'Ł' => 'L', - 'ĺ' => 'l', - 'ļ' => 'l', - 'ľ' => 'l', - 'ŀ' => 'l', - 'ł' => 'l', - 'Ñ' => 'N', - 'Ń' => 'N', - 'Ņ' => 'N', - 'Ň' => 'N', - 'ñ' => 'n', - 'ń' => 'n', - 'ņ' => 'n', - 'ň' => 'n', - 'ʼn' => 'n', - 'Ò' => 'O', - 'Ó' => 'O', - 'Ô' => 'O', - 'Õ' => 'O', - 'Ō' => 'O', - 'Ŏ' => 'O', - 'Ǒ' => 'O', - 'Ő' => 'O', - 'Ơ' => 'O', - 'Ø' => 'O', - 'Ǿ' => 'O', - 'ò' => 'o', - 'ó' => 'o', - 'ô' => 'o', - 'õ' => 'o', - 'ō' => 'o', - 'ŏ' => 'o', - 'ǒ' => 'o', - 'ő' => 'o', - 'ơ' => 'o', - 'ø' => 'o', - 'ǿ' => 'o', - 'º' => 'o', - 'Ŕ' => 'R', - 'Ŗ' => 'R', - 'Ř' => 'R', - 'ŕ' => 'r', - 'ŗ' => 'r', - 'ř' => 'r', - 'Ś' => 'S', - 'Ŝ' => 'S', - 'Ş' => 'S', - 'Ș' => 'S', - 'Š' => 'S', - 'ẞ' => 'SS', - 'ś' => 's', - 'ŝ' => 's', - 'ş' => 's', - 'ș' => 's', - 'š' => 's', - 'ſ' => 's', - 'Ţ' => 'T', - 'Ț' => 'T', - 'Ť' => 'T', - 'Ŧ' => 'T', - 'ţ' => 't', - 'ț' => 't', - 'ť' => 't', - 'ŧ' => 't', - 'Ù' => 'U', - 'Ú' => 'U', - 'Û' => 'U', - 'Ũ' => 'U', - 'Ū' => 'U', - 'Ŭ' => 'U', - 'Ů' => 'U', - 'Ű' => 'U', - 'Ų' => 'U', - 'Ư' => 'U', - 'Ǔ' => 'U', - 'Ǖ' => 'U', - 'Ǘ' => 'U', - 'Ǚ' => 'U', - 'Ǜ' => 'U', - 'ù' => 'u', - 'ú' => 'u', - 'û' => 'u', - 'ũ' => 'u', - 'ū' => 'u', - 'ŭ' => 'u', - 'ů' => 'u', - 'ű' => 'u', - 'ų' => 'u', - 'ư' => 'u', - 'ǔ' => 'u', - 'ǖ' => 'u', - 'ǘ' => 'u', - 'ǚ' => 'u', - 'ǜ' => 'u', - 'Ý' => 'Y', - 'Ÿ' => 'Y', - 'Ŷ' => 'Y', - 'ý' => 'y', - 'ÿ' => 'y', - 'ŷ' => 'y', - 'Ŵ' => 'W', - 'ŵ' => 'w', - 'Ź' => 'Z', - 'Ż' => 'Z', - 'Ž' => 'Z', - 'ź' => 'z', - 'ż' => 'z', - 'ž' => 'z', - 'Æ' => 'AE', - 'Ǽ' => 'AE', - 'ß' => 'ss', - 'IJ' => 'IJ', - 'ij' => 'ij', - 'Œ' => 'OE', - 'ƒ' => 'f', - 'Þ' => 'TH', - 'þ' => 'th', - 'Є' => 'Ye', - 'є' => 'ye', + 'pokemon', 'proceedings', 'research', 'sea[- ]bass', 'series', 'species', 'weather', ]; /** * Method cache array. * - * @var array + * @var array */ - protected static $_cache = []; + protected static array $_cache = []; /** * The initial state of Inflector so reset() works. * * @var array */ - protected static $_initialState = []; + protected static array $_initialState = []; /** * Cache inflected values, and return if already available * * @param string $type Inflection type * @param string $key Original value - * @param string|bool $value Inflected value - * @return string|bool Inflected value on cache hit or false on cache miss. + * @param string|false $value Inflected value + * @return string|false Inflected value on cache hit or false on cache miss. */ - protected static function _cache($type, $key, $value = false) + protected static function _cache(string $type, string $key, string|false $value = false): string|false { $key = '_' . $key; $type = '_' . $type; @@ -441,10 +207,10 @@ protected static function _cache($type, $key, $value = false) * * @return void */ - public static function reset() + public static function reset(): void { - if (empty(static::$_initialState)) { - static::$_initialState = get_class_vars(__CLASS__); + if (static::$_initialState === []) { + static::$_initialState = get_class_vars(self::class); return; } @@ -457,7 +223,7 @@ public static function reset() /** * Adds custom inflection $rules, of either 'plural', 'singular', - * 'uninflected', 'irregular' or 'transliteration' $type. + * 'uninflected' or 'irregular' $type. * * ### Usage: * @@ -465,17 +231,16 @@ public static function reset() * Inflector::rules('plural', ['/^(inflect)or$/i' => '\1ables']); * Inflector::rules('irregular', ['red' => 'redlings']); * Inflector::rules('uninflected', ['dontinflectme']); - * Inflector::rules('transliteration', ['/å/' => 'aa']); * ``` * * @param string $type The type of inflection, either 'plural', 'singular', - * 'uninflected' or 'transliteration'. + * or 'uninflected'. * @param array $rules Array of rules to be added. * @param bool $reset If true, will unset default inflections for all * new rules that are being defined in $rules. * @return void */ - public static function rules($type, $rules, $reset = false) + public static function rules(string $type, array $rules, bool $reset = false): void { $var = '_' . $type; @@ -484,7 +249,7 @@ public static function rules($type, $rules, $reset = false) } elseif ($type === 'uninflected') { static::$_uninflected = array_merge( $rules, - static::$_uninflected + static::$_uninflected, ); } else { static::${$var} = $rules + static::${$var}; @@ -498,19 +263,26 @@ public static function rules($type, $rules, $reset = false) * * @param string $word Word in singular * @return string Word in plural - * @link https://book.cakephp.org/3.0/en/core-libraries/inflector.html#creating-plural-singular-forms + * @link https://book.cakephp.org/5/en/core-libraries/inflector.html#creating-plural-singular-forms */ - public static function pluralize($word) + public static function pluralize(string $word): string { if (isset(static::$_cache['pluralize'][$word])) { return static::$_cache['pluralize'][$word]; } if (!isset(static::$_cache['irregular']['pluralize'])) { - static::$_cache['irregular']['pluralize'] = '(?:' . implode('|', array_keys(static::$_irregular)) . ')'; + $words = array_keys(static::$_irregular); + static::$_cache['irregular']['pluralize'] = '/(.*?(?:\\b|_))(' . implode('|', $words) . ')$/i'; + + $upperWords = array_map('ucfirst', $words); + static::$_cache['irregular']['upperPluralize'] = '/(.*?(?:\\b|[a-z]))(' . implode('|', $upperWords) . ')$/'; } - if (preg_match('/(.*?(?:\\b|_))(' . static::$_cache['irregular']['pluralize'] . ')$/i', $word, $regs)) { + if ( + preg_match(static::$_cache['irregular']['pluralize'], $word, $regs) || + preg_match(static::$_cache['irregular']['upperPluralize'], $word, $regs) + ) { static::$_cache['pluralize'][$word] = $regs[1] . substr($regs[2], 0, 1) . substr(static::$_irregular[strtolower($regs[2])], 1); @@ -518,10 +290,10 @@ public static function pluralize($word) } if (!isset(static::$_cache['uninflected'])) { - static::$_cache['uninflected'] = '(?:' . implode('|', static::$_uninflected) . ')'; + static::$_cache['uninflected'] = '/^(' . implode('|', static::$_uninflected) . ')$/i'; } - if (preg_match('/^(' . static::$_cache['uninflected'] . ')$/i', $word, $regs)) { + if (preg_match(static::$_cache['uninflected'], $word, $regs)) { static::$_cache['pluralize'][$word] = $word; return $word; @@ -529,11 +301,13 @@ public static function pluralize($word) foreach (static::$_plural as $rule => $replacement) { if (preg_match($rule, $word)) { - static::$_cache['pluralize'][$word] = preg_replace($rule, $replacement, $word); + static::$_cache['pluralize'][$word] = (string)preg_replace($rule, $replacement, $word); return static::$_cache['pluralize'][$word]; } } + + return $word; } /** @@ -541,38 +315,48 @@ public static function pluralize($word) * * @param string $word Word in plural * @return string Word in singular - * @link https://book.cakephp.org/3.0/en/core-libraries/inflector.html#creating-plural-singular-forms + * @link https://book.cakephp.org/5/en/core-libraries/inflector.html#creating-plural-singular-forms */ - public static function singularize($word) + public static function singularize(string $word): string { if (isset(static::$_cache['singularize'][$word])) { return static::$_cache['singularize'][$word]; } if (!isset(static::$_cache['irregular']['singular'])) { - static::$_cache['irregular']['singular'] = '(?:' . implode('|', static::$_irregular) . ')'; + $wordList = array_values(static::$_irregular); + static::$_cache['irregular']['singular'] = '/(.*?(?:\\b|_))(' . implode('|', $wordList) . ')$/i'; + + $upperWordList = array_map('ucfirst', $wordList); + static::$_cache['irregular']['singularUpper'] = '/(.*?(?:\\b|[a-z]))(' . + implode('|', $upperWordList) . + ')$/'; } - if (preg_match('/(.*?(?:\\b|_))(' . static::$_cache['irregular']['singular'] . ')$/i', $word, $regs)) { - static::$_cache['singularize'][$word] = $regs[1] . substr($regs[2], 0, 1) . - substr(array_search(strtolower($regs[2]), static::$_irregular), 1); + if ( + preg_match(static::$_cache['irregular']['singular'], $word, $regs) || + preg_match(static::$_cache['irregular']['singularUpper'], $word, $regs) + ) { + $suffix = array_search(strtolower($regs[2]), static::$_irregular, true); + $suffix = $suffix ? substr($suffix, 1) : ''; + static::$_cache['singularize'][$word] = $regs[1] . substr($regs[2], 0, 1) . $suffix; return static::$_cache['singularize'][$word]; } if (!isset(static::$_cache['uninflected'])) { - static::$_cache['uninflected'] = '(?:' . implode('|', static::$_uninflected) . ')'; + static::$_cache['uninflected'] = '/^(' . implode('|', static::$_uninflected) . ')$/i'; } - if (preg_match('/^(' . static::$_cache['uninflected'] . ')$/i', $word, $regs)) { - static::$_cache['pluralize'][$word] = $word; + if (preg_match(static::$_cache['uninflected'], $word, $regs)) { + static::$_cache['singularize'][$word] = $word; return $word; } foreach (static::$_singular as $rule => $replacement) { if (preg_match($rule, $word)) { - static::$_cache['singularize'][$word] = preg_replace($rule, $replacement, $word); + static::$_cache['singularize'][$word] = (string)preg_replace($rule, $replacement, $word); return static::$_cache['singularize'][$word]; } @@ -588,9 +372,9 @@ public static function singularize($word) * @param string $string String to camelize * @param string $delimiter the delimiter in the input string * @return string CamelizedStringLikeThis. - * @link https://book.cakephp.org/3.0/en/core-libraries/inflector.html#creating-camelcase-and-under-scored-forms + * @link https://book.cakephp.org/5/en/core-libraries/inflector.html#creating-camelcase-and-under-scored-forms */ - public static function camelize($string, $delimiter = '_') + public static function camelize(string $string, string $delimiter = '_'): string { $cacheKey = __FUNCTION__ . $delimiter; @@ -611,9 +395,9 @@ public static function camelize($string, $delimiter = '_') * * @param string $string CamelCasedString to be "underscorized" * @return string underscore_version of the input string - * @link https://book.cakephp.org/3.0/en/core-libraries/inflector.html#creating-camelcase-and-under-scored-forms + * @link https://book.cakephp.org/5/en/core-libraries/inflector.html#creating-camelcase-and-under-scored-forms */ - public static function underscore($string) + public static function underscore(string $string): string { return static::delimit(str_replace('-', '_', $string), '_'); } @@ -626,7 +410,7 @@ public static function underscore($string) * @param string $string The string to dasherize. * @return string Dashed version of the input string */ - public static function dasherize($string) + public static function dasherize(string $string): string { return static::delimit(str_replace('_', '-', $string), '-'); } @@ -638,9 +422,9 @@ public static function dasherize($string) * @param string $string String to be humanized * @param string $delimiter the character to replace with a space * @return string Human-readable string - * @link https://book.cakephp.org/3.0/en/core-libraries/inflector.html#creating-human-readable-forms + * @link https://book.cakephp.org/5/en/core-libraries/inflector.html#creating-human-readable-forms */ - public static function humanize($string, $delimiter = '_') + public static function humanize(string $string, string $delimiter = '_'): string { $cacheKey = __FUNCTION__ . $delimiter; @@ -665,14 +449,14 @@ public static function humanize($string, $delimiter = '_') * @param string $delimiter the character to use as a delimiter * @return string delimited string */ - public static function delimit($string, $delimiter = '_') + public static function delimit(string $string, string $delimiter = '_'): string { $cacheKey = __FUNCTION__ . $delimiter; $result = static::_cache($cacheKey, $string); if ($result === false) { - $result = mb_strtolower(preg_replace('/(?<=\\w)([A-Z])/', $delimiter . '\\1', $string)); + $result = mb_strtolower((string)preg_replace('/(?<=\\w)([A-Z])/', $delimiter . '\\1', $string)); static::_cache($cacheKey, $string, $result); } @@ -680,13 +464,13 @@ public static function delimit($string, $delimiter = '_') } /** - * Returns corresponding table name for given model $className. ("people" for the model class "Person"). + * Returns corresponding table name for given model $className. ("people" for the class name "Person"). * * @param string $className Name of class to get database table name for * @return string Name of the database table for given class - * @link https://book.cakephp.org/3.0/en/core-libraries/inflector.html#creating-table-and-class-name-forms + * @link https://book.cakephp.org/5/en/core-libraries/inflector.html#creating-table-and-class-name-forms */ - public static function tableize($className) + public static function tableize(string $className): string { $result = static::_cache(__FUNCTION__, $className); @@ -699,13 +483,13 @@ public static function tableize($className) } /** - * Returns Cake model class name ("Person" for the database table "people".) for given database table. + * Returns a singular, CamelCase inflection for given database table. ("Person" for the table name "people") * * @param string $tableName Name of database table to get class name for * @return string Class name - * @link https://book.cakephp.org/3.0/en/core-libraries/inflector.html#creating-table-and-class-name-forms + * @link https://book.cakephp.org/5/en/core-libraries/inflector.html#creating-table-and-class-name-forms */ - public static function classify($tableName) + public static function classify(string $tableName): string { $result = static::_cache(__FUNCTION__, $tableName); @@ -722,9 +506,9 @@ public static function classify($tableName) * * @param string $string String to convert. * @return string in variable form - * @link https://book.cakephp.org/3.0/en/core-libraries/inflector.html#creating-variable-names + * @link https://book.cakephp.org/5/en/core-libraries/inflector.html#creating-variable-names */ - public static function variable($string) + public static function variable(string $string): string { $result = static::_cache(__FUNCTION__, $string); @@ -737,33 +521,4 @@ public static function variable($string) return $result; } - - /** - * Returns a string with all spaces converted to dashes (by default), accented - * characters converted to non-accented characters, and non word characters removed. - * - * @deprecated 3.2.7 Use Text::slug() instead. - * @param string $string the string you want to slug - * @param string $replacement will replace keys in map - * @return string - * @link https://book.cakephp.org/3.0/en/core-libraries/inflector.html#creating-url-safe-strings - */ - public static function slug($string, $replacement = '-') - { - $quotedReplacement = preg_quote($replacement, '/'); - - $map = [ - '/[^\s\p{Zs}\p{Ll}\p{Lm}\p{Lo}\p{Lt}\p{Lu}\p{Nd}]/mu' => ' ', - '/[\s\p{Zs}]+/mu' => $replacement, - sprintf('/^[%s]+|[%s]+$/', $quotedReplacement, $quotedReplacement) => '', - ]; - - $string = str_replace( - array_keys(static::$_transliteration), - array_values(static::$_transliteration), - $string - ); - - return preg_replace(array_keys($map), array_values($map), $string); - } } diff --git a/src/Utility/LICENSE.txt b/src/Utility/LICENSE.txt index 0c4b7932c31..b938c9e8ed3 100644 --- a/src/Utility/LICENSE.txt +++ b/src/Utility/LICENSE.txt @@ -1,7 +1,7 @@ The MIT License (MIT) CakePHP(tm) : The Rapid Development PHP Framework (https://cakephp.org) -Copyright (c) 2005-2016, Cake Software Foundation, Inc. (https://cakefoundation.org) +Copyright (c) 2005-2020, Cake Software Foundation, Inc. (https://cakefoundation.org) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/Utility/MergeVariablesTrait.php b/src/Utility/MergeVariablesTrait.php index 6f676518d92..3db0f304d86 100644 --- a/src/Utility/MergeVariablesTrait.php +++ b/src/Utility/MergeVariablesTrait.php @@ -1,4 +1,6 @@ $properties An array of properties and the merge strategy for them. + * @param array $options The options to use when merging properties. * @return void */ - protected function _mergeVars($properties, $options = []) + protected function _mergeVars(array $properties, array $options = []): void { - $class = get_class($this); + $class = static::class; $parents = []; while (true) { $parent = get_parent_class($class); @@ -60,16 +61,17 @@ protected function _mergeVars($properties, $options = []) * Merge a single property with the values declared in all parent classes. * * @param string $property The name of the property being merged. - * @param array $parentClasses An array of classes you want to merge with. - * @param array $options Options for merging the property, see _mergeVars() + * @param array $parentClasses An array of classes you want to merge with. + * @param array $options Options for merging the property, see _mergeVars() * @return void */ - protected function _mergeProperty($property, $parentClasses, $options) + protected function _mergeProperty(string $property, array $parentClasses, array $options): void { $thisValue = $this->{$property}; $isAssoc = false; - if (isset($options['associative']) && - in_array($property, (array)$options['associative']) + if ( + isset($options['associative']) && + in_array($property, (array)$options['associative'], true) ) { $isAssoc = true; } @@ -96,19 +98,17 @@ protected function _mergeProperty($property, $parentClasses, $options) * * @param array $current The current merged value. * @param array $parent The parent class' value. - * @param bool $isAssoc Whether or not the merging should be done in associative mode. - * @return mixed The updated value. + * @param bool $isAssoc Whether the merging should be done in associative mode. + * @return array The updated value. */ - protected function _mergePropertyData($current, $parent, $isAssoc) + protected function _mergePropertyData(array $current, array $parent, bool $isAssoc): array { if (!$isAssoc) { return array_merge($parent, $current); } $parent = Hash::normalize($parent); foreach ($parent as $key => $value) { - if (!isset($current[$key])) { - $current[$key] = $value; - } + $current[$key] ??= $value; } return $current; diff --git a/src/Utility/README.md b/src/Utility/README.md index 3183e002c95..a175976064e 100644 --- a/src/Utility/README.md +++ b/src/Utility/README.md @@ -23,7 +23,7 @@ $bigPeople = Hash::extract($things, '{n}[age>21].name'); // $bigPeople will contain ['Susan', 'Lucy'] ``` -Check the [official Hash class documentation](https://book.cakephp.org/3.0/en/core-libraries/hash.html) +Check the [official Hash class documentation](https://book.cakephp.org/5/en/core-libraries/hash.html) ### Inflector @@ -36,7 +36,7 @@ echo Inflector::pluralize('Apple'); // echoes Apples echo Inflector::singularize('People'); // echoes Person ``` -Check the [official Inflector class documentation](https://book.cakephp.org/3.0/en/core-libraries/inflector.html) +Check the [official Inflector class documentation](https://book.cakephp.org/5/en/core-libraries/inflector.html) ### Text @@ -57,7 +57,7 @@ This is the song that never ends. ``` -Check the [official Text class documentation](https://book.cakephp.org/3.0/en/core-libraries/text.html) +Check the [official Text class documentation](https://book.cakephp.org/5/en/core-libraries/text.html) ### Security @@ -70,7 +70,7 @@ $result = Security::encrypt($value, $key); Security::decrypt($result, $key); ``` -Check the [official Security class documentation](https://book.cakephp.org/3.0/en/core-libraries/security.html) +Check the [official Security class documentation](https://book.cakephp.org/5/en/core-libraries/security.html) ### Xml @@ -88,4 +88,4 @@ $data = [ $xml = Xml::build($data); ``` -Check the [official Xml class documentation](https://book.cakephp.org/3.0/en/core-libraries/xml.html) +Check the [official Xml class documentation](https://book.cakephp.org/5/en/core-libraries/xml.html) diff --git a/src/Utility/Security.php b/src/Utility/Security.php index 2169199d498..8e9d1f84537 100644 --- a/src/Utility/Security.php +++ b/src/Utility/Security.php @@ -1,4 +1,6 @@ `'); } - if (function_exists('openssl_random_pseudo_bytes')) { - $bytes = openssl_random_pseudo_bytes($length, $strongSource); - if (!$strongSource) { - trigger_error( - 'openssl was unable to use a strong source of entropy. ' . - 'Consider updating your system libraries, or ensuring ' . - 'you have more available entropy.', - E_USER_WARNING - ); - } - return $bytes; - } - trigger_error( - 'You do not have a safe source of random data available. ' . - 'Install either the openssl extension, or paragonie/random_compat. ' . - 'Falling back to an insecure random source.', - E_USER_WARNING - ); + return random_bytes($length); + } - return static::insecureRandomBytes($length); + /** + * Creates a secure random string. + * + * @param int $length String length. Default 64. + * @return string + */ + public static function randomString(int $length = 64): string + { + return substr( + bin2hex(Security::randomBytes((int)ceil($length / 2))), + 0, + $length, + ); } /** @@ -132,14 +141,14 @@ public static function randomBytes($length) * @return string Random bytes in binary. * @see \Cake\Utility\Security::randomBytes() */ - public static function insecureRandomBytes($length) + public static function insecureRandomBytes(int $length): string { $length *= 2; $bytes = ''; $byteLength = 0; while ($byteLength < $length) { - $bytes .= static::hash(Text::uuid() . uniqid(mt_rand(), true), 'sha512', true); + $bytes .= static::hash(Text::uuid() . uniqid((string)mt_rand(), true), 'sha512', true); $byteLength = strlen($bytes); } $bytes = substr($bytes, 0, $length); @@ -150,58 +159,31 @@ public static function insecureRandomBytes($length) /** * Get the crypto implementation based on the loaded extensions. * - * You can use this method to forcibly decide between mcrypt/openssl/custom implementations. + * You can use this method to forcibly decide between openssl/custom implementations. * - * @param \Cake\Utility\Crypto\OpenSsl|\Cake\Utility\Crypto\Mcrypt|null $instance The crypto instance to use. - * @return \Cake\Utility\Crypto\OpenSsl|\Cake\Utility\Crypto\Mcrypt Crypto instance. + * @param \Cake\Utility\Crypto\OpenSsl|null $instance The crypto instance to use. If provided, sets and returns this instance. + * If null, returns the currently configured engine or creates a new OpenSsl instance. + * @return \Cake\Utility\Crypto\OpenSsl Crypto instance. By default, returns a \Cake\Utility\Crypto\OpenSsl instance. * @throws \InvalidArgumentException When no compatible crypto extension is available. */ - public static function engine($instance = null) + public static function engine(?object $instance = null): object { - if ($instance === null && static::$_instance === null) { - if (extension_loaded('openssl')) { - $instance = new OpenSsl(); - } elseif (extension_loaded('mcrypt')) { - $instance = new Mcrypt(); - } - } if ($instance) { - static::$_instance = $instance; + return static::$_instance = $instance; } if (isset(static::$_instance)) { + /** @var \Cake\Utility\Crypto\OpenSsl */ return static::$_instance; } + if (extension_loaded('openssl')) { + return static::$_instance = new OpenSsl(); + } throw new InvalidArgumentException( 'No compatible crypto engine available. ' . - 'Load either the openssl or mcrypt extensions' + 'Load the openssl extension.', ); } - /** - * Encrypts/Decrypts a text using the given key using rijndael method. - * - * @param string $text Encrypted string to decrypt, normal string to encrypt - * @param string $key Key to use as the encryption key for encrypted data. - * @param string $operation Operation to perform, encrypt or decrypt - * @throws \InvalidArgumentException When there are errors. - * @return string Encrypted/Decrypted string - */ - public static function rijndael($text, $key, $operation) - { - if (empty($key)) { - throw new InvalidArgumentException('You cannot use an empty key for Security::rijndael()'); - } - if (empty($operation) || !in_array($operation, ['encrypt', 'decrypt'])) { - throw new InvalidArgumentException('You must specify the operation for Security::rijndael(), either encrypt or decrypt'); - } - if (mb_strlen($key, '8bit') < 32) { - throw new InvalidArgumentException('You must use a key larger than 32 bytes for Security::rijndael()'); - } - $crypto = static::engine(); - - return $crypto->rijndael($text, $key, $operation); - } - /** * Encrypt a value using AES-256. * @@ -211,23 +193,23 @@ public static function rijndael($text, $key, $operation) * * @param string $plain The value to encrypt. * @param string $key The 256 bit/32 byte key to use as a cipher key. - * @param string|null $hmacSalt The salt to use for the HMAC process. Leave null to use Security.salt. + * @param string|null $hmacSalt The salt to use for the HMAC process. + * Leave null to use value of Security::getSalt(). * @return string Encrypted data. * @throws \InvalidArgumentException On invalid data or key. */ - public static function encrypt($plain, $key, $hmacSalt = null) + public static function encrypt(string $plain, #[SensitiveParameter] string $key, #[SensitiveParameter] ?string $hmacSalt = null): string { self::_checkKey($key, 'encrypt()'); - if ($hmacSalt === null) { - $hmacSalt = static::$_salt; - } + $hmacSalt ??= static::getSalt(); + // Generate the encryption and hmac key. - $key = mb_substr(hash('sha256', $key . $hmacSalt), 0, 32, '8bit'); + [$encryptionKey, $hmacKey] = static::makeEncryptionKeys($key, $hmacSalt); $crypto = static::engine(); - $ciphertext = $crypto->encrypt($plain, $key); - $hmac = hash_hmac('sha256', $ciphertext, $key); + $ciphertext = $crypto->encrypt($plain, $encryptionKey); + $hmac = hash_hmac('sha256', $ciphertext, $hmacKey); return $hmac . $ciphertext; } @@ -240,76 +222,91 @@ public static function encrypt($plain, $key, $hmacSalt = null) * @return void * @throws \InvalidArgumentException When key length is not 256 bit/32 bytes */ - protected static function _checkKey($key, $method) + protected static function _checkKey(#[SensitiveParameter] string $key, string $method): void { if (mb_strlen($key, '8bit') < 32) { throw new InvalidArgumentException( - sprintf('Invalid key for %s, key must be at least 256 bits (32 bytes) long.', $method) + sprintf('Invalid key for %s, key must be at least 256 bits (32 bytes) long.', $method), ); } } + /** + * Generate a key pair of encryption and authentication tokens. + * + * Encapsulates the two key generation implementations we support. + * The previous implementation has a keyspace reduction weakness. + * + * It is recommended to enable `Security.encryptWithRawKey` in new applications, + * to take advantage of longer keys that are longer and have derived encryption + * and authentication keys. + * + * @param string $key The bare key to use. + * @param string $hmacSalt The hmac salt to use. + * @return array{string, string} A list of $encryption, $authentication keys intended for encrypt() and decrypt(). + */ + protected static function makeEncryptionKeys(#[SensitiveParameter] string $key, #[SensitiveParameter] string $hmacSalt): array + { + if (Configure::read('Security.encryptWithRawKey') === true) { + $encryption = hash_hkdf('sha256', $key, 32, 'encryption', $hmacSalt); + $authentication = hash_hkdf('sha256', $key, 32, 'authentication', $hmacSalt); + + return [$encryption, $authentication]; + } + + $hashKey = mb_substr(hash('sha256', $key . $hmacSalt), 0, 32, '8bit'); + + // The old implementation both keys were the same. + return [$hashKey, $hashKey]; + } + /** * Decrypt a value using AES-256. * * @param string $cipher The ciphertext to decrypt. * @param string $key The 256 bit/32 byte key to use as a cipher key. - * @param string|null $hmacSalt The salt to use for the HMAC process. Leave null to use Security.salt. - * @return string|bool Decrypted data. Any trailing null bytes will be removed. + * @param string|null $hmacSalt The salt to use for the HMAC process. + * Leave null to use value of Security::getSalt(). + * @return string|null Decrypted data. Any trailing null bytes will be removed. * @throws \InvalidArgumentException On invalid data or key. */ - public static function decrypt($cipher, $key, $hmacSalt = null) + public static function decrypt(string $cipher, #[SensitiveParameter] string $key, #[SensitiveParameter] ?string $hmacSalt = null): ?string { self::_checkKey($key, 'decrypt()'); - if (empty($cipher)) { + if (!$cipher) { throw new InvalidArgumentException('The data to decrypt cannot be empty.'); } - if ($hmacSalt === null) { - $hmacSalt = static::$_salt; - } + $hmacSalt ??= static::getSalt(); // Generate the encryption and hmac key. - $key = mb_substr(hash('sha256', $key . $hmacSalt), 0, 32, '8bit'); + [$encryptionKey, $hmacKey] = static::makeEncryptionKeys($key, $hmacSalt); // Split out hmac for comparison $macSize = 64; $hmac = mb_substr($cipher, 0, $macSize, '8bit'); $cipher = mb_substr($cipher, $macSize, null, '8bit'); - $compareHmac = hash_hmac('sha256', $cipher, $key); - if (!static::_constantEquals($hmac, $compareHmac)) { - return false; + $compareHmac = hash_hmac('sha256', $cipher, $hmacKey); + if (!static::constantEquals($hmac, $compareHmac)) { + return null; } $crypto = static::engine(); - return $crypto->decrypt($cipher, $key); + return $crypto->decrypt($cipher, $encryptionKey); } /** * A timing attack resistant comparison that prefers native PHP implementations. * - * @param string $hmac The hmac from the ciphertext being decrypted. - * @param string $compare The comparison hmac. + * @param mixed $original The original value. + * @param mixed $compare The comparison value. * @return bool - * @see https://github.com/resonantcore/php-future/ + * @since 3.6.2 */ - protected static function _constantEquals($hmac, $compare) + public static function constantEquals(mixed $original, mixed $compare): bool { - if (function_exists('hash_equals')) { - return hash_equals($hmac, $compare); - } - $hashLength = mb_strlen($hmac, '8bit'); - $compareLength = mb_strlen($compare, '8bit'); - if ($hashLength !== $compareLength) { - return false; - } - $result = 0; - for ($i = 0; $i < $hashLength; $i++) { - $result |= (ord($hmac[$i]) ^ ord($compare[$i])); - } - - return $result === 0; + return is_string($original) && is_string($compare) && hash_equals($original, $compare); } /** @@ -318,8 +315,14 @@ protected static function _constantEquals($hmac, $compare) * * @return string The currently configured salt */ - public static function getSalt() + public static function getSalt(): string { + if (static::$_salt === null) { + throw new CakeException( + 'Salt not set. Use Security::setSalt() to set one, ideally in `config/bootstrap.php`.', + ); + } + return static::$_salt; } @@ -330,25 +333,8 @@ public static function getSalt() * @param string $salt The salt to use for encryption routines. * @return void */ - public static function setSalt($salt) + public static function setSalt(#[SensitiveParameter] string $salt): void { - static::$_salt = (string)$salt; - } - - /** - * Gets or sets the HMAC salt to be used for encryption/decryption - * routines. - * - * @deprecated 3.5.0 Use getSalt()/setSalt() instead. - * @param string|null $salt The salt to use for encryption routines. If null returns current salt. - * @return string The currently configured salt - */ - public static function salt($salt = null) - { - if ($salt === null) { - return static::$_salt; - } - - return static::$_salt = (string)$salt; + static::$_salt = $salt; } } diff --git a/src/Utility/String.php b/src/Utility/String.php deleted file mode 100644 index 9c86189faa9..00000000000 --- a/src/Utility/String.php +++ /dev/null @@ -1,5 +0,0 @@ - */ - protected static $_defaultHtmlNoCount = [ + protected static array $_defaultHtmlNoCount = [ 'style', - 'script' + 'script', ]; + /** + * Whether to use I18n functions for translating default error messages + * + * @var bool + */ + protected static bool $useI18n; + /** * Generate a random UUID version 4 * * Warning: This method should not be used as a random seed for any cryptographic operations. - * Instead you should use the openssl or mcrypt extensions. + * Instead, you should use `Security::randomBytes()` or `Security::randomString()` instead. * * It should also not be used to create identifiers that have security implications, such as - * 'unguessable' URL identifiers. Instead you should use `Security::randomBytes()` for that. + * 'unguessable' URL identifiers. Instead, you should use {@link \Cake\Utility\Security::randomBytes()}` for that. + * + * ### Custom UUID generation + * + * You can configure a custom UUID generator by setting a Closure via Configure: + * + * ``` + * Configure::write('Text.uuidGenerator', function () { + * // Return your custom UUID string + * return MyUuidLibrary::generate(); + * }); + * ``` * * @see https://www.ietf.org/rfc/rfc4122.txt * @return string RFC 4122 UUID + * @link https://book.cakephp.org/5/en/core-libraries/text.html#text-uuid * @copyright Matt Farina MIT License https://github.com/lootils/uuid/blob/master/LICENSE */ - public static function uuid() + public static function uuid(): string { - $random = function_exists('random_int') ? 'random_int' : 'mt_rand'; + $generator = Configure::read('Text.uuidGenerator'); + if ($generator instanceof Closure) { + return $generator(); + } return sprintf( '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', // 32 bits for "time_low" - $random(0, 65535), - $random(0, 65535), + random_int(0, 65535), + random_int(0, 65535), // 16 bits for "time_mid" - $random(0, 65535), + random_int(0, 65535), // 12 bits before the 0100 of (version) 4 for "time_hi_and_version" - $random(0, 4095) | 0x4000, + random_int(0, 4095) | 0x4000, // 16 bits, 8 bits for "clk_seq_hi_res", // 8 bits for "clk_seq_low", // two most significant bits holds zero and one for variant DCE1.1 - $random(0, 0x3fff) | 0x8000, + random_int(0, 0x3fff) | 0x8000, // 48 bits for "node" - $random(0, 65535), - $random(0, 65535), - $random(0, 65535) + random_int(0, 65535), + random_int(0, 65535), + random_int(0, 65535), ); } @@ -84,11 +119,16 @@ public static function uuid() * @param string $separator The token to split the data on. * @param string $leftBound The left boundary to ignore separators in. * @param string $rightBound The right boundary to ignore separators in. - * @return array|string Array of tokens in $data or original input if empty. + * @return array Array of tokens in $data. + * @link https://book.cakephp.org/5/en/core-libraries/text.html#text-tokenize */ - public static function tokenize($data, $separator = ',', $leftBound = '(', $rightBound = ')') - { - if (empty($data)) { + public static function tokenize( + string $data, + string $separator = ',', + string $leftBound = '(', + string $rightBound = ')', + ): array { + if (!$data) { return []; } @@ -104,10 +144,10 @@ public static function tokenize($data, $separator = ',', $leftBound = '(', $righ $offsets = [ mb_strpos($data, $separator, $offset), mb_strpos($data, $leftBound, $offset), - mb_strpos($data, $rightBound, $offset) + mb_strpos($data, $rightBound, $offset), ]; for ($i = 0; $i < 3; $i++) { - if ($offsets[$i] !== false && ($offsets[$i] < $tmpOffset || $tmpOffset == -1)) { + if ($offsets[$i] !== false && ($offsets[$i] < $tmpOffset || $tmpOffset === -1)) { $tmpOffset = $offsets[$i]; } } @@ -127,28 +167,27 @@ public static function tokenize($data, $separator = ',', $leftBound = '(', $righ if ($char === $rightBound) { $depth--; } - } else { - if ($char === $leftBound) { - if (!$open) { - $depth++; - $open = true; - } else { - $depth--; - $open = false; - } + } elseif ($char === $leftBound) { + if (!$open) { + $depth++; + $open = true; + } else { + $depth--; + $open = false; } } - $offset = ++$tmpOffset; + $tmpOffset += 1; + $offset = $tmpOffset; } else { $results[] = $buffer . mb_substr($data, $offset); $offset = $length + 1; } } - if (empty($results) && !empty($buffer)) { + if (!$results && $buffer) { $results[] = $buffer; } - if (!empty($results)) { + if ($results) { return array_map('trim', $results); } @@ -176,58 +215,47 @@ public static function tokenize($data, $separator = ',', $leftBound = '(', $righ * @param string $str A string containing variable placeholders * @param array $data A key => val array where each key stands for a placeholder variable name * to be replaced with val - * @param array $options An array of options, see description above + * @param array $options An array of options, see description above * @return string + * @link https://book.cakephp.org/5/en/core-libraries/text.html#text-insert */ - public static function insert($str, $data, array $options = []) + public static function insert(string $str, array $data, array $options = []): string { - $defaults = [ - 'before' => ':', 'after' => null, 'escape' => '\\', 'format' => null, 'clean' => false - ]; - $options += $defaults; - $format = $options['format']; - $data = (array)$data; - if (empty($data)) { + $options += ['before' => ':', 'after' => '', 'escape' => '\\', 'format' => null, 'clean' => false]; + if (!$data) { return $options['clean'] ? static::cleanInsert($str, $options) : $str; } - if (!isset($format)) { - $format = sprintf( - '/(? hash('xxh128', (string)$str), + $dataKeys, + ); + /** @var array $tempData */ $tempData = array_combine($dataKeys, $hashKeys); krsort($tempData); foreach ($tempData as $key => $hashVal) { $key = sprintf($format, preg_quote($key, '/')); - $str = preg_replace($key, $hashVal, $str); + $str = (string)preg_replace($key, $hashVal, $str); } + /** @var array $dataReplacements */ $dataReplacements = array_combine($hashKeys, array_values($data)); foreach ($dataReplacements as $tmpHash => $tmpValue) { - $tmpValue = is_array($tmpValue) ? '' : $tmpValue; - $str = str_replace($tmpHash, $tmpValue, $str); + $tmpValue = is_array($tmpValue) ? '' : (string)$tmpValue; + $str = (string)str_replace($tmpHash, $tmpValue, $str); } - if (!isset($options['format']) && isset($options['before'])) { - $str = str_replace($options['escape'] . $options['before'], $options['before'], $str); + if ($options['format'] === null && $options['before'] !== null) { + $str = (string)str_replace($options['escape'] . $options['before'], $options['before'], $str); } return $options['clean'] ? static::cleanInsert($str, $options) : $str; @@ -240,11 +268,12 @@ public static function insert($str, $data, array $options = []) * by Text::insert(). * * @param string $str String to clean. - * @param array $options Options list. + * @param array $options Options list. * @return string * @see \Cake\Utility\Text::insert() + * @link https://book.cakephp.org/5/en/core-libraries/text.html#text-cleaninsert */ - public static function cleanInsert($str, array $options) + public static function cleanInsert(string $str, array $options): string { $clean = $options['clean']; if (!$clean) { @@ -267,9 +296,9 @@ public static function cleanInsert($str, array $options) '/[\s]*[a-z]+=(")(%s%s%s[\s]*)+\\1/i', preg_quote($options['before'], '/'), $clean['word'], - preg_quote($options['after'], '/') + preg_quote($options['after'], '/'), ); - $str = preg_replace($kleenex, $clean['replacement'], $str); + $str = (string)preg_replace($kleenex, $clean['replacement'], $str); if ($clean['andText']) { $options['clean'] = ['method' => 'text']; $str = static::cleanInsert($str, $options); @@ -291,9 +320,9 @@ public static function cleanInsert($str, array $options) $clean['gap'], preg_quote($options['before'], '/'), $clean['word'], - preg_quote($options['after'], '/') + preg_quote($options['after'], '/'), ); - $str = preg_replace($kleenex, $clean['replacement'], $str); + $str = (string)preg_replace($kleenex, $clean['replacement'], $str); break; } @@ -311,21 +340,26 @@ public static function cleanInsert($str, array $options) * - `indentAt` 0 based index to start indenting at. Defaults to 0. * * @param string $text The text to format. - * @param array|int $options Array of options to use, or an integer to wrap the text to. + * @param array|int $options Array of options to use, or an integer to wrap the text to. * @return string Formatted text. + * @link https://book.cakephp.org/5/en/core-libraries/text.html#text-wrap */ - public static function wrap($text, $options = []) + public static function wrap(string $text, array|int $options = []): string { - if (is_numeric($options)) { + if (is_int($options)) { $options = ['width' => $options]; } $options += ['width' => 72, 'wordWrap' => true, 'indent' => null, 'indentAt' => 0]; if ($options['wordWrap']) { $wrapped = self::wordWrap($text, $options['width'], "\n"); } else { - $wrapped = trim(chunk_split($text, $options['width'] - 1, "\n")); + $length = $options['width'] - 1; + if ($length < 1) { + throw new InvalidArgumentException('Length must be `int<1, max>`.'); + } + $wrapped = trim(chunk_split($text, $length, "\n")); } - if (!empty($options['indent'])) { + if ($options['indent']) { $chunks = explode("\n", $wrapped); for ($i = $options['indentAt'], $len = count($chunks); $i < $len; $i++) { $chunks[$i] = $options['indent'] . $chunks[$i]; @@ -348,26 +382,20 @@ public static function wrap($text, $options = []) * - `indentAt` 0 based index to start indenting at. Defaults to 0. * * @param string $text The text to format. - * @param array|int $options Array of options to use, or an integer to wrap the text to. + * @param array|int $options Array of options to use, or an integer to wrap the text to. * @return string Formatted text. + * @link https://book.cakephp.org/5/en/core-libraries/text.html#text-wrapblock */ - public static function wrapBlock($text, $options = []) + public static function wrapBlock(string $text, array|int $options = []): string { - if (is_numeric($options)) { + if (is_int($options)) { $options = ['width' => $options]; } $options += ['width' => 72, 'wordWrap' => true, 'indent' => null, 'indentAt' => 0]; - if (!empty($options['indentAt']) && $options['indentAt'] === 0) { - $indentLength = !empty($options['indent']) ? strlen($options['indent']) : 0; - $options['width'] -= $indentLength; - - return self::wrap($text, $options); - } - $wrapped = self::wrap($text, $options); - if (!empty($options['indent'])) { + if ($options['indent']) { $indentationLength = mb_strlen($options['indent']); $chunks = explode("\n", $wrapped); $count = count($chunks); @@ -396,11 +424,11 @@ public static function wrapBlock($text, $options = []) * * @param string $text The text to format. * @param int $width The width to wrap to. Defaults to 72. - * @param string $break The line is broken using the optional break parameter. Defaults to '\n'. + * @param non-empty-string $break The line is broken using the optional break parameter. Defaults to '\n'. * @param bool $cut If the cut is set to true, the string is always wrapped at the specified width. * @return string Formatted text. */ - public static function wordWrap($text, $width = 72, $break = "\n", $cut = false) + public static function wordWrap(string $text, int $width = 72, string $break = "\n", bool $cut = false): string { $paragraphs = explode($break, $text); foreach ($paragraphs as &$paragraph) { @@ -419,10 +447,10 @@ public static function wordWrap($text, $width = 72, $break = "\n", $cut = false) * @param bool $cut If the cut is set to true, the string is always wrapped at the specified width. * @return string Formatted text. */ - protected static function _wordWrap($text, $width = 72, $break = "\n", $cut = false) + protected static function _wordWrap(string $text, int $width = 72, string $break = "\n", bool $cut = false): string { + $parts = []; if ($cut) { - $parts = []; while (mb_strlen($text) > 0) { $part = mb_substr($text, 0, $width); $parts[] = trim($part); @@ -432,7 +460,6 @@ protected static function _wordWrap($text, $width = 72, $break = "\n", $cut = fa return implode($break, $parts); } - $parts = []; while (mb_strlen($text) > 0) { if ($width >= mb_strlen($text)) { $parts[] = trim($text); @@ -473,26 +500,23 @@ protected static function _wordWrap($text, $width = 72, $break = "\n", $cut = fa * - `limit` A limit, optional, defaults to -1 (none) * * @param string $text Text to search the phrase in. - * @param string|array $phrase The phrase or phrases that will be searched. - * @param array $options An array of HTML attributes and options. + * @param array|string $phrase The phrase or phrases that will be searched. + * @param array $options An array of HTML attributes and options. * @return string The highlighted text - * @link https://book.cakephp.org/3.0/en/core-libraries/text.html#highlighting-substrings + * @link https://book.cakephp.org/5/en/core-libraries/text.html#highlighting-substrings */ - public static function highlight($text, $phrase, array $options = []) + public static function highlight(string $text, array|string $phrase, array $options = []): string { - if (empty($phrase)) { + if (!$phrase) { return $text; } - $defaults = [ + $options += [ 'format' => '\1', 'html' => false, 'regex' => '|%s|iu', 'limit' => -1, ]; - $options += $defaults; - $html = $format = $ellipsis = $exact = $limit = null; - extract($options); if (is_array($phrase)) { $replace = []; @@ -500,42 +524,28 @@ public static function highlight($text, $phrase, array $options = []) foreach ($phrase as $key => $segment) { $segment = '(' . preg_quote($segment, '|') . ')'; - if ($html) { - $segment = "(?![^<]+>)$segment(?![^<]+>)"; + if ($options['html']) { + $segment = "(?![^<]+>){$segment}(?![^<]+>)"; } - $with[] = is_array($format) ? $format[$key] : $format; + $with[] = is_array($options['format']) ? $options['format'][$key] : $options['format']; $replace[] = sprintf($options['regex'], $segment); } - return preg_replace($replace, $with, $text, $limit); + return (string)preg_replace($replace, $with, $text, $options['limit']); } $phrase = '(' . preg_quote($phrase, '|') . ')'; - if ($html) { - $phrase = "(?![^<]+>)$phrase(?![^<]+>)"; + if ($options['html']) { + $phrase = "(?![^<]+>){$phrase}(?![^<]+>)"; } - return preg_replace(sprintf($options['regex'], $phrase), $format, $text, $limit); - } - - /** - * Strips given text of all links (]*)?(>|$)#i', '', $text, -1, $count); - } while ($count); - - return $text; + return (string)preg_replace( + sprintf($options['regex'], $phrase), + $options['format'], + $text, + $options['limit'], + ); } /** @@ -546,29 +556,26 @@ public static function stripLinks($text) * * ### Options: * - * - `ellipsis` Will be used as Beginning and prepended to the trimmed string + * - `ellipsis` Will be used as beginning and prepended to the trimmed string * - `exact` If false, $text will not be cut mid-word * * @param string $text String to truncate. * @param int $length Length of returned string, including ellipsis. - * @param array $options An array of options. + * @param array $options An array of options. * @return string Trimmed string. + * @link https://book.cakephp.org/5/en/core-libraries/text.html#text-tail */ - public static function tail($text, $length = 100, array $options = []) + public static function tail(string $text, int $length = 100, array $options = []): string { - $default = [ - 'ellipsis' => '...', 'exact' => true - ]; - $options += $default; - $exact = $ellipsis = null; - extract($options); + $options += ['ellipsis' => '…', 'exact' => true]; + $ellipsis = $options['ellipsis']; if (mb_strlen($text) <= $length) { return $text; } $truncate = mb_substr($text, mb_strlen($text) - $length + mb_strlen($ellipsis)); - if (!$exact) { + if (!$options['exact']) { $spacepos = mb_strpos($truncate, ' '); $truncate = $spacepos === false ? '' : trim(mb_substr($truncate, $spacepos)); } @@ -591,14 +598,14 @@ public static function tail($text, $length = 100, array $options = []) * * @param string $text String to truncate. * @param int $length Length of returned string, including ellipsis. - * @param array $options An array of HTML attributes and options. + * @param array $options An array of HTML attributes and options. * @return string Trimmed string. - * @link https://book.cakephp.org/3.0/en/core-libraries/text.html#truncating-text + * @link https://book.cakephp.org/5/en/core-libraries/text.html#truncating-text */ - public static function truncate($text, $length = 100, array $options = []) + public static function truncate(string $text, int $length = 100, array $options = []): string { $default = [ - 'ellipsis' => '...', 'exact' => true, 'html' => false, 'trimWidth' => false, + 'ellipsis' => '…', 'exact' => true, 'html' => false, 'trimWidth' => false, ]; if (!empty($options['html']) && strtolower(mb_internal_encoding()) === 'utf-8') { $default['ellipsis'] = "\xe2\x80\xa6"; @@ -624,11 +631,16 @@ public static function truncate($text, $length = 100, array $options = []) } if ($truncate === '') { - if (!preg_match('/img|br|input|hr|area|base|basefont|col|frame|isindex|link|meta|param/i', $tag[2])) { + if ( + !preg_match( + '/img|br|input|hr|area|base|basefont|col|frame|isindex|link|meta|param/i', + $tag[2], + ) + ) { if (preg_match('/<[\w]+[^>]*>/', $tag[0])) { array_unshift($openTags, $tag[2]); } elseif (preg_match('/<\/([\w]+)[^>]*>/', $tag[0], $closeTag)) { - $pos = array_search($closeTag[1], $openTags); + $pos = array_search($closeTag[1], $openTags, true); if ($pos !== false) { array_splice($openTags, $pos, 1); } @@ -676,7 +688,7 @@ public static function truncate($text, $length = 100, array $options = []) } // If result is empty, then we don't need to count ellipsis in the cut. - if (!strlen($result)) { + if ($result === '') { $result = self::_substr($text, 0, $length, $options); } } @@ -689,11 +701,11 @@ public static function truncate($text, $length = 100, array $options = []) * * @param string $text String to truncate. * @param int $length Length of returned string, including ellipsis. - * @param array $options An array of HTML attributes and options. + * @param array $options An array of HTML attributes and options. * @return string Trimmed string. * @see \Cake\Utility\Text::truncate() */ - public static function truncateByWidth($text, $length = 100, array $options = []) + public static function truncateByWidth(string $text, int $length = 100, array $options = []): string { return static::truncate($text, $length, ['trimWidth' => true] + $options); } @@ -707,10 +719,10 @@ public static function truncateByWidth($text, $length = 100, array $options = [] * - `trimWidth` If true, the width will return. * * @param string $text The string being checked for length - * @param array $options An array of options. + * @param array $options An array of options. * @return int */ - protected static function _strlen($text, array $options) + protected static function _strlen(string $text, array $options): int { if (empty($options['trimWidth'])) { $strlen = 'mb_strlen'; @@ -723,14 +735,14 @@ protected static function _strlen($text, array $options) } $pattern = '/&[0-9a-z]{2,8};|&#[0-9]{1,7};|&#x[0-9a-f]{1,6};/i'; - $replace = preg_replace_callback( + $replace = (string)preg_replace_callback( $pattern, function ($match) use ($strlen) { $utf8 = html_entity_decode($match[0], ENT_HTML5 | ENT_QUOTES, 'UTF-8'); return str_repeat(' ', $strlen($utf8, 'UTF-8')); }, - $text + $text, ); return $strlen($replace); @@ -746,11 +758,11 @@ function ($match) use ($strlen) { * * @param string $text The input string. * @param int $start The position to begin extracting. - * @param int $length The desired length. - * @param array $options An array of options. + * @param int|null $length The desired length. + * @param array $options An array of options. * @return string */ - protected static function _substr($text, $start, $length, array $options) + protected static function _substr(string $text, int $start, ?int $length, array $options): string { if (empty($options['trimWidth'])) { $substr = 'mb_substr'; @@ -769,9 +781,7 @@ protected static function _substr($text, $start, $length, array $options) return ''; } - if ($length === null) { - $length = self::_strlen($text, $options); - } + $length ??= self::_strlen($text, $options); if ($length < 0) { $text = self::_substr($text, $start, null, $options); @@ -792,7 +802,7 @@ protected static function _substr($text, $start, $length, array $options) $result = ''; $pattern = '/(&[0-9a-z]{2,8};|&#[0-9]{1,7};|&#x[0-9a-f]{1,6};)/i'; - $parts = preg_split($pattern, $text, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); + $parts = preg_split($pattern, $text, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY) ?: []; foreach ($parts as $part) { $offset = 0; @@ -809,7 +819,9 @@ protected static function _substr($text, $start, $length, array $options) $len = self::_strlen($part, $options); if ($offset !== 0 || $totalLength + $len > $length) { - if (strpos($part, '&') === 0 && preg_match($pattern, $part) + if ( + str_starts_with($part, '&') + && preg_match($pattern, $part) && $part !== html_entity_decode($part, ENT_HTML5 | ENT_QUOTES, 'UTF-8') ) { // Entities cannot be passed substr. @@ -836,17 +848,17 @@ protected static function _substr($text, $start, $length, array $options) * @param string $text The input text * @return string */ - protected static function _removeLastWord($text) + protected static function _removeLastWord(string $text): string { $spacepos = mb_strrpos($text, ' '); if ($spacepos !== false) { - $lastWord = mb_strrpos($text, $spacepos); + $lastWord = mb_substr($text, $spacepos); // Some languages are written without word separation. // We recognize a string as a word if it doesn't contain any full-width characters. if (mb_strwidth($lastWord) === mb_strlen($lastWord)) { - $text = mb_substr($text, 0, $spacepos); + return mb_substr($text, 0, $spacepos); } return $text; @@ -861,23 +873,23 @@ protected static function _removeLastWord($text) * * @param string $text String to search the phrase in * @param string $phrase Phrase that will be searched for - * @param int $radius The amount of characters that will be returned on each side of the founded phrase + * @param int $radius The amount of characters that will be returned on each side of the found phrase * @param string $ellipsis Ending that will be appended * @return string Modified string - * @link https://book.cakephp.org/3.0/en/core-libraries/text.html#extracting-an-excerpt + * @link https://book.cakephp.org/5/en/core-libraries/text.html#extracting-an-excerpt */ - public static function excerpt($text, $phrase, $radius = 100, $ellipsis = '...') + public static function excerpt(string $text, string $phrase, int $radius = 100, string $ellipsis = '…'): string { - if (empty($text) || empty($phrase)) { + if ($text === '' || $phrase === '') { return static::truncate($text, $radius * 2, ['ellipsis' => $ellipsis]); } - - $append = $prepend = $ellipsis; + $append = $ellipsis; + $prepend = $ellipsis; $phraseLen = mb_strlen($phrase); $textLen = mb_strlen($text); - $pos = mb_strpos(mb_strtolower($text), mb_strtolower($phrase)); + $pos = mb_stripos($text, $phrase); if ($pos === false) { return mb_substr($text, 0, $radius) . $ellipsis; } @@ -895,30 +907,29 @@ public static function excerpt($text, $phrase, $radius = 100, $ellipsis = '...') } $excerpt = mb_substr($text, $startPos, $endPos - $startPos); - $excerpt = $prepend . $excerpt . $append; - return $excerpt; + return $prepend . $excerpt . $append; } /** * Creates a comma separated list where the last two items are joined with 'and', forming natural language. * - * @param array $list The list to be joined. + * @param array $list The list to be joined. * @param string|null $and The word used to join the last and second last items together with. Defaults to 'and'. * @param string $separator The separator used to join all the other items together. Defaults to ', '. * @return string The glued together string. - * @link https://book.cakephp.org/3.0/en/core-libraries/text.html#converting-an-array-to-sentence-form + * @link https://book.cakephp.org/5/en/core-libraries/text.html#converting-an-array-to-sentence-form */ - public static function toList(array $list, $and = null, $separator = ', ') + public static function toList(array $list, ?string $and = null, string $separator = ', '): string { - if ($and === null) { - $and = __d('cake', 'and'); - } + static::$useI18n ??= function_exists('Cake\I18n\__d'); + $and ??= static::$useI18n ? __d('cake', 'and') : 'and'; + if (count($list) > 1) { - return implode($separator, array_slice($list, null, -1)) . ' ' . $and . ' ' . array_pop($list); + return implode($separator, array_slice($list, 0, -1)) . ' ' . $and . ' ' . array_pop($list); } - return array_pop($list); + return (string)array_pop($list); } /** @@ -927,7 +938,7 @@ public static function toList(array $list, $and = null, $separator = ', ') * @param string $string value to test * @return bool */ - public static function isMultibyte($string) + public static function isMultibyte(string $string): bool { $length = strlen($string); @@ -946,9 +957,9 @@ public static function isMultibyte($string) * to the decimal value of the character * * @param string $string String to convert. - * @return array + * @return array */ - public static function utf8($string) + public static function utf8(string $string): array { $map = []; @@ -962,13 +973,13 @@ public static function utf8($string) if ($value < 128) { $map[] = $value; } else { - if (empty($values)) { - $find = ($value < 224) ? 2 : 3; + if (!$values) { + $find = $value < 224 ? 2 : 3; } $values[] = $value; if (count($values) === $find) { - if ($find == 3) { + if ($find === 3) { $map[] = (($values[0] % 16) * 4096) + (($values[1] % 64) * 64) + ($values[2] % 64); } else { $map[] = (($values[0] % 32) * 64) + ($values[1] % 64); @@ -986,10 +997,10 @@ public static function utf8($string) * Converts the decimal value of a multibyte character string * to a string * - * @param array $array Array + * @param array $array Array * @return string */ - public static function ascii(array $array) + public static function ascii(array $array): string { $ascii = ''; @@ -997,11 +1008,11 @@ public static function ascii(array $array) if ($utf8 < 128) { $ascii .= chr($utf8); } elseif ($utf8 < 2048) { - $ascii .= chr(192 + (($utf8 - ($utf8 % 64)) / 64)); + $ascii .= chr(192 + (int)(($utf8 - ($utf8 % 64)) / 64)); $ascii .= chr(128 + ($utf8 % 64)); } else { - $ascii .= chr(224 + (($utf8 - ($utf8 % 4096)) / 4096)); - $ascii .= chr(128 + ((($utf8 % 4096) - ($utf8 % 64)) / 64)); + $ascii .= chr(224 + (int)(($utf8 - ($utf8 % 4096)) / 4096)); + $ascii .= chr(128 + (int)((($utf8 % 4096) - ($utf8 % 64)) / 64)); $ascii .= chr(128 + ($utf8 % 64)); } } @@ -1010,15 +1021,17 @@ public static function ascii(array $array) } /** - * Converts filesize from human readable string to bytes + * Converts filesize from human-readable string to bytes * - * @param string $size Size in human readable string like '5MB', '5M', '500B', '50kb' etc. - * @param mixed $default Value to be returned when invalid size was used, for example 'Unknown type' - * @return mixed Number of bytes as integer on success, `$default` on failure if not false - * @throws \InvalidArgumentException On invalid Unit type. - * @link https://book.cakephp.org/3.0/en/core-libraries/text.html#Cake\Utility\Text::parseFileSize + * @param string $size Size in human-readable string like '5MB', '5M', '500B', '50kb' etc. + * @param mixed $default Value to be returned when invalid size was used. + * If set to false (default), an exception will be thrown instead. + * @return mixed Number of bytes as integer on success, or $default value on failure + * (if $default is not false). + * @throws \InvalidArgumentException On invalid unit type when $default is false. + * @link https://book.cakephp.org/5/en/core-libraries/text.html#text-parsefilesize */ - public static function parseFileSize($size, $default = false) + public static function parseFileSize(string $size, mixed $default = false): mixed { if (ctype_digit($size)) { return (int)$size; @@ -1026,18 +1039,18 @@ public static function parseFileSize($size, $default = false) $size = strtoupper($size); $l = -2; - $i = array_search(substr($size, -2), ['KB', 'MB', 'GB', 'TB', 'PB']); + $i = array_search(substr($size, -2), ['KB', 'MB', 'GB', 'TB', 'PB'], true); if ($i === false) { $l = -1; - $i = array_search(substr($size, -1), ['K', 'M', 'G', 'T', 'P']); + $i = array_search(substr($size, -1), ['K', 'M', 'G', 'T', 'P'], true); } if ($i !== false) { - $size = substr($size, 0, $l); + $size = (float)substr($size, 0, $l); - return $size * pow(1024, $i + 1); + return (int)($size * pow(1024, $i + 1)); } - if (substr($size, -1) === 'B' && ctype_digit(substr($size, 0, -1))) { + if (str_ends_with($size, 'B') && ctype_digit(substr($size, 0, -1))) { $size = substr($size, 0, -1); return (int)$size; @@ -1049,12 +1062,34 @@ public static function parseFileSize($size, $default = false) throw new InvalidArgumentException('No unit type.'); } + /** + * Get the default transliterator. + * + * @return \Transliterator|null Either a Transliterator instance, or `null` + * in case no transliterator has been set yet. + */ + public static function getTransliterator(): ?Transliterator + { + return static::$_defaultTransliterator; + } + + /** + * Set the default transliterator. + * + * @param \Transliterator $transliterator A `Transliterator` instance. + * @return void + */ + public static function setTransliterator(Transliterator $transliterator): void + { + static::$_defaultTransliterator = $transliterator; + } + /** * Get default transliterator identifier string. * * @return string Transliterator identifier. */ - public static function getTransliteratorId() + public static function getTransliteratorId(): string { return static::$_defaultTransliteratorId; } @@ -1065,8 +1100,14 @@ public static function getTransliteratorId() * @param string $transliteratorId Transliterator identifier. * @return void */ - public static function setTransliteratorId($transliteratorId) + public static function setTransliteratorId(string $transliteratorId): void { + $transliterator = transliterator_create($transliteratorId); + if ($transliterator === null) { + throw new CakeException(sprintf('Unable to create transliterator for id: %s.', $transliteratorId)); + } + + static::setTransliterator($transliterator); static::$_defaultTransliteratorId = $transliteratorId; } @@ -1074,16 +1115,26 @@ public static function setTransliteratorId($transliteratorId) * Transliterate string. * * @param string $string String to transliterate. - * @param string|null $transliteratorId Transliterator identifier. If null - * Text::$_defaultTransliteratorId will be used. + * @param \Transliterator|string|null $transliterator Either a Transliterator + * instance, or a transliterator identifier string. If `null`, the default + * transliterator (identifier) set via `setTransliteratorId()` or + * `setTransliterator()` will be used. * @return string * @see https://secure.php.net/manual/en/transliterator.transliterate.php + * @link https://book.cakephp.org/5/en/core-libraries/text.html#text-transliterate */ - public static function transliterate($string, $transliteratorId = null) + public static function transliterate(string $string, Transliterator|string|null $transliterator = null): string { - $transliteratorId = $transliteratorId ?: static::$_defaultTransliteratorId; + if (!$transliterator) { + $transliterator = static::$_defaultTransliterator ?: static::$_defaultTransliteratorId; + } + + $return = transliterator_transliterate($transliterator, $string); + if ($return === false) { + throw new CakeException(sprintf('Unable to transliterate string: %s', $string)); + } - return transliterator_transliterate($transliteratorId, $string); + return $return; } /** @@ -1093,18 +1144,22 @@ public static function transliterate($string, $transliteratorId = null) * ### Options: * * - `replacement`: Replacement string. Default '-'. - * - `transliteratorId`: A valid tranliterator id string. - * If default `null` Text::$_defaultTransliteratorId to be used. + * - `transliteratorId`: A valid transliterator id string. + * If `null` (default) the transliterator (identifier) set via + * `setTransliteratorId()` or `setTransliterator()` will be used. * If `false` no transliteration will be done, only non words will be removed. * - `preserve`: Specific non-word character to preserve. Default `null`. * For e.g. this option can be set to '.' to generate clean file names. * * @param string $string the string you want to slug - * @param array $options If string it will be use as replacement character + * @param array|string $options If string it will be use as replacement character * or an array of options. * @return string + * @see Text::setTransliterator() + * @see Text::setTransliteratorId() + * @link https://book.cakephp.org/5/en/core-libraries/text.html#text-slug */ - public static function slug($string, $options = []) + public static function slug(string $string, array|string $options = []): string { if (is_string($options)) { $options = ['replacement' => $options]; @@ -1112,24 +1167,211 @@ public static function slug($string, $options = []) $options += [ 'replacement' => '-', 'transliteratorId' => null, - 'preserve' => null + 'preserve' => null, ]; if ($options['transliteratorId'] !== false) { $string = static::transliterate($string, $options['transliteratorId']); } - $regex = '^\s\p{Ll}\p{Lm}\p{Lo}\p{Lt}\p{Lu}\p{Nd}'; + $regex = '^\p{Ll}\p{Lm}\p{Lo}\p{Lt}\p{Lu}\p{Nd}'; if ($options['preserve']) { $regex .= preg_quote($options['preserve'], '/'); } - $quotedReplacement = preg_quote($options['replacement'], '/'); + $quotedReplacement = preg_quote((string)$options['replacement'], '/'); $map = [ - '/[' . $regex . ']/mu' => ' ', - '/[\s]+/mu' => $options['replacement'], + '/[' . $regex . ']/mu' => $options['replacement'], sprintf('/^[%s]+|[%s]+$/', $quotedReplacement, $quotedReplacement) => '', ]; - $string = preg_replace(array_keys($map), $map, $string); + if (is_string($options['replacement']) && $options['replacement'] !== '') { + $map[sprintf('/[%s]+/mu', $quotedReplacement)] = $options['replacement']; + } + + return (string)preg_replace(array_keys($map), $map, $string); + } + + /** + * Masks a portion of a string with a repeated character. + * Replaces characters from $offset to $offset + $length with $maskCharacter. + * + * If $length is null, it will mask until the end of the string. + * + * Negative $offset value will count from the end of the string. If the computed $offset is still less than 0, it is clamped to 0. + * An $offset at or beyond the string length returns the original string unchanged. + * + * @param string $string The input string. + * @param int $offset Start position of the mask. Negative values count from the end. + * @param int|null $length Number of characters to mask. Null masks from $offset to end of string. + * @param string $maskCharacter The single Unicode code point character to use as the mask. Defaults to '*'. + * @throws \InvalidArgumentException If $maskCharacter is not exactly a single character. + * @return string + */ + public static function mask(string $string, int $offset = 0, ?int $length = null, string $maskCharacter = '*'): string + { + if (mb_strlen($maskCharacter) !== 1) { + throw new InvalidArgumentException('Mask character must be a single character.'); + } + + if ($string === '') { + return $string; + } + + $stringLength = mb_strlen($string); + + if ($offset < 0) { + $offset = max(0, $stringLength + $offset); + } + + if ($offset >= $stringLength) { + return $string; + } + + if ($length !== null && $length <= 0) { + return $string; + } + + $length = $length === null + ? $stringLength - $offset + : min($length, $stringLength - $offset); + + $start = mb_substr($string, 0, $offset); + $mask = str_repeat($maskCharacter, $length); + $end = mb_substr($string, $offset + $length); + + return $start . $mask . $end; + } + + /** + * Masks all occurrences of given substring(s) within a string using a repeated character. + * + * Each occurrence of the provided substring(s) will be replaced by a sequence + * of the masking character. + * + * @param string $string The input string. + * @param string[] $needles List of substrings to search for match (case-sensitive) and mask. + * @param string $maskCharacter Single masking character. + * @throws \InvalidArgumentException If $maskCharacter is not exactly a single character. + * @return string + */ + public static function maskValue(string $string, array $needles, string $maskCharacter = '*'): string + { + if ($string === '' || $needles === []) { + return $string; + } + + $needles = array_unique(array_filter($needles, fn($n) => $n !== '')); + + if ($needles === []) { + return $string; + } + + if (mb_strlen($maskCharacter) !== 1) { + throw new InvalidArgumentException('Mask character must be a single character.'); + } + + $escapedForRegex = array_map(function (string $needle) { + return preg_quote($needle, '/'); + }, $needles); + + $regexPattern = '/' . implode('|', $escapedForRegex) . '/u'; + + return (string)preg_replace_callback($regexPattern, function ($matches) use ($maskCharacter) { + return str_repeat($maskCharacter, mb_strlen($matches[0])); + }, $string); + } + + /** + * Masks all occurrences of given regex pattern(s) within a string using a repeated character. + * + * Each occurrence of the provided pattern(s) will be replaced by a sequence of the masking character. + * + * @param string $string The input string. + * @param string[]|string $patterns One or more regex patterns. + * @param string $maskCharacter Single masking character. + * @throws \InvalidArgumentException If $maskCharacter is not exactly a single character. + * @return string + */ + public static function maskRegex(string $string, array|string $patterns, string $maskCharacter = '*'): string + { + if (!is_array($patterns)) { + $patterns = [$patterns]; + } + + $patterns = array_unique(array_filter($patterns, fn(string $n) => $n !== '')); + + if ($string === '' || $patterns === []) { + return $string; + } + + if (mb_strlen($maskCharacter) !== 1) { + throw new InvalidArgumentException('Mask character must be a single character.'); + } + + foreach ($patterns as $regex) { + $string = (string)preg_replace_callback($regex, function ($matches) use ($maskCharacter) { + return str_repeat($maskCharacter, mb_strlen($matches[0])); + }, $string); + } + + return $string; + } + + /** + * Masks occurrences of given regex pattern(s) within a string, optionally preserving leading/trailing characters of + * each match. + * + * Each occurrence of the provided pattern(s) will have its interior replaced by a sequence of the masking + * character, while the first $showLeading and last $showTrailing characters of the match are left unchanged. If + * $showLeading + $showTrailing >= match length, the match is returned as-is. + * + * Examples: + * - maskPartialRegex('Card used: 4242424242424242', '/\d{16}/', 0, 4) => Card used: ************4242 + * - maskPartialRegex('Secret Codeword', '/\b\w+\b/', 1, 1) => S****t C*******d + * + * @param string $string The input string. + * @param string[]|string $patterns One or more regex patterns. + * @param int $showLeading Number of leading characters of each match to leave unmasked. + * @param int $showTrailing Number of trailing characters of each match to leave unmasked. + * @param string $maskCharacter Single masking character. + * @return string + * @throws \InvalidArgumentException If $maskCharacter is not exactly a single character, or if $showLeading/$showTrailing are negative. + */ + public static function maskPartialRegex(string $string, array|string $patterns, int $showLeading = 0, int $showTrailing = 0, string $maskCharacter = '*'): string + { + if (!is_array($patterns)) { + $patterns = [$patterns]; + } + + $patterns = array_unique(array_filter($patterns, fn(string $n) => $n !== '')); + + if ($string === '' || $patterns === []) { + return $string; + } + + if (mb_strlen($maskCharacter) !== 1) { + throw new InvalidArgumentException('Mask character must be a single character.'); + } + + if ($showLeading < 0 || $showTrailing < 0) { + throw new InvalidArgumentException('Leading and trailing character counts must be non-negative.'); + } + + foreach ($patterns as $regex) { + $string = (string)preg_replace_callback($regex, function ($matches) use ($maskCharacter, $showLeading, $showTrailing) { + $match = $matches[0]; + $matchLen = mb_strlen($match); + $middleLen = $matchLen - $showLeading - $showTrailing; + + if ($middleLen <= 0) { + return $match; + } + + $leading = $showLeading > 0 ? mb_substr($match, 0, $showLeading) : ''; + $trailing = $showTrailing > 0 ? mb_substr($match, -$showTrailing) : ''; + + return $leading . str_repeat($maskCharacter, $middleLen) . $trailing; + }, $string); + } return $string; } diff --git a/src/Utility/Xml.php b/src/Utility/Xml.php index faf7a7aa879..442a4e01035 100644 --- a/src/Utility/Xml.php +++ b/src/Utility/Xml.php @@ -1,4 +1,6 @@ true]); * ``` * * Building XML from a remote URL: @@ -89,24 +95,23 @@ class Xml * - `return` Can be 'simplexml' to return object of SimpleXMLElement or 'domdocument' to return DOMDocument. * - `loadEntities` Defaults to false. Set to true to enable loading of ` $options The options to use * @return \SimpleXMLElement|\DOMDocument SimpleXMLElement or DOMDocument * @throws \Cake\Utility\Exception\XmlException */ - public static function build($input, array $options = []) + public static function build(object|array|string $input, array $options = []): SimpleXMLElement|DOMDocument { $defaults = [ 'return' => 'simplexml', 'loadEntities' => false, - 'readFile' => true, + 'readFile' => false, 'parseHuge' => false, ]; $options += $defaults; @@ -115,16 +120,17 @@ public static function build($input, array $options = []) return static::fromArray($input, $options); } - if (strpos($input, '<') !== false) { - return static::_loadXml($input, $options); - } - if ($options['readFile'] && file_exists($input)) { - return static::_loadXml(file_get_contents($input), $options); + $content = file_get_contents($input); + if ($content === false) { + throw new CakeException(sprintf('Cannot read file content of `%s`', $input)); + } + + return static::_loadXml($content, $options); } - if (!is_string($input)) { - throw new XmlException('Invalid input.'); + if (str_contains($input, '<')) { + return static::_loadXml($input, $options); } throw new XmlException('XML cannot be read.'); @@ -134,40 +140,89 @@ public static function build($input, array $options = []) * Parse the input data and create either a SimpleXmlElement object or a DOMDocument. * * @param string $input The input to load. - * @param array $options The options to use. See Xml::build() + * @param array $options The options to use. See Xml::build() * @return \SimpleXMLElement|\DOMDocument * @throws \Cake\Utility\Exception\XmlException */ - protected static function _loadXml($input, $options) + protected static function _loadXml(string $input, array $options): SimpleXMLElement|DOMDocument { - $hasDisable = function_exists('libxml_disable_entity_loader'); - $internalErrors = libxml_use_internal_errors(true); - if ($hasDisable && !$options['loadEntities']) { - libxml_disable_entity_loader(true); - } - $flags = LIBXML_NOCDATA; + return static::load( + $input, + $options, + function ($input, $options, $flags) { + if ($options['return'] === 'simplexml' || $options['return'] === 'simplexmlelement') { + $flags |= LIBXML_NOCDATA; + $xml = new SimpleXMLElement($input, $flags); + } else { + $xml = new DOMDocument(); + $xml->loadXML($input, $flags); + } + + return $xml; + }, + ); + } + + /** + * Parse the input html string and create either a SimpleXmlElement object or a DOMDocument. + * + * @param string $input The input html string to load. + * @param array $options The options to use. See Xml::build() + * @return \SimpleXMLElement|\DOMDocument + * @throws \Cake\Utility\Exception\XmlException + */ + public static function loadHtml(string $input, array $options = []): SimpleXMLElement|DOMDocument + { + $defaults = [ + 'return' => 'simplexml', + 'loadEntities' => false, + ]; + $options += $defaults; + + return static::load( + $input, + $options, + function ($input, $options, $flags) { + $xml = new DOMDocument(); + $xml->loadHTML($input, $flags); + + if ($options['return'] === 'simplexml' || $options['return'] === 'simplexmlelement') { + return simplexml_import_dom($xml); + } + + return $xml; + }, + ); + } + + /** + * Parse the input data and create either a SimpleXmlElement object or a DOMDocument. + * + * @param string $input The input to load. + * @param array $options The options to use. See Xml::build() + * @param \Closure $callable Closure that should return SimpleXMLElement or DOMDocument instance. + * @return \SimpleXMLElement|\DOMDocument + * @throws \Cake\Utility\Exception\XmlException + */ + protected static function load(string $input, array $options, Closure $callable): SimpleXMLElement|DOMDocument + { + $flags = 0; if (!empty($options['parseHuge'])) { $flags |= LIBXML_PARSEHUGE; } + + $internalErrors = libxml_use_internal_errors(true); + if ($options['loadEntities']) { + $flags |= LIBXML_NOENT; + } + try { - if ($options['return'] === 'simplexml' || $options['return'] === 'simplexmlelement') { - $xml = new SimpleXMLElement($input, $flags); - } else { - $xml = new DOMDocument(); - $xml->loadXML($input); - } + return $callable($input, $options, $flags); } catch (Exception $e) { - $xml = null; - } - if ($hasDisable && !$options['loadEntities']) { - libxml_disable_entity_loader(false); + throw new XmlException('Xml cannot be read. ' . $e->getMessage(), null, $e); + } finally { + libxml_use_internal_errors($internalErrors); } - libxml_use_internal_errors($internalErrors); - if ($xml === null) { - throw new XmlException('Xml cannot be read.'); - } - - return $xml; } /** @@ -175,11 +230,13 @@ protected static function _loadXml($input, $options) * * ### Options * - * - `format` If create childs ('tags') or attributes ('attributes'). + * - `format` If create children ('tags') or attributes ('attributes'). * - `pretty` Returns formatted Xml when set to `true`. Defaults to `false` * - `version` Version of XML document. Default is 1.0. - * - `encoding` Encoding of XML document. If null remove from XML header. Default is the some of application. - * - `return` If return object of SimpleXMLElement ('simplexml') or DOMDocument ('domdocument'). Default is SimpleXMLElement. + * - `encoding` Encoding of XML document. If null remove from XML header. + * Defaults to the application's encoding + * - `return` If return object of SimpleXMLElement ('simplexml') + * or DOMDocument ('domdocument'). Default is SimpleXMLElement. * * Using the following data: * @@ -203,33 +260,33 @@ protected static function _loadXml($input, $options) * * `description` * - * @param array|\Cake\Collection\Collection $input Array with data or a collection instance. - * @param string|array $options The options to use or a string to use as format. + * @param object|array $input Array with data or a collection instance. + * @param array $options The options to use. * @return \SimpleXMLElement|\DOMDocument SimpleXMLElement or DOMDocument * @throws \Cake\Utility\Exception\XmlException */ - public static function fromArray($input, $options = []) + public static function fromArray(object|array $input, array $options = []): SimpleXMLElement|DOMDocument { if (is_object($input) && method_exists($input, 'toArray') && is_callable([$input, 'toArray'])) { - $input = call_user_func([$input, 'toArray']); + $input = $input->toArray(); } if (!is_array($input) || count($input) !== 1) { - throw new XmlException('Invalid input.'); + throw new XmlException( + 'Invalid input of type `' . gettype($input) . '`' + . (is_array($input) ? ' (Count of ' . count($input) . ')' : '') . '.', + ); } $key = key($input); if (is_int($key)) { throw new XmlException('The key of input must be alphanumeric'); } - if (!is_array($options)) { - $options = ['format' => (string)$options]; - } $defaults = [ 'format' => 'tags', 'version' => '1.0', 'encoding' => mb_internal_encoding(), 'return' => 'simplexml', - 'pretty' => false + 'pretty' => false, ]; $options += $defaults; @@ -241,31 +298,56 @@ public static function fromArray($input, $options = []) $options['return'] = strtolower($options['return']); if ($options['return'] === 'simplexml' || $options['return'] === 'simplexmlelement') { - return new SimpleXMLElement($dom->saveXML()); + $xmlString = (string)$dom->saveXML(); + $check = new DOMDocument(); + libxml_use_internal_errors(true); + + if (!$check->loadXML($xmlString, LIBXML_NOWARNING | LIBXML_NOERROR)) { + $errors = libxml_get_errors(); + $messages = []; + + foreach ($errors as $error) { + $messages[] = trim(sprintf( + 'File: %s, Line %d, Column %d: %s', + $error->file ?: '[string input]', + $error->line, + $error->column, + $error->message, + )); + } + libxml_clear_errors(); + throw new XmlException("Invalid XML string:\n" . implode("\n", $messages)); + } + + return new SimpleXMLElement($xmlString); } return $dom; } /** - * Recursive method to create childs from array + * Recursive method to create children from array * * @param \DOMDocument $dom Handler to DOMDocument - * @param \DOMElement $node Handler to DOMElement (child) - * @param array $data Array of data to append to the $node. + * @param \DOMDocument|\DOMElement $node Handler to DOMElement (child) + * @param mixed $data Array of data to append to the $node. * @param string $format Either 'attributes' or 'tags'. This determines where nested keys go. * @return void * @throws \Cake\Utility\Exception\XmlException */ - protected static function _fromArray($dom, $node, &$data, $format) - { - if (empty($data) || !is_array($data)) { + protected static function _fromArray( + DOMDocument $dom, + DOMDocument|DOMElement $node, + mixed $data, + string $format, + ): void { + if (!$data || !is_array($data)) { return; } foreach ($data as $key => $value) { if (is_string($key)) { if (is_object($value) && method_exists($value, 'toArray') && is_callable([$value, 'toArray'])) { - $value = call_user_func([$value, 'toArray']); + $value = $value->toArray(); } if (!is_array($value)) { @@ -274,32 +356,39 @@ protected static function _fromArray($dom, $node, &$data, $format) } elseif ($value === null) { $value = ''; } - $isNamespace = strpos($key, 'xmlns:'); - if ($isNamespace !== false) { - $node->setAttributeNS('http://www.w3.org/2000/xmlns/', $key, $value); + if (str_contains($key, 'xmlns:')) { + assert($node instanceof DOMElement); + $node->setAttributeNS('http://www.w3.org/2000/xmlns/', $key, (string)$value); continue; } - if ($key[0] !== '@' && $format === 'tags') { + if (!str_starts_with($key, '@') && $format === 'tags') { if (!is_numeric($value)) { // Escape special characters // https://www.w3.org/TR/REC-xml/#syntax // https://bugs.php.net/bug.php?id=36795 $child = $dom->createElement($key, ''); + if ($value instanceof BackedEnum) { + $value = (string)$value->value; + } elseif ($value instanceof UnitEnum) { + $value = $value->name; + } else { + $value = (string)$value; + } $child->appendChild(new DOMText($value)); } else { - $child = $dom->createElement($key, $value); + $child = $dom->createElement($key, (string)$value); } $node->appendChild($child); } else { - if ($key[0] === '@') { + if (str_starts_with($key, '@')) { $key = substr($key, 1); } $attribute = $dom->createAttribute($key); - $attribute->appendChild($dom->createTextNode($value)); + $attribute->appendChild($dom->createTextNode((string)$value)); $node->appendChild($attribute); } } else { - if ($key[0] === '@') { + if (str_starts_with($key, '@')) { throw new XmlException('Invalid array'); } if (is_numeric(implode('', array_keys($value)))) { @@ -321,30 +410,26 @@ protected static function _fromArray($dom, $node, &$data, $format) } /** - * Helper to _fromArray(). It will create childs of arrays + * Helper to _fromArray(). It will create children of arrays * - * @param array $data Array with information to create childs + * @param array{dom: \DOMDocument, node: \DOMNode, key: string, format: string, value?: mixed} $data Array with information to create children * @return void */ - protected static function _createChild($data) + protected static function _createChild(array $data): void { $data += [ - 'dom' => null, - 'node' => null, - 'key' => null, 'value' => null, - 'format' => null, ]; - $value = $data['value']; - $dom = $data['dom']; $key = $data['key']; $format = $data['format']; + $value = $data['value']; + $dom = $data['dom']; $node = $data['node']; - - $childNS = $childValue = null; + $childNS = null; + $childValue = null; if (is_object($value) && method_exists($value, 'toArray') && is_callable([$value, 'toArray'])) { - $value = call_user_func([$value, 'toArray']); + $value = $value->toArray(); } if (is_array($value)) { if (isset($value['@'])) { @@ -355,7 +440,7 @@ protected static function _createChild($data) $childNS = $value['xmlns:']; unset($value['xmlns:']); } - } elseif (!empty($value) || $value === 0 || $value === '0') { + } elseif ($value || $value === 0 || $value === '0') { $childValue = (string)$value; } @@ -374,18 +459,20 @@ protected static function _createChild($data) /** * Returns this XML structure as an array. * - * @param \SimpleXMLElement|\DOMDocument|\DOMNode $obj SimpleXMLElement, DOMDocument or DOMNode instance + * @param \SimpleXMLElement|\DOMNode $obj SimpleXMLElement, DOMNode instance * @return array Array representation of the XML structure. * @throws \Cake\Utility\Exception\XmlException */ - public static function toArray($obj) + public static function toArray(SimpleXMLElement|DOMNode $obj): array { if ($obj instanceof DOMNode) { $obj = simplexml_import_dom($obj); } - if (!($obj instanceof SimpleXMLElement)) { - throw new XmlException('The input is not instance of SimpleXMLElement, DOMDocument or DOMNode.'); + + if ($obj === null) { + throw new XmlException('Failed converting DOMNode to SimpleXMLElement'); } + $result = []; $namespaces = array_merge(['' => ''], $obj->getNamespaces(true)); static::_toArray($obj, $result, '', array_keys($namespaces)); @@ -397,18 +484,19 @@ public static function toArray($obj) * Recursive method to toArray * * @param \SimpleXMLElement $xml SimpleXMLElement object - * @param array $parentData Parent array with data + * @param array $parentData Parent array with data * @param string $ns Namespace of current child - * @param array $namespaces List of namespaces in XML + * @param array $namespaces List of namespaces in XML * @return void */ - protected static function _toArray($xml, &$parentData, $ns, $namespaces) + protected static function _toArray(SimpleXMLElement $xml, array &$parentData, string $ns, array $namespaces): void { $data = []; foreach ($namespaces as $namespace) { - foreach ($xml->attributes($namespace, true) as $key => $value) { - if (!empty($namespace)) { + $attributes = $xml->attributes($namespace, true); + foreach ($attributes as $key => $value) { + if ($namespace) { $key = $namespace . ':' . $key; } $data['@' . $key] = (string)$value; @@ -420,13 +508,13 @@ protected static function _toArray($xml, &$parentData, $ns, $namespaces) } $asString = trim((string)$xml); - if (empty($data)) { + if (!$data) { $data = $asString; - } elseif (strlen($asString) > 0) { + } elseif ($asString !== '') { $data['@'] = $asString; } - if (!empty($ns)) { + if ($ns) { $ns .= ':'; } $name = $ns . $xml->getName(); diff --git a/src/Utility/bootstrap.php b/src/Utility/bootstrap.php index fbc847175c8..d335b754ddf 100644 --- a/src/Utility/bootstrap.php +++ b/src/Utility/bootstrap.php @@ -1,4 +1,6 @@ =5.6.0" - }, - "suggest": { - "ext-intl": "To use Text::transliterate() or Text::slug()", - "lib-ICU": "To use Text::transliterate() or Text::slug()" + "php": ">=8.2", + "cakephp/core": "^5.4.0" }, "autoload": { "psr-4": { @@ -38,5 +35,16 @@ "files": [ "bootstrap.php" ] + }, + "suggest": { + "ext-intl": "To use Text::transliterate() or Text::slug()", + "lib-ICU": "To use Text::transliterate() or Text::slug()" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" + } } } diff --git a/src/Validation/.gitattributes b/src/Validation/.gitattributes new file mode 100644 index 00000000000..0086560d10e --- /dev/null +++ b/src/Validation/.gitattributes @@ -0,0 +1,10 @@ +# Define the line ending behavior of the different file extensions +# Set default behavior, in case users don't have core.autocrlf set. +* text text=auto eol=lf + +.php diff=php + +# Remove files for archives generated using `git archive` +.gitattributes export-ignore +phpstan.neon.dist export-ignore +tests/ export-ignore diff --git a/src/Validation/LICENSE.txt b/src/Validation/LICENSE.txt index 0c4b7932c31..b938c9e8ed3 100644 --- a/src/Validation/LICENSE.txt +++ b/src/Validation/LICENSE.txt @@ -1,7 +1,7 @@ The MIT License (MIT) CakePHP(tm) : The Rapid Development PHP Framework (https://cakephp.org) -Copyright (c) 2005-2016, Cake Software Foundation, Inc. (https://cakefoundation.org) +Copyright (c) 2005-2020, Cake Software Foundation, Inc. (https://cakefoundation.org) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/Validation/README.md b/src/Validation/README.md index 17966516996..9d43d566e98 100644 --- a/src/Validation/README.md +++ b/src/Validation/README.md @@ -22,16 +22,16 @@ $validator 'message' => 'E-mail must be valid' ]) ->requirePresence('name') - ->notEmpty('name', 'We need your name.') + ->notEmptyString('name', 'We need your name.') ->requirePresence('comment') - ->notEmpty('comment', 'You need to give a comment.'); + ->notEmptyString('comment', 'You need to give a comment.'); -$errors = $validator->errors($_POST); -if (!empty($errors)) { +$errors = $validator->validate($_POST); +if ($errors) { // display errors. } ``` ## Documentation -Please make sure you check the [official documentation](https://book.cakephp.org/3.0/en/core-libraries/validation.html) +Please make sure you check the [official documentation](https://book.cakephp.org/5/en/core-libraries/validation.html) diff --git a/src/Validation/RulesProvider.php b/src/Validation/RulesProvider.php index 5fe6338c321..ef98036185f 100644 --- a/src/Validation/RulesProvider.php +++ b/src/Validation/RulesProvider.php @@ -1,4 +1,6 @@ */ - protected $_reflection; + protected ReflectionClass $_reflection; /** * Constructor, sets the default class to use for calling methods * - * @param string $class the default class to proxy + * @param object|class-string $class the default class to proxy + * @throws \ReflectionException */ - public function __construct($class = '\Cake\Validation\Validation') + public function __construct(object|string $class = Validation::class) { + deprecationWarning( + '5.2.0', + sprintf( + 'The class Cake\Validation\RulesProvider is deprecated. ' + . 'Directly set %s as a validation provider.', + (is_string($class) ? $class : get_class($class)), + ), + ); + $this->_class = $class; $this->_reflection = new ReflectionClass($class); } @@ -58,13 +74,15 @@ public function __construct($class = '\Cake\Validation\Validation') * * @param string $method the validation method to call * @param array $arguments the list of arguments to pass to the method - * @return bool whether or not the validation rule passed + * @return bool Whether the validation rule passed */ - public function __call($method, $arguments) + public function __call(string $method, array $arguments): bool { $method = $this->_reflection->getMethod($method); $argumentList = $method->getParameters(); - if (array_pop($argumentList)->getName() !== 'context') { + /** @var \ReflectionParameter $argument */ + $argument = array_pop($argumentList); + if ($argument->getName() !== 'context') { $arguments = array_slice($arguments, 0, -1); } $object = is_string($this->_class) ? null : $this->_class; diff --git a/src/Validation/ValidatableInterface.php b/src/Validation/ValidatableInterface.php deleted file mode 100644 index 35780823cb3..00000000000 --- a/src/Validation/ValidatableInterface.php +++ /dev/null @@ -1,31 +0,0 @@ -'; + + /** + * Greater than or equal to comparison operator. + * + * @var string + */ + public const COMPARE_GREATER_OR_EQUAL = '>='; + + /** + * Less than comparison operator. + * + * @var string + */ + public const COMPARE_LESS = '<'; + + /** + * Less than or equal to comparison operator. + * + * @var string + */ + public const COMPARE_LESS_OR_EQUAL = '<='; + + /** + * @var array + */ + protected const COMPARE_STRING = [ + self::COMPARE_EQUAL, + self::COMPARE_NOT_EQUAL, + self::COMPARE_SAME, + self::COMPARE_NOT_SAME, + ]; + + /** + * Datetime ISO8601 format + * + * @var string + */ + public const DATETIME_ISO8601 = 'iso8601'; /** * Some complex patterns needed in multiple places * - * @var array + * @var array */ - protected static $_pattern = [ + protected static array $_pattern = [ 'hostname' => '(?:[_\p{L}0-9][-_\p{L}0-9]*\.)*(?:[\p{L}0-9][-\p{L}0-9]{0,62})\.(?:(?:[a-z]{2}\.)?[a-z]{2,})', 'latitude' => '[-+]?([1-8]?\d(\.\d+)?|90(\.0+)?)', 'longitude' => '[-+]?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)', @@ -55,34 +136,19 @@ class Validation * * @var array */ - public static $errors = []; - - /** - * Backwards compatibility wrapper for Validation::notBlank(). - * - * @param string $check Value to check. - * @return bool Success. - * @deprecated 3.0.2 Use Validation::notBlank() instead. - * @see \Cake\Validation\Validation::notBlank() - */ - public static function notEmpty($check) - { - trigger_error('Validation::notEmpty() is deprecated. Use Validation::notBlank() instead.', E_USER_DEPRECATED); - - return static::notBlank($check); - } + public static array $errors = []; /** * Checks that a string contains something other than whitespace * * Returns true if string contains something other than whitespace * - * @param string $check Value to check + * @param mixed $check Value to check * @return bool Success */ - public static function notBlank($check) + public static function notBlank(mixed $check): bool { - if (empty($check) && !is_bool($check) && !is_numeric($check)) { + if (!$check && !is_bool($check) && !is_numeric($check)) { return false; } @@ -90,16 +156,17 @@ public static function notBlank($check) } /** - * Checks that a string contains only integer or letters + * Checks that a string contains only integer or letters. * - * Returns true if string contains only integer or letters + * This method's definition of letters and integers includes unicode characters. + * Use `asciiAlphaNumeric()` if you want to exclude unicode. * - * @param string $check Value to check + * @param mixed $check Value to check * @return bool Success */ - public static function alphaNumeric($check) + public static function alphaNumeric(mixed $check): bool { - if (empty($check) && $check !== '0') { + if ((empty($check) && $check !== '0') || !is_scalar($check)) { return false; } @@ -107,68 +174,95 @@ public static function alphaNumeric($check) } /** - * Checks that a string length is within specified range. - * Spaces are included in the character count. - * Returns true if string matches value min, max, or between min and max, + * Checks that a value doesn't contain any alpha numeric characters * - * @param string $check Value to check for length - * @param int $min Minimum value in range (inclusive) - * @param int $max Maximum value in range (inclusive) + * This method's definition of letters and integers includes unicode characters. + * Use `notAsciiAlphaNumeric()` if you want to exclude ascii only. + * + * @param mixed $check Value to check * @return bool Success */ - public static function lengthBetween($check, $min, $max) + public static function notAlphaNumeric(mixed $check): bool { - if (!is_string($check)) { + return !static::alphaNumeric($check); + } + + /** + * Checks that a string contains only ascii integer or letters. + * + * @param mixed $check Value to check + * @return bool Success + */ + public static function asciiAlphaNumeric(mixed $check): bool + { + if ((empty($check) && $check !== '0') || !is_scalar($check)) { return false; } - $length = mb_strlen($check); - return ($length >= $min && $length <= $max); + return self::_check($check, '/^[[:alnum:]]+$/'); } /** - * Returns true if field is left blank -OR- only whitespace characters are present in its value - * Whitespace characters include Space, Tab, Carriage Return, Newline + * Checks that a value doesn't contain any non-ascii alpha numeric characters * - * @param string $check Value to check + * @param mixed $check Value to check * @return bool Success - * @deprecated 3.0.2 */ - public static function blank($check) + public static function notAsciiAlphaNumeric(mixed $check): bool { - trigger_error('Validation::blank() is deprecated.', E_USER_DEPRECATED); + return !static::asciiAlphaNumeric($check); + } - return !static::_check($check, '/[^\\s]/'); + /** + * Checks that a string length is within specified range. + * Spaces are included in the character count. + * Returns true if string matches value min, max, or between min and max, + * + * @param mixed $check Value to check for length + * @param int $min Minimum value in range (inclusive) + * @param int $max Maximum value in range (inclusive) + * @return bool Success + */ + public static function lengthBetween(mixed $check, int $min, int $max): bool + { + if (!is_scalar($check)) { + return false; + } + $length = mb_strlen((string)$check); + + return $length >= $min && $length <= $max; } /** * Validation of credit card numbers. * Returns true if $check is in the proper credit card format. * - * @param string $check credit card number to validate - * @param string|array $type 'all' may be passed as a string, defaults to fast which checks format of most major credit cards - * if an array is used only the values of the array are checked. + * @param mixed $check credit card number to validate + * @param array|string $type 'all' may be passed as a string, defaults to fast which checks format of + * most major credit cards if an array is used only the values of the array are checked. * Example: ['amex', 'bankcard', 'maestro'] * @param bool $deep set to true this will check the Luhn algorithm of the credit card. - * @param string|null $regex A custom regex can also be passed, this will be used instead of the defined regex values + * @param string|null $regex A custom regex, this will be used instead of the defined regex values. * @return bool Success * @see \Cake\Validation\Validation::luhn() */ - public static function cc($check, $type = 'fast', $deep = false, $regex = null) - { - if (!is_scalar($check)) { + public static function creditCard( + mixed $check, + array|string $type = 'fast', + bool $deep = false, + ?string $regex = null, + ): bool { + if (!is_string($check) && !is_int($check)) { return false; } - $check = str_replace(['-', ' '], '', $check); + $check = str_replace(['-', ' '], '', (string)$check); if (mb_strlen($check) < 13) { return false; } - if ($regex !== null) { - if (static::_check($check, $regex)) { - return !$deep || static::luhn($check); - } + if ($regex !== null && static::_check($check, $regex)) { + return !$deep || static::luhn($check); } $cards = [ 'all' => [ @@ -182,11 +276,13 @@ public static function cc($check, $type = 'fast', $deep = false, $regex = null) 'maestro' => '/^(?:5020|6\\d{3})\\d{12}$/', 'mc' => '/^(5[1-5]\\d{14})|(2(?:22[1-9]|2[3-9][0-9]|[3-6][0-9]{2}|7[0-1][0-9]|720)\\d{12})$/', 'solo' => '/^(6334[5-9][0-9]|6767[0-9]{2})\\d{10}(\\d{2,3})?$/', + // phpcs:ignore Generic.Files.LineLength 'switch' => '/^(?:49(03(0[2-9]|3[5-9])|11(0[1-2]|7[4-9]|8[1-2])|36[0-9]{2})\\d{10}(\\d{2,3})?)|(?:564182\\d{10}(\\d{2,3})?)|(6(3(33[0-4][0-9])|759[0-9]{2})\\d{10}(\\d{2,3})?)$/', 'visa' => '/^4\\d{12}(\\d{3})?$/', - 'voyager' => '/^8699[0-9]{11}$/' + 'voyager' => '/^8699[0-9]{11}$/', ], - 'fast' => '/^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6011[0-9]{12}|3(?:0[0-5]|[68][0-9])[0-9]{11}|3[47][0-9]{13})$/' + // phpcs:ignore Generic.Files.LineLength + 'fast' => '/^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6011[0-9]{12}|3(?:0[0-5]|[68][0-9])[0-9]{11}|3[47][0-9]{13})$/', ]; if (is_array($type)) { @@ -219,16 +315,16 @@ public static function cc($check, $type = 'fast', $deep = false, $regex = null) /** * Used to check the count of a given value of type array or Countable. * - * @param array|\Countable $check The value to check the count on. + * @param mixed $check The value to check the count on. * @param string $operator Can be either a word or operand * is greater >, is less <, greater or equal >= * less or equal <=, is less <, equal to ==, not equal != * @param int $expectedCount The expected count value. * @return bool Success */ - public static function numElements($check, $operator, $expectedCount) + public static function numElements(mixed $check, string $operator, int $expectedCount): bool { - if (!is_array($check) && !$check instanceof \Countable) { + if (!is_array($check) && !$check instanceof Countable) { return false; } @@ -238,59 +334,35 @@ public static function numElements($check, $operator, $expectedCount) /** * Used to compare 2 numeric values. * - * @param string $check1 The left value to compare. - * @param string $operator Can be either a word or operand - * is greater >, is less <, greater or equal >= - * less or equal <=, is less <, equal to ==, not equal != - * @param int $check2 The right value to compare. + * @param mixed $check1 The left value to compare. + * @param string $operator Can be one of following operator strings: + * '>', '<', '>=', '<=', '==', '!=', '===' and '!=='. You can use one of + * the Validation::COMPARE_* constants. + * @param mixed $check2 The right value to compare. * @return bool Success */ - public static function comparison($check1, $operator, $check2) + public static function comparison(mixed $check1, string $operator, mixed $check2): bool { - if ((float)$check1 != $check1) { + if ( + (!is_numeric($check1) || !is_numeric($check2)) && + !in_array($operator, static::COMPARE_STRING, true) + ) { return false; } - $operator = str_replace([' ', "\t", "\n", "\r", "\0", "\x0B"], '', strtolower($operator)); - switch ($operator) { - case 'isgreater': - case '>': - if ($check1 > $check2) { - return true; - } - break; - case 'isless': - case '<': - if ($check1 < $check2) { - return true; - } - break; - case 'greaterorequal': - case '>=': - if ($check1 >= $check2) { - return true; - } - break; - case 'lessorequal': - case '<=': - if ($check1 <= $check2) { - return true; - } - break; - case 'equalto': - case '==': - if ($check1 == $check2) { - return true; - } - break; - case 'notequal': - case '!=': - if ($check1 != $check2) { - return true; - } - break; - default: - static::$errors[] = 'You must define the $operator parameter for Validation::comparison()'; + try { + return match ($operator) { + static::COMPARE_GREATER => $check1 > $check2, + static::COMPARE_LESS => $check1 < $check2, + static::COMPARE_GREATER_OR_EQUAL => $check1 >= $check2, + static::COMPARE_LESS_OR_EQUAL => $check1 <= $check2, + static::COMPARE_EQUAL => $check1 == $check2, + static::COMPARE_NOT_EQUAL => $check1 != $check2, + static::COMPARE_SAME => $check1 === $check2, + static::COMPARE_NOT_SAME => $check1 !== $check2, + }; + } catch (UnhandledMatchError) { + static::$errors[] = 'You must define a valid $operator parameter for Validation::comparison()'; } return false; @@ -303,47 +375,47 @@ public static function comparison($check1, $operator, $check2) * * @param mixed $check The value to find in $field. * @param string $field The field to check $check against. This field must be present in $context. - * @param array $context The validation context. + * @param array $context The validation context. * @return bool */ - public static function compareWith($check, $field, $context) + public static function compareWith(mixed $check, string $field, array $context): bool { - if (!isset($context['data'][$field])) { - return false; - } - - return $context['data'][$field] === $check; + return self::compareFields($check, $field, static::COMPARE_SAME, $context); } /** - * Checks if a string contains one or more non-alphanumeric characters. + * Compare one field to another. * - * Returns true if string contains at least the specified number of non-alphanumeric characters + * Return true if the comparison matches the expected result. * - * @param string $check Value to check - * @param int $count Number of non-alphanumerics to check for - * @return bool Success + * @param mixed $check The value to find in $field. + * @param string $field The field to check $check against. This field must be present in $context. + * @param string $operator Comparison operator. See Validation::comparison(). + * @param array $context The validation context. + * @return bool + * @since 3.6.0 */ - public static function containsNonAlphaNumeric($check, $count = 1) + public static function compareFields(mixed $check, string $field, string $operator, array $context): bool { - if (!is_scalar($check)) { + if (!isset($context['data']) || !array_key_exists($field, $context['data'])) { return false; } - $matches = preg_match_all('/[^a-zA-Z0-9]/', $check); - - return $matches >= $count; + return static::comparison($check, $operator, $context['data'][$field]); } /** * Used when a custom regular expression is needed. * - * @param string $check The value to check. + * @param mixed $check The value to check. * @param string|null $regex If $check is passed as a string, $regex must also be set to valid regular expression * @return bool Success */ - public static function custom($check, $regex = null) + public static function custom(mixed $check, ?string $regex = null): bool { + if (!is_scalar($check)) { + return false; + } if ($regex === null) { static::$errors[] = 'You must define a regular expression for Validation::custom()'; @@ -357,13 +429,13 @@ public static function custom($check, $regex = null) * Date validation, determines if the string passed is a valid date. * keys that expect full month, day and year will validate leap years. * - * Years are valid from 1800 to 2999. + * Years are valid from 0001 to 2999. * * ### Formats: * + * - `ymd` 2006-12-27 or 06-12-27 separators can be a space, period, dash, forward slash * - `dmy` 27-12-2006 or 27-12-06 separators can be a space, period, dash, forward slash * - `mdy` 12-27-2006 or 12-27-06 separators can be a space, period, dash, forward slash - * - `ymd` 2006-12-27 or 06-12-27 separators can be a space, period, dash, forward slash * - `dMy` 27 December 2006 or 27 Dec 2006 * - `Mdy` December 27, 2006 or Dec 27, 2006 comma is optional * - `My` December 2006 or Dec 2006 @@ -371,15 +443,18 @@ public static function custom($check, $regex = null) * - `ym` 2006/12 or 06/12 separators can be a space, period, dash, forward slash * - `y` 2006 just the year without any separators * - * @param string|\DateTimeInterface $check a valid date string/object - * @param string|array $format Use a string or an array of the keys above. - * Arrays should be passed as ['dmy', 'mdy', etc] + * @param mixed $check a valid date string/object + * @param array|string $format Use a string or an array of the keys above. + * Arrays should be passed as ['dmy', 'mdy', ...] * @param string|null $regex If a custom regular expression is used this is the only validation that will occur. * @return bool Success */ - public static function date($check, $format = 'ymd', $regex = null) + public static function date(mixed $check, array|string $format = 'ymd', ?string $regex = null): bool { - if ($check instanceof DateTimeInterface) { + if ( + (class_exists(ChronosDate::class) && $check instanceof ChronosDate) + || $check instanceof DateTimeInterface + ) { return true; } if (is_object($check)) { @@ -395,37 +470,45 @@ public static function date($check, $format = 'ymd', $regex = null) } $month = '(0[123456789]|10|11|12)'; $separator = '([- /.])'; - $fourDigitYear = '(([1][8-9][0-9][0-9])|([2][0-9][0-9][0-9]))'; - $twoDigitYear = '([0-9]{2})'; + // Don't allow 0000, but 0001-2999 are ok. + $fourDigitYear = '(?:(?!0000)[012]\d{3})'; + $twoDigitYear = '(?:\d{2})'; $year = '(?:' . $fourDigitYear . '|' . $twoDigitYear . ')'; + // phpcs:disable Generic.Files.LineLength + // 2 or 4 digit leap year sub-pattern + $leapYear = '(?:(?:(?:(?!0000)[012]\\d)?(?:0[48]|[2468][048]|[13579][26])|(?:(?:16|[2468][048]|[3579][26])00)))'; + // 4 digit leap year sub-pattern + $fourDigitLeapYear = '(?:(?:(?:(?!0000)[012]\\d)(?:0[48]|[2468][048]|[13579][26])|(?:(?:16|[2468][048]|[3579][26])00)))'; + $regex['dmy'] = '%^(?:(?:31(\\/|-|\\.|\\x20)(?:0?[13578]|1[02]))\\1|(?:(?:29|30)' . - $separator . '(?:0?[1,3-9]|1[0-2])\\2))(?:(?:1[6-9]|[2-9]\\d)?\\d{2})$|^(?:29' . - $separator . '0?2\\3(?:(?:(?:1[6-9]|[2-9]\\d)?(?:0[48]|[2468][048]|[13579][26])|(?:(?:16|[2468][048]|[3579][26])00))))$|^(?:0?[1-9]|1\\d|2[0-8])' . - $separator . '(?:(?:0?[1-9])|(?:1[0-2]))\\4(?:(?:1[6-9]|[2-9]\\d)?\\d{2})$%'; + $separator . '(?:0?[13-9]|1[0-2])\\2))' . $year . '$|^(?:29' . + $separator . '0?2\\3' . $leapYear . ')$|^(?:0?[1-9]|1\\d|2[0-8])' . + $separator . '(?:(?:0?[1-9])|(?:1[0-2]))\\4' . $year . '$%'; $regex['mdy'] = '%^(?:(?:(?:0?[13578]|1[02])(\\/|-|\\.|\\x20)31)\\1|(?:(?:0?[13-9]|1[0-2])' . - $separator . '(?:29|30)\\2))(?:(?:1[6-9]|[2-9]\\d)?\\d{2})$|^(?:0?2' . $separator . '29\\3(?:(?:(?:1[6-9]|[2-9]\\d)?(?:0[48]|[2468][048]|[13579][26])|(?:(?:16|[2468][048]|[3579][26])00))))$|^(?:(?:0?[1-9])|(?:1[0-2]))' . - $separator . '(?:0?[1-9]|1\\d|2[0-8])\\4(?:(?:1[6-9]|[2-9]\\d)?\\d{2})$%'; + $separator . '(?:29|30)\\2))' . $year . '$|^(?:0?2' . $separator . '29\\3' . $leapYear . ')$|^(?:(?:0?[1-9])|(?:1[0-2]))' . + $separator . '(?:0?[1-9]|1\\d|2[0-8])\\4' . $year . '$%'; - $regex['ymd'] = '%^(?:(?:(?:(?:(?:1[6-9]|[2-9]\\d)?(?:0[48]|[2468][048]|[13579][26])|(?:(?:16|[2468][048]|[3579][26])00)))' . - $separator . '(?:0?2\\1(?:29)))|(?:(?:(?:1[6-9]|[2-9]\\d)?\\d{2})' . - $separator . '(?:(?:(?:0?[13578]|1[02])\\2(?:31))|(?:(?:0?[1,3-9]|1[0-2])\\2(29|30))|(?:(?:0?[1-9])|(?:1[0-2]))\\2(?:0?[1-9]|1\\d|2[0-8]))))$%'; + $regex['ymd'] = '%^(?:(?:' . $leapYear . + $separator . '(?:0?2\\1(?:29)))|(?:' . $year . + $separator . '(?:(?:(?:0?[13578]|1[02])\\2(?:31))|(?:(?:0?[13-9]|1[0-2])\\2(29|30))|(?:(?:0?[1-9])|(?:1[0-2]))\\2(?:0?[1-9]|1\\d|2[0-8]))))$%'; - $regex['dMy'] = '/^((31(?!\\ (Feb(ruary)?|Apr(il)?|June?|(Sep(?=\\b|t)t?|Nov)(ember)?)))|((30|29)(?!\\ Feb(ruary)?))|(29(?=\\ Feb(ruary)?\\ (((1[6-9]|[2-9]\\d)(0[48]|[2468][048]|[13579][26])|((16|[2468][048]|[3579][26])00)))))|(0?[1-9])|1\\d|2[0-8])\\ (Jan(uary)?|Feb(ruary)?|Ma(r(ch)?|y)|Apr(il)?|Ju((ly?)|(ne?))|Aug(ust)?|Oct(ober)?|(Sep(?=\\b|t)t?|Nov|Dec)(ember)?)\\ ((1[6-9]|[2-9]\\d)\\d{2})$/'; + $regex['dMy'] = '/^((31(?!\\ (Feb(ruary)?|Apr(il)?|June?|(Sep(?=\\b|t)t?|Nov)(ember)?)))|((30|29)(?!\\ Feb(ruary)?))|(29(?=\\ Feb(ruary)?\\ ' . $fourDigitLeapYear . '))|(0?[1-9])|1\\d|2[0-8])\\ (Jan(uary)?|Feb(ruary)?|Ma(r(ch)?|y)|Apr(il)?|Ju((ly?)|(ne?))|Aug(ust)?|Oct(ober)?|(Sep(?=\\b|t)t?|Nov|Dec)(ember)?)\\ ' . $fourDigitYear . '$/'; - $regex['Mdy'] = '/^(?:(((Jan(uary)?|Ma(r(ch)?|y)|Jul(y)?|Aug(ust)?|Oct(ober)?|Dec(ember)?)\\ 31)|((Jan(uary)?|Ma(r(ch)?|y)|Apr(il)?|Ju((ly?)|(ne?))|Aug(ust)?|Oct(ober)?|(Sep)(tember)?|(Nov|Dec)(ember)?)\\ (0?[1-9]|([12]\\d)|30))|(Feb(ruary)?\\ (0?[1-9]|1\\d|2[0-8]|(29(?=,?\\ ((1[6-9]|[2-9]\\d)(0[48]|[2468][048]|[13579][26])|((16|[2468][048]|[3579][26])00)))))))\\,?\\ ((1[6-9]|[2-9]\\d)\\d{2}))$/'; + $regex['Mdy'] = '/^(?:(((Jan(uary)?|Ma(r(ch)?|y)|Jul(y)?|Aug(ust)?|Oct(ober)?|Dec(ember)?)\\ 31)|((Jan(uary)?|Ma(r(ch)?|y)|Apr(il)?|Ju((ly?)|(ne?))|Aug(ust)?|Oct(ober)?|(Sep)(tember)?|(Nov|Dec)(ember)?)\\ (0?[1-9]|([12]\\d)|30))|(Feb(ruary)?\\ (0?[1-9]|1\\d|2[0-8]|(29(?=,?\\ ' . $fourDigitLeapYear . ')))))\\,?\\ ' . $fourDigitYear . ')$/'; $regex['My'] = '%^(Jan(uary)?|Feb(ruary)?|Ma(r(ch)?|y)|Apr(il)?|Ju((ly?)|(ne?))|Aug(ust)?|Oct(ober)?|(Sep(?=\\b|t)t?|Nov|Dec)(ember)?)' . - $separator . '((1[6-9]|[2-9]\\d)\\d{2})$%'; + $separator . $fourDigitYear . '$%'; + // phpcs:enable Generic.Files.LineLength $regex['my'] = '%^(' . $month . $separator . $year . ')$%'; $regex['ym'] = '%^(' . $year . $separator . $month . ')$%'; $regex['y'] = '%^(' . $fourDigitYear . ')$%'; - $format = is_array($format) ? array_values($format) : [$format]; + $format = (array)$format; foreach ($format as $key) { - if (static::_check($check, $regex[$key]) === true) { + if (static::_check($check, $regex[$key])) { return true; } } @@ -438,14 +521,35 @@ public static function date($check, $format = 'ymd', $regex = null) * * All values matching the "date" core validation rule, and the "time" one will be valid * - * @param string|\DateTimeInterface $check Value to check - * @param string|array $dateFormat Format of the date part. See Validation::date() for more information. - * @param string|null $regex Regex for the date part. If a custom regular expression is used this is the only validation that will occur. + * Years are valid from 0001 to 2999. + * + * ### Formats: + * + * - `ymd` 2006-12-27 or 06-12-27 separators can be a space, period, dash, forward slash + * - `dmy` 27-12-2006 or 27-12-06 separators can be a space, period, dash, forward slash + * - `mdy` 12-27-2006 or 12-27-06 separators can be a space, period, dash, forward slash + * - `dMy` 27 December 2006 or 27 Dec 2006 + * - `Mdy` December 27, 2006 or Dec 27, 2006 comma is optional + * - `My` December 2006 or Dec 2006 + * - `my` 12/2006 or 12/06 separators can be a space, period, dash, forward slash + * - `ym` 2006/12 or 06/12 separators can be a space, period, dash, forward slash + * - `y` 2006 just the year without any separators + * + * Time is validated as 24hr (HH:MM[:SS][.FFFFFF]) or am/pm ([H]H:MM[a|p]m) + * + * Seconds and fractional seconds (microseconds) are allowed but optional + * in 24hr format. + * + * @param mixed $check Value to check + * @param array|string $dateFormat Format of the date part. See Validation::date() for more information. + * Or `Validation::DATETIME_ISO8601` to validate an ISO8601 datetime value. + * @param string|null $regex Regex for the date part. If a custom regular expression is used + * this is the only validation that will occur. * @return bool True if the value is valid, false otherwise * @see \Cake\Validation\Validation::date() * @see \Cake\Validation\Validation::time() */ - public static function datetime($check, $dateFormat = 'ymd', $regex = null) + public static function datetime(mixed $check, array|string $dateFormat = 'ymd', ?string $regex = null): bool { if ($check instanceof DateTimeInterface) { return true; @@ -453,58 +557,119 @@ public static function datetime($check, $dateFormat = 'ymd', $regex = null) if (is_object($check)) { return false; } + if (is_array($dateFormat) && count($dateFormat) === 1) { + $dateFormat = reset($dateFormat); + } + if ($dateFormat === static::DATETIME_ISO8601 && !static::iso8601($check)) { + return false; + } + $valid = false; if (is_array($check)) { $check = static::_getDateString($check); $dateFormat = 'ymd'; } - $parts = explode(' ', $check); - if (!empty($parts) && count($parts) > 1) { + if (!is_string($check)) { + return false; + } + $parts = preg_split('/[\sT]+/', $check); + if ($parts && count($parts) > 1) { $date = rtrim(array_shift($parts), ','); $time = implode(' ', $parts); + if ($dateFormat === static::DATETIME_ISO8601) { + $dateFormat = 'ymd'; + $time = preg_split("/[TZ\-\+\.]/", $time) ?: []; + $time = array_shift($time); + } $valid = static::date($date, $dateFormat, $regex) && static::time($time); } return $valid; } + /** + * Validates an iso8601 datetime format + * ISO8601 recognize datetime like 2019 as a valid date. To validate and check date integrity, use @see \Cake\Validation\Validation::datetime() + * + * @param mixed $check Value to check + * @return bool True if the value is valid, false otherwise + * @see https://www.myintervals.com/blog/2009/05/20/iso-8601-date-validation-that-doesnt-suck/ for regex credits + */ + public static function iso8601(mixed $check): bool + { + if ($check instanceof DateTimeInterface) { + return true; + } + if (is_object($check)) { + return false; + } + + // phpcs:ignore Generic.Files.LineLength + $regex = '/^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/'; + + return static::_check($check, $regex); + } + /** * Time validation, determines if the string passed is a valid time. - * Validates time as 24hr (HH:MM) or am/pm ([H]H:MM[a|p]m) - * Does not allow/validate seconds. + * Validates time as 24hr (HH:MM[:SS][.FFFFFF]) or am/pm ([H]H:MM[a|p]m) + * + * Seconds and fractional seconds (microseconds) are allowed but optional + * in 24hr format. * - * @param string|\DateTimeInterface $check a valid time string/object + * @param mixed $check a valid time string/object * @return bool Success */ - public static function time($check) + public static function time(mixed $check): bool { - if ($check instanceof DateTimeInterface) { + if ( + (class_exists(ChronosTime::class) && $check instanceof ChronosTime) + || $check instanceof DateTimeInterface + ) { return true; } if (is_array($check)) { $check = static::_getDateString($check); } - return static::_check($check, '%^((0?[1-9]|1[012])(:[0-5]\d){0,2} ?([AP]M|[ap]m))$|^([01]\d|2[0-3])(:[0-5]\d){0,2}$%'); + if (!is_scalar($check)) { + return false; + } + + $meridianClockRegex = '^((0?[1-9]|1[012])(:[0-5]\d){0,2} ?([AP]M|[ap]m))$'; + $standardClockRegex = '^([01]\d|2[0-3])((:[0-5]\d){1,2}|(:[0-5]\d){2}\.\d{0,6})$'; + + return static::_check($check, '%' . $meridianClockRegex . '|' . $standardClockRegex . '%'); } /** * Date and/or time string validation. * Uses `I18n::Time` to parse the date. This means parsing is locale dependent. * - * @param string|\DateTime $check a date string or object (will always pass) + * @param mixed $check a date string or object (will always pass) * @param string $type Parser type, one out of 'date', 'time', and 'datetime' * @param string|int|null $format any format accepted by IntlDateFormatter * @return bool Success * @throws \InvalidArgumentException when unsupported $type given - * @see \Cake\I18n\Time::parseDate(), \Cake\I18n\Time::parseTime(), \Cake\I18n\Time::parseDateTime() + * @see \Cake\I18n\Date::parseDate() + * @see \Cake\I18n\Time::parseTime() + * @see \Cake\I18n\DateTime::parseDateTime() */ - public static function localizedTime($check, $type = 'datetime', $format = null) + public static function localizedTime(mixed $check, string $type = 'datetime', string|int|null $format = null): bool { - if ($check instanceof DateTimeInterface) { + if (!class_exists(DateTime::class)) { + throw new CakeException( + 'The Cake\I18n\DateTime class is not available. Install the cakephp/i18n package.', + ); + } + + if ( + (class_exists(ChronosTime::class) && $check instanceof ChronosTime) + || $check instanceof DateTimeInterface + ) { return true; } - if (is_object($check)) { + if (!is_string($check)) { return false; } static $methods = [ @@ -517,24 +682,20 @@ public static function localizedTime($check, $type = 'datetime', $format = null) } $method = $methods[$type]; - return (Time::$method($check, $format) !== null); + return DateTime::$method($check, $format) !== null; } /** * Validates if passed value is boolean-like. * - * The list of what is considered to be boolean values, may be set via $booleanValues. + * The list of what is considered to be boolean values may be set via $booleanValues. * - * @param bool|int|string $check Value to check. - * @param array $booleanValues List of valid boolean values, defaults to `[true, false, 0, 1, '0', '1']`. + * @param mixed $check Value to check. + * @param array $booleanValues List of valid boolean values, defaults to `[true, false, 0, 1, '0', '1']`. * @return bool Success. */ - public static function boolean($check, array $booleanValues = []) + public static function boolean(mixed $check, array $booleanValues = [true, false, 0, 1, '0', '1']): bool { - if (!$booleanValues) { - $booleanValues = [true, false, 0, 1, '0', '1']; - } - return in_array($check, $booleanValues, true); } @@ -543,16 +704,12 @@ public static function boolean($check, array $booleanValues = []) * * The list of what is considered to be truthy values, may be set via $truthyValues. * - * @param bool|int|string $check Value to check. - * @param array $truthyValues List of valid truthy values, defaults to `[true, 1, '1']`. + * @param mixed $check Value to check. + * @param array $truthyValues List of valid truthy values, defaults to `[true, 1, '1']`. * @return bool Success. */ - public static function truthy($check, array $truthyValues = []) + public static function truthy(mixed $check, array $truthyValues = [true, 1, '1']): bool { - if (!$truthyValues) { - $truthyValues = [true, 1, '1']; - } - return in_array($check, $truthyValues, true); } @@ -561,35 +718,38 @@ public static function truthy($check, array $truthyValues = []) * * The list of what is considered to be falsey values, may be set via $falseyValues. * - * @param bool|int|string $check Value to check. - * @param array $falseyValues List of valid falsey values, defaults to `[false, 0, '0']`. + * @param mixed $check Value to check. + * @param array $falseyValues List of valid falsey values, defaults to `[false, 0, '0']`. * @return bool Success. */ - public static function falsey($check, array $falseyValues = []) + public static function falsey(mixed $check, array $falseyValues = [false, 0, '0']): bool { - if (!$falseyValues) { - $falseyValues = [false, 0, '0']; - } - return in_array($check, $falseyValues, true); } /** * Checks that a value is a valid decimal. Both the sign and exponent are optional. * + * Be aware that the currently set locale is being used to determine + * the decimal and thousands separator of the given number. + * * Valid Places: * * - null => Any number of decimal places, including none. The '.' is not required. * - true => Any number of decimal places greater than 0, or a float|double. The '.' is required. * - 1..N => Exactly that many number of decimal places. The '.' is required. * - * @param float $check The value the test for decimal. - * @param int|bool|null $places Decimal places. + * @param mixed $check The value the test for decimal. + * @param int|true|null $places Decimal places. * @param string|null $regex If a custom regular expression is used, this is the only validation that will occur. * @return bool Success */ - public static function decimal($check, $places = null, $regex = null) + public static function decimal(mixed $check, int|bool|null $places = null, ?string $regex = null): bool { + if (!is_scalar($check)) { + return false; + } + if ($regex === null) { $lnum = '[0-9]+'; $dnum = "[0-9]*[\.]{$lnum}"; @@ -603,7 +763,7 @@ public static function decimal($check, $places = null, $regex = null) $check = sprintf('%.1f', $check); } $regex = "/^{$sign}{$dnum}{$exp}$/"; - } elseif (is_numeric($places)) { + } else { $places = '[0-9]{' . $places . '}'; $dnum = "(?:[0-9]*[\.]{$places}|{$lnum}[\.]{$places})"; $regex = "/^{$sign}{$dnum}{$exp}$/"; @@ -616,8 +776,12 @@ public static function decimal($check, $places = null, $regex = null) $decimalPoint = $formatter->getSymbol(NumberFormatter::DECIMAL_SEPARATOR_SYMBOL); $groupingSep = $formatter->getSymbol(NumberFormatter::GROUPING_SEPARATOR_SYMBOL); - $check = str_replace($groupingSep, '', $check); - $check = str_replace($decimalPoint, '.', $check); + // There are two types of non-breaking spaces - we inject a space to account for human input + if ($groupingSep === "\xc2\xa0" || $groupingSep === "\xe2\x80\xaf") { + $check = str_replace([' ', $groupingSep, $decimalPoint], ['', '', '.'], (string)$check); + } else { + $check = str_replace([$groupingSep, $decimalPoint], ['', '.'], (string)$check); + } return static::_check($check, $regex); } @@ -628,26 +792,26 @@ public static function decimal($check, $places = null, $regex = null) * Only uses getmxrr() checking for deep validation, or * any PHP version on a non-windows distribution * - * @param string $check Value to check - * @param bool $deep Perform a deeper validation (if true), by also checking availability of host + * @param mixed $check Value to check + * @param bool|null $deep Perform a deeper validation (if true), by also checking availability of host * @param string|null $regex Regex to use (if none it will use built in regex) * @return bool Success */ - public static function email($check, $deep = false, $regex = null) + public static function email(mixed $check, ?bool $deep = false, ?string $regex = null): bool { if (!is_string($check)) { return false; } - if ($regex === null) { - $regex = '/^[\p{L}0-9!#$%&\'*+\/=?^_`{|}~-]+(?:\.[\p{L}0-9!#$%&\'*+\/=?^_`{|}~-]+)*@' . self::$_pattern['hostname'] . '$/ui'; - } + // phpcs:ignore Generic.Files.LineLength + $regex ??= '/^[\p{L}0-9!#$%&\'*+\/=?^_`{|}~-]+(?:\.[\p{L}0-9!#$%&\'*+\/=?^_`{|}~-]+)*@' . self::$_pattern['hostname'] . '$/ui'; + $return = static::_check($check, $regex); if ($deep === false || $deep === null) { return $return; } - if ($return === true && preg_match('/@(' . static::$_pattern['hostname'] . ')$/i', $check, $regs)) { + if ($return && preg_match('/@(' . static::$_pattern['hostname'] . ')$/i', $check, $regs)) { if (function_exists('getmxrr') && getmxrr($regs[1], $mxhosts)) { return true; } @@ -661,6 +825,165 @@ public static function email($check, $deep = false, $regex = null) return false; } + /** + * Checks that the value is a valid backed enum instance or value. + * + * @param mixed $check Value to check + * @param class-string<\BackedEnum> $enumClassName The valid backed enum class name + * @return bool Success + * @since 5.0.3 + */ + public static function enum(mixed $check, string $enumClassName): bool + { + return static::checkEnum($check, $enumClassName); + } + + /** + * Checks that the value is backed enum instance or value of one of the provided enum cases. + * + * @param mixed $check Value to check + * @param array<\BackedEnum> $cases Array of enum cases that are valid. + * @return bool Success + * @since 5.1.0 + */ + public static function enumOnly(mixed $check, array $cases): bool + { + if ($cases === []) { + throw new InvalidArgumentException('At least one case needed for `enumOnly()` validation.'); + } + + $firstKey = array_key_first($cases); + $firstValue = $cases[$firstKey]; + $enumClassName = $firstValue::class; + + $options = ['only' => $cases]; + + return static::checkEnum($check, $enumClassName, $options); + } + + /** + * Checks that the value is a valid backed enum instance or value except the cases provided. + * + * @param mixed $check Value to check + * @param array<\BackedEnum> $cases Array of enum cases that are not valid. + * @return bool Success + * @since 5.1.0 + */ + public static function enumExcept(mixed $check, array $cases): bool + { + if ($cases === []) { + throw new InvalidArgumentException('At least one case needed for `enumExcept()` validation.'); + } + + $firstKey = array_key_first($cases); + $firstValue = $cases[$firstKey]; + $enumClassName = $firstValue::class; + + $options = ['except' => $cases]; + + return static::checkEnum($check, $enumClassName, $options); + } + + /** + * @param mixed $check + * @param class-string $enumClassName + * @param array $options + * @return bool + */ + protected static function checkEnum(mixed $check, string $enumClassName, array $options = []): bool + { + if ( + $check instanceof $enumClassName && + $check instanceof BackedEnum + ) { + return static::isValidEnum($check, $options); + } + + $backingType = null; + try { + $reflectionEnum = new ReflectionEnum($enumClassName); + + /** @var \ReflectionNamedType|null $reflectionBackingType */ + $reflectionBackingType = $reflectionEnum->getBackingType(); + if ($reflectionBackingType) { + if (method_exists($reflectionBackingType, 'getName')) { + $backingType = $reflectionBackingType->getName(); + } else { + $backingType = (string)$reflectionBackingType; + } + } + } catch (ReflectionException) { + } + + if ($backingType === null) { + throw new InvalidArgumentException( + 'The `$enumClassName` argument must be the classname of a valid backed enum.', + ); + } + + if (!is_string($check) && !is_int($check)) { + return false; + } + + if ($backingType === 'int') { + if (!is_numeric($check)) { + return false; + } + $check = (int)$check; + } + + if (get_debug_type($check) !== $backingType) { + return false; + } + + $options += [ + 'only' => null, + 'except' => null, + ]; + + /** @var class-string<\BackedEnum> $enumClassName */ + $enum = $enumClassName::tryFrom($check); + if ($enum === null) { + return false; + } + + return static::isValidEnum($enum, $options); + } + + /** + * @param \BackedEnum $enum + * @param array $options + * @return bool + */ + protected static function isValidEnum(BackedEnum $enum, array $options): bool + { + $options += ['only' => null, 'except' => null]; + + if ($options['only']) { + if (!is_array($options['only'])) { + $options['only'] = [$options['only']]; + } + + if (in_array($enum, $options['only'], true)) { + return true; + } + + return false; + } + + if ($options['except']) { + if (!is_array($options['except'])) { + $options['except'] = [$options['except']]; + } + + if (in_array($enum, $options['except'], true)) { + return false; + } + } + + return true; + } + /** * Checks that value is exactly $comparedTo. * @@ -668,25 +991,35 @@ public static function email($check, $deep = false, $regex = null) * @param mixed $comparedTo Value to compare * @return bool Success */ - public static function equalTo($check, $comparedTo) + public static function equalTo(mixed $check, mixed $comparedTo): bool { - return ($check === $comparedTo); + return $check === $comparedTo; } /** * Checks that value has a valid file extension. * - * @param string|array $check Value to check - * @param array $extensions file extensions to allow. By default extensions are 'gif', 'jpeg', 'png', 'jpg' + * Supports checking `\Psr\Http\Message\UploadedFileInterface` instances + * and arrays with a `name` key. + * + * @param mixed $check Value to check + * @param array $extensions file extensions to allow. By default extensions are 'gif', 'jpeg', 'png', 'jpg' * @return bool Success */ - public static function extension($check, $extensions = ['gif', 'jpeg', 'png', 'jpg']) + public static function extension(mixed $check, array $extensions = ['gif', 'jpeg', 'png', 'jpg']): bool { - if (is_array($check)) { - $check = isset($check['name']) ? $check['name'] : array_shift($check); + if (interface_exists(UploadedFileInterface::class) && $check instanceof UploadedFileInterface) { + $check = $check->getClientFilename(); + } elseif (is_array($check) && isset($check['name'])) { + $check = $check['name']; + } elseif (is_array($check)) { + return static::extension(array_shift($check), $extensions); + } - return static::extension($check, $extensions); + if (!$check) { + return false; } + $extension = strtolower(pathinfo($check, PATHINFO_EXTENSION)); foreach ($extensions as $value) { if ($extension === strtolower($value)) { @@ -700,12 +1033,16 @@ public static function extension($check, $extensions = ['gif', 'jpeg', 'png', 'j /** * Validation of an IP address. * - * @param string $check The string to test. + * @param mixed $check The string to test. * @param string $type The IP Protocol version to validate against * @return bool Success */ - public static function ip($check, $type = 'both') + public static function ip(mixed $check, string $type = 'both'): bool { + if (!is_string($check)) { + return false; + } + $type = strtolower($type); $flags = 0; if ($type === 'ipv4') { @@ -718,62 +1055,107 @@ public static function ip($check, $type = 'both') return (bool)filter_var($check, FILTER_VALIDATE_IP, ['flags' => $flags]); } + /** + * Validation of an IP address or range (subnet). + * + * @param mixed $check The string to test. + * @param string $type The IP Protocol version to validate against + * @return bool Success + */ + public static function ipOrRange(mixed $check, string $type = 'both'): bool + { + if (!is_string($check)) { + return false; + } + + if (!str_contains($check, '/')) { + return static::ip($check, $type); + } + + [$ip, $mask] = explode('/', $check, 2); + + if (in_array($type, ['both', 'ipv4', true]) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return is_numeric($mask) && $mask >= 0 && $mask <= 32; + } + if (in_array($type, ['both', 'ipv6', true]) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + return is_numeric($mask) && $mask >= 0 && $mask <= 128; + } + + return false; + } + /** * Checks whether the length of a string (in characters) is greater or equal to a minimal length. * - * @param string $check The string to test + * @param mixed $check The string to test * @param int $min The minimal string length * @return bool Success */ - public static function minLength($check, $min) + public static function minLength(mixed $check, int $min): bool { - return mb_strlen($check) >= $min; + if (!is_scalar($check)) { + return false; + } + + return mb_strlen((string)$check) >= $min; } /** * Checks whether the length of a string (in characters) is smaller or equal to a maximal length. * - * @param string $check The string to test + * @param mixed $check The string to test * @param int $max The maximal string length * @return bool Success */ - public static function maxLength($check, $max) + public static function maxLength(mixed $check, int $max): bool { - return mb_strlen($check) <= $max; + if (!is_scalar($check)) { + return false; + } + + return mb_strlen((string)$check) <= $max; } /** * Checks whether the length of a string (in bytes) is greater or equal to a minimal length. * - * @param string $check The string to test + * @param mixed $check The string to test * @param int $min The minimal string length (in bytes) * @return bool Success */ - public static function minLengthBytes($check, $min) + public static function minLengthBytes(mixed $check, int $min): bool { - return strlen($check) >= $min; + if (!is_scalar($check)) { + return false; + } + + return strlen((string)$check) >= $min; } /** * Checks whether the length of a string (in bytes) is smaller or equal to a maximal length. * - * @param string $check The string to test + * @param mixed $check The string to test * @param int $max The maximal string length * @return bool Success */ - public static function maxLengthBytes($check, $max) + public static function maxLengthBytes(mixed $check, int $max): bool { - return strlen($check) <= $max; + if (!is_scalar($check)) { + return false; + } + + return strlen((string)$check) <= $max; } /** * Checks that a value is a monetary amount. * - * @param string $check Value to check + * @param mixed $check Value to check * @param string $symbolPosition Where symbol is located (left/right) * @return bool Success */ - public static function money($check, $symbolPosition = 'left') + public static function money(mixed $check, string $symbolPosition = 'left'): bool { $money = '(?!0,?\d)(?:\d{1,3}(?:([, .])\d{3})?(?:\1\d{3})*|(?:\d+))((?!\1)[,.]\d{1,2})?'; if ($symbolPosition === 'right') { @@ -794,20 +1176,20 @@ public static function money($check, $symbolPosition = 'left') * - max => maximum number of non-zero choices that can be made * - min => minimum number of non-zero choices that can be made * - * @param array $check Value to check - * @param array $options Options for the check. + * @param mixed $check Value to check + * @param array $options Options for the check. * @param bool $caseInsensitive Set to true for case insensitive comparison. * @return bool Success */ - public static function multiple($check, array $options = [], $caseInsensitive = false) + public static function multiple(mixed $check, array $options = [], bool $caseInsensitive = false): bool { $defaults = ['in' => null, 'max' => null, 'min' => null]; $options += $defaults; $check = array_filter((array)$check, function ($value) { - return ($value || is_numeric($value)); + return $value || is_numeric($value); }); - if (empty($check)) { + if (!$check) { return false; } if ($options['max'] && count($check) > $options['max']) { @@ -823,7 +1205,7 @@ public static function multiple($check, array $options = [], $caseInsensitive = foreach ($check as $val) { $strict = !is_numeric($val); if ($caseInsensitive) { - $val = mb_strtolower($val); + $val = mb_strtolower((string)$val); } if (!in_array((string)$val, $options['in'], $strict)) { return false; @@ -837,10 +1219,10 @@ public static function multiple($check, array $options = [], $caseInsensitive = /** * Checks if a value is numeric. * - * @param string $check Value to check + * @param mixed $check Value to check * @return bool Success */ - public static function numeric($check) + public static function numeric(mixed $check): bool { return is_numeric($check); } @@ -848,12 +1230,12 @@ public static function numeric($check) /** * Checks if a value is a natural number. * - * @param string $check Value to check + * @param mixed $check Value to check * @param bool $allowZero Set true to allow zero, defaults to false * @return bool Success * @see https://en.wikipedia.org/wiki/Natural_number */ - public static function naturalNumber($check, $allowZero = false) + public static function naturalNumber(mixed $check, bool $allowZero = false): bool { $regex = $allowZero ? '/^(?:0|[1-9][0-9]*)$/' : '/^[1-9][0-9]*$/'; @@ -867,12 +1249,12 @@ public static function naturalNumber($check, $allowZero = false) * If they are not set, will return true if $check is a * legal finite on this platform. * - * @param string $check Value to check - * @param int|float|null $lower Lower limit - * @param int|float|null $upper Upper limit + * @param mixed $check Value to check + * @param float|null $lower Lower limit + * @param float|null $upper Upper limit * @return bool Success */ - public static function range($check, $lower = null, $upper = null) + public static function range(mixed $check, ?float $lower = null, ?float $upper = null): bool { if (!is_numeric($check)) { return false; @@ -881,10 +1263,10 @@ public static function range($check, $lower = null, $upper = null) return false; } if (isset($lower, $upper)) { - return ($check >= $lower && $check <= $upper); + return $check >= $lower && $check <= $upper; } - return is_finite($check); + return is_finite((float)$check); } /** @@ -893,20 +1275,24 @@ public static function range($check, $lower = null, $upper = null) * The regex checks for the following component parts: * * - a valid, optional, scheme - * - a valid ip address OR + * - a valid IP address OR * a valid domain name as defined by section 2.3.1 of https://www.ietf.org/rfc/rfc1035.txt * with an optional port number * - an optional valid path * - an optional query string (get parameters) * - an optional fragment (anchor tag) as defined in RFC 3986 * - * @param string $check Value to check + * @param mixed $check Value to check * @param bool $strict Require URL to be prefixed by a valid scheme (one of http(s)/ftp(s)/file/news/gopher) * @return bool Success * @link https://tools.ietf.org/html/rfc3986 */ - public static function url($check, $strict = false) + public static function url(mixed $check, bool $strict = false): bool { + if (!is_string($check)) { + return false; + } + static::_populateIp(); $emoji = '\x{1F190}-\x{1F9EF}'; @@ -915,28 +1301,33 @@ public static function url($check, $strict = false) $subDelimiters = preg_quote('/!"$&\'()*+,-.@_:;=~[]', '/'); $path = '([' . $subDelimiters . $alpha . ']|' . $hex . ')'; $fragmentAndQuery = '([\?' . $subDelimiters . $alpha . ']|' . $hex . ')'; - $regex = '/^(?:(?:https?|ftps?|sftp|file|news|gopher):\/\/)' . (!empty($strict) ? '' : '?') . + // phpcs:disable Generic.Files.LineLength + $regex = '/^(?:(?:https?|ftps?|sftp|file|news|gopher):\/\/)' . ($strict ? '' : '?') . '(?:' . static::$_pattern['IPv4'] . '|\[' . static::$_pattern['IPv6'] . '\]|' . static::$_pattern['hostname'] . ')(?::[1-9][0-9]{0,4})?' . '(?:\/' . $path . '*)?' . '(?:\?' . $fragmentAndQuery . '*)?' . '(?:#' . $fragmentAndQuery . '*)?$/iu'; + // phpcs:enable Generic.Files.LineLength return static::_check($check, $regex); } /** - * Checks if a value is in a given list. Comparison is case sensitive by default. + * Checks if a value is in a given list. Comparison is case-sensitive by default. * - * @param string $check Value to check. - * @param array $list List to check against. - * @param bool $caseInsensitive Set to true for case insensitive comparison. + * @param mixed $check Value to check. + * @param array $list List to check against. + * @param bool $caseInsensitive Set to true for case-insensitive comparison. * @return bool Success. */ - public static function inList($check, array $list, $caseInsensitive = false) + public static function inList(mixed $check, array $list, bool $caseInsensitive = false): bool { + if (!is_scalar($check)) { + return false; + } if ($caseInsensitive) { $list = array_map('mb_strtolower', $list); - $check = mb_strtolower($check); + $check = mb_strtolower((string)$check); } else { $list = array_map('strval', $list); } @@ -944,35 +1335,15 @@ public static function inList($check, array $list, $caseInsensitive = false) return in_array((string)$check, $list, true); } - /** - * Runs an user-defined validation. - * - * @param string|array $check value that will be validated in user-defined methods. - * @param object $object class that holds validation method - * @param string $method class method name for validation to run - * @param array|null $args arguments to send to method - * @return mixed user-defined class class method returns - * @deprecated 3.0.2 You can just set a callable for `rule` key when adding validators. - */ - public static function userDefined($check, $object, $method, $args = null) - { - trigger_error( - 'Validation::userDefined() is deprecated. Just set a callable for `rule` key when adding validators instead.', - E_USER_DEPRECATED - ); - - return $object->$method($check, $args); - } - /** * Checks that a value is a valid UUID - https://tools.ietf.org/html/rfc4122 * - * @param string $check Value to check + * @param mixed $check Value to check * @return bool Success */ - public static function uuid($check) + public static function uuid(mixed $check): bool { - $regex = '/^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[0-5][a-fA-F0-9]{3}-[089aAbB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$/'; + $regex = '/^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[0-8][a-fA-F0-9]{3}-[089aAbB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$/'; return self::_check($check, $regex); } @@ -980,40 +1351,41 @@ public static function uuid($check) /** * Runs a regular expression match. * - * @param string $check Value to check against the $regex expression + * @param mixed $check Value to check against the $regex expression * @param string $regex Regular expression * @return bool Success of match */ - protected static function _check($check, $regex) + protected static function _check(mixed $check, string $regex): bool { - return is_string($regex) && is_scalar($check) && preg_match($regex, $check); + return is_scalar($check) && preg_match($regex, (string)$check); } /** * Luhn algorithm * - * @param string|array $check Value to check. + * @param mixed $check Value to check. * @return bool Success * @see https://en.wikipedia.org/wiki/Luhn_algorithm */ - public static function luhn($check) + public static function luhn(mixed $check): bool { if (!is_scalar($check) || (int)$check === 0) { return false; } $sum = 0; + $check = (string)$check; $length = strlen($check); for ($position = 1 - ($length % 2); $position < $length; $position += 2) { - $sum += $check[$position]; + $sum += (int)$check[$position]; } - for ($position = ($length % 2); $position < $length; $position += 2) { - $number = $check[$position] * 2; - $sum += ($number < 10) ? $number : $number - 9; + for ($position = $length % 2; $position < $length; $position += 2) { + $number = (int)$check[$position] * 2; + $sum += $number < 10 ? $number : $number - 9; } - return ($sum % 10 === 0); + return $sum % 10 === 0; } /** @@ -1023,36 +1395,33 @@ public static function luhn($check) * by checking the using finfo on the file, not relying on the content-type * sent by the client. * - * @param string|array|\Psr\Http\Message\UploadedFileInterface $check Value to check. + * @param mixed $check Value to check. * @param array|string $mimeTypes Array of mime types or regex pattern to check. * @return bool Success - * @throws \RuntimeException when mime type can not be determined. - * @throws \LogicException when ext/fileinfo is missing + * @throws \Cake\Core\Exception\CakeException when mime type can not be determined. */ - public static function mimeType($check, $mimeTypes = []) + public static function mimeType(mixed $check, array|string $mimeTypes = []): bool { $file = static::getFilename($check); - if ($file === false) { + if ($file === null) { return false; } if (!function_exists('finfo_open')) { - throw new LogicException('ext/fileinfo is required for validating file mime types'); + throw new CakeException('ext/fileinfo is required for validating file mime types'); } if (!is_file($file)) { - throw new RuntimeException('Cannot validate mimetype for a missing file'); + throw new CakeException('Cannot validate mimetype for a missing file'); } - $finfo = finfo_open(FILEINFO_MIME); - $finfo = finfo_file($finfo, $file); + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = $finfo ? finfo_file($finfo, $file) : null; - if (!$finfo) { - throw new RuntimeException('Can not determine the mimetype.'); + if (!$mime) { + throw new CakeException('Can not determine the mimetype.'); } - list($mime) = explode(';', $finfo); - if (is_string($mimeTypes)) { return self::_check($mime, $mimeTypes); } @@ -1061,31 +1430,36 @@ public static function mimeType($check, $mimeTypes = []) $mimeTypes[$key] = strtolower($val); } - return in_array($mime, $mimeTypes); + return in_array(strtolower($mime), $mimeTypes, true); } /** - * Helper for reading the file out of the various file implementations - * we accept. + * Helper for reading the file name. * - * @param string|array|\Psr\Http\Message\UploadedFileInterface $check The data to read a filename out of. - * @return string|bool Either the filename or false on failure. + * @param mixed $check The data to read a filename out of. + * @return string|null Either the filename or null on failure. */ - protected static function getFilename($check) + protected static function getFilename(mixed $check): ?string { if ($check instanceof UploadedFileInterface) { + // Uploaded files throw exceptions on upload errors. try { - // Uploaded files throw exceptions on upload errors. - return $check->getStream()->getMetadata('uri'); - } catch (RuntimeException $e) { - return false; + $uri = $check->getStream()->getMetadata('uri'); + if (is_string($uri)) { + return $uri; + } + + return null; + } catch (RuntimeException) { + return null; } } - if (is_array($check) && isset($check['tmp_name'])) { - return $check['tmp_name']; + + if (is_string($check)) { + return $check; } - return $check; + return null; } /** @@ -1095,15 +1469,15 @@ protected static function getFilename($check) * by checking the filesize() on disk and not relying on the length * reported by the client. * - * @param string|array|\Psr\Http\Message\UploadedFileInterface $check Value to check. - * @param string|null $operator See `Validation::comparison()`. - * @param int|string|null $size Size in bytes or human readable string like '5MB'. + * @param mixed $check Value to check. + * @param string $operator See `Validation::comparison()`. + * @param string|int $size Size in bytes or human-readable string like '5MB'. * @return bool Success */ - public static function fileSize($check, $operator = null, $size = null) + public static function fileSize(mixed $check, string $operator, string|int $size): bool { $file = static::getFilename($check); - if ($file === false) { + if ($file === null) { return false; } @@ -1118,16 +1492,23 @@ public static function fileSize($check, $operator = null, $size = null) /** * Checking for upload errors * - * @param string|array|\Psr\Http\Message\UploadedFileInterface $check Value to check. + * Supports checking `\Psr\Http\Message\UploadedFileInterface` instances + * and arrays with an `error` key. + * + * @param mixed $check Value to check. * @param bool $allowNoFile Set to true to allow UPLOAD_ERR_NO_FILE as a pass. * @return bool * @see https://secure.php.net/manual/en/features.file-upload.errors.php */ - public static function uploadError($check, $allowNoFile = false) + public static function uploadError(mixed $check, bool $allowNoFile = false): bool { if ($check instanceof UploadedFileInterface) { $code = $check->getError(); - } elseif (is_array($check) && isset($check['error'])) { + } elseif (is_array($check)) { + if (!isset($check['error'])) { + return false; + } + $code = $check['error']; } else { $code = $check; @@ -1152,78 +1533,83 @@ public static function uploadError($check, $allowNoFile = false) * the file type will be checked with ext/finfo. * - `minSize` - The minimum file size in bytes. Defaults to not checking. * - `maxSize` - The maximum file size in bytes. Defaults to not checking. - * - `optional` - Whether or not this file is optional. Defaults to false. + * - `optional` - Whether this file is optional. Defaults to false. * If true a missing file will pass the validator regardless of other constraints. * - * @param array $file The uploaded file data from PHP. - * @param array $options An array of options for the validation. + * @param mixed $file The uploaded file data from PHP. + * @param array $options An array of options for the validation. * @return bool */ - public static function uploadedFile($file, array $options = []) + public static function uploadedFile(mixed $file, array $options = []): bool { + if (!($file instanceof UploadedFileInterface)) { + return false; + } + $options += [ 'minSize' => null, 'maxSize' => null, 'types' => null, 'optional' => false, ]; - if (!is_array($file) && !($file instanceof UploadedFileInterface)) { - return false; - } - $error = $isUploaded = false; - if ($file instanceof UploadedFileInterface) { - $error = $file->getError(); - $isUploaded = true; - } - if (is_array($file)) { - $keys = ['error', 'name', 'size', 'tmp_name', 'type']; - ksort($file); - if (array_keys($file) != $keys) { - return false; - } - $error = (int)$file['error']; - $isUploaded = is_uploaded_file($file['tmp_name']); - } if (!static::uploadError($file, $options['optional'])) { return false; } - if ($options['optional'] && $error === UPLOAD_ERR_NO_FILE) { + + if ($options['optional'] && $file->getError() === UPLOAD_ERR_NO_FILE) { return true; } - if (isset($options['minSize']) && !static::fileSize($file, '>=', $options['minSize'])) { + + if ( + isset($options['minSize']) + && !static::fileSize($file, static::COMPARE_GREATER_OR_EQUAL, $options['minSize']) + ) { return false; } - if (isset($options['maxSize']) && !static::fileSize($file, '<=', $options['maxSize'])) { + + if ( + isset($options['maxSize']) + && !static::fileSize($file, static::COMPARE_LESS_OR_EQUAL, $options['maxSize']) + ) { return false; } + if (isset($options['types']) && !static::mimeType($file, $options['types'])) { return false; } - return $isUploaded; + return true; } /** * Validates the size of an uploaded image. * - * @param array $file The uploaded file data from PHP. - * @param array $options Options to validate width and height. + * @param mixed $file The uploaded file data from PHP. + * @param array $options Options to validate width and height. * @return bool + * @throws \InvalidArgumentException */ - public static function imageSize($file, $options) + public static function imageSize(mixed $file, array $options): bool { if (!isset($options['height']) && !isset($options['width'])) { - throw new InvalidArgumentException('Invalid image size validation parameters! Missing `width` and / or `height`.'); + throw new InvalidArgumentException( + 'Invalid image size validation parameters! Missing `width` and / or `height`.', + ); } - if ($file instanceof UploadedFileInterface) { - $file = $file->getStream()->getContents(); - } elseif (is_array($file) && isset($file['tmp_name'])) { - $file = $file['tmp_name']; + $file = static::getFilename($file); + if ($file === null) { + return false; } - - list($width, $height) = getimagesize($file); + $width = null; + $height = null; + $imageSize = getimagesize($file); + if ($imageSize) { + [$width, $height] = $imageSize; + } + $validWidth = null; + $validHeight = null; if (isset($options['height'])) { $validHeight = self::comparison($height, $options['height'][0], $options['height'][1]); @@ -1231,13 +1617,13 @@ public static function imageSize($file, $options) if (isset($options['width'])) { $validWidth = self::comparison($width, $options['width'][0], $options['width'][1]); } - if (isset($validHeight) && isset($validWidth)) { - return ($validHeight && $validWidth); + if ($validHeight !== null && $validWidth !== null) { + return $validHeight && $validWidth; } - if (isset($validHeight)) { + if ($validHeight !== null) { return $validHeight; } - if (isset($validWidth)) { + if ($validWidth !== null) { return $validWidth; } @@ -1247,36 +1633,36 @@ public static function imageSize($file, $options) /** * Validates the image width. * - * @param array $file The uploaded file data from PHP. - * @param string $operator Comparision operator. + * @param mixed $file The uploaded file data from PHP. + * @param string $operator Comparison operator. * @param int $width Min or max width. * @return bool */ - public static function imageWidth($file, $operator, $width) + public static function imageWidth(mixed $file, string $operator, int $width): bool { return self::imageSize($file, [ 'width' => [ $operator, - $width - ] + $width, + ], ]); } /** - * Validates the image width. + * Validates the image height. * - * @param array $file The uploaded file data from PHP. - * @param string $operator Comparision operator. - * @param int $height Min or max width. + * @param mixed $file The uploaded file data from PHP. + * @param string $operator Comparison operator. + * @param int $height Min or max height. * @return bool */ - public static function imageHeight($file, $operator, $height) + public static function imageHeight(mixed $file, string $operator, int $height): bool { return self::imageSize($file, [ 'height' => [ $operator, - $height - ] + $height, + ], ]); } @@ -1293,20 +1679,24 @@ public static function imageHeight($file, $operator, $height) * - `format` - By default `both`, can be `long` and `lat` as well to validate * only a part of the coordinate. * - * @param string $value Geographic location as string - * @param array $options Options for the validation logic. + * @param mixed $value Geographic location as string + * @param array $options Options for the validation logic. * @return bool */ - public static function geoCoordinate($value, array $options = []) + public static function geoCoordinate(mixed $value, array $options = []): bool { + if (!is_scalar($value)) { + return false; + } + $options += [ 'format' => 'both', - 'type' => 'latLong' + 'type' => 'latLong', ]; if ($options['type'] !== 'latLong') { - throw new RuntimeException(sprintf( - 'Unsupported coordinate type "%s". Use "latLong" instead.', - $options['type'] + throw new InvalidArgumentException(sprintf( + 'Unsupported coordinate type `%s`. Use `latLong` instead.', + $options['type'], )); } $pattern = '/^' . self::$_pattern['latitude'] . ',\s*' . self::$_pattern['longitude'] . '$/'; @@ -1317,19 +1707,19 @@ public static function geoCoordinate($value, array $options = []) $pattern = '/^' . self::$_pattern['latitude'] . '$/'; } - return (bool)preg_match($pattern, $value); + return (bool)preg_match($pattern, (string)$value); } /** * Convenience method for latitude validation. * - * @param string $value Latitude as string - * @param array $options Options for the validation logic. + * @param mixed $value Latitude as string + * @param array $options Options for the validation logic. * @return bool * @link https://en.wikipedia.org/wiki/Latitude * @see \Cake\Validation\Validation::geoCoordinate() */ - public static function latitude($value, array $options = []) + public static function latitude(mixed $value, array $options = []): bool { $options['format'] = 'lat'; @@ -1339,13 +1729,13 @@ public static function latitude($value, array $options = []) /** * Convenience method for longitude validation. * - * @param string $value Latitude as string - * @param array $options Options for the validation logic. + * @param mixed $value Longitude as string + * @param array $options Options for the validation logic. * @return bool * @link https://en.wikipedia.org/wiki/Longitude * @see \Cake\Validation\Validation::geoCoordinate() */ - public static function longitude($value, array $options = []) + public static function longitude(mixed $value, array $options = []): bool { $options['format'] = 'long'; @@ -1357,10 +1747,10 @@ public static function longitude($value, array $options = []) * * This method will reject all non-string values. * - * @param string $value The value to check + * @param mixed $value The value to check * @return bool */ - public static function ascii($value) + public static function ascii(mixed $value): bool { if (!is_string($value)) { return false; @@ -1380,18 +1770,18 @@ public static function ascii($value) * MySQL's older utf8 encoding type does not allow characters above * the basic multilingual plane. Defaults to false. * - * @param string $value The value to check - * @param array $options An array of options. See above for the supported options. + * @param mixed $value The value to check + * @param array $options An array of options. See above for the supported options. * @return bool */ - public static function utf8($value, array $options = []) + public static function utf8(mixed $value, array $options = []): bool { if (!is_string($value)) { return false; } $options += ['extended' => false]; if ($options['extended']) { - return true; + return preg_match('//u', $value) === 1; } return preg_match('/[\x{10000}-\x{10FFFF}]/u', $value) === 0; @@ -1403,28 +1793,29 @@ public static function utf8($value, array $options = []) * This method will accept strings that contain only integer data * as well. * - * @param string $value The value to check + * @param mixed $value The value to check * @return bool */ - public static function isInteger($value) + public static function isInteger(mixed $value): bool { - if (!is_scalar($value) || is_float($value)) { - return false; - } if (is_int($value)) { return true; } + if (!is_string($value) || !is_numeric($value)) { + return false; + } + return (bool)preg_match('/^-?[0-9]+$/', $value); } /** * Check that the input value is an array. * - * @param array $value The value to check + * @param mixed $value The value to check * @return bool */ - public static function isArray($value) + public static function isArray(mixed $value): bool { return is_array($value); } @@ -1438,7 +1829,7 @@ public static function isArray($value) * @param mixed $value The value to check * @return bool */ - public static function isScalar($value) + public static function isScalar(mixed $value): bool { return is_scalar($value); } @@ -1446,27 +1837,69 @@ public static function isScalar($value) /** * Check that the input value is a 6 digits hex color. * - * @param string|array $check The value to check + * @param mixed $check The value to check * @return bool Success */ - public static function hexColor($check) + public static function hexColor(mixed $check): bool { return static::_check($check, '/^#[0-9a-f]{6}$/iD'); } + /** + * Check that the input value has a valid International Bank Account Number IBAN syntax + * Requirements are uppercase, no whitespaces, max length 34, country code and checksum exist at right spots, + * body matches against checksum via Mod97-10 algorithm + * + * @param mixed $check The value to check + * @return bool Success + */ + public static function iban(mixed $check): bool + { + if ( + !is_string($check) || + !preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{1,30}$/', $check) + ) { + return false; + } + + $country = substr($check, 0, 2); + $checkInt = intval(substr($check, 2, 2)); + $account = substr($check, 4); + $search = range('A', 'Z'); + $replace = []; + foreach (range(10, 35) as $tmp) { + $replace[] = strval($tmp); + } + $numStr = str_replace($search, $replace, $account . $country . '00'); + $checksum = intval(substr($numStr, 0, 1)); + $numStrLength = strlen($numStr); + for ($pos = 1; $pos < $numStrLength; $pos++) { + $checksum *= 10; + $checksum += intval(substr($numStr, $pos, 1)); + $checksum %= 97; + } + + return $checkInt === 98 - $checksum; + } + /** * Converts an array representing a date or datetime into a ISO string. * The arrays are typically sent for validation from a form generated by * the CakePHP FormHelper. * - * @param array $value The array representing a date or datetime. + * @param array $value The array representing a date or datetime. * @return string */ - protected static function _getDateString($value) + protected static function _getDateString(array $value): string { $formatted = ''; - if (isset($value['year'], $value['month'], $value['day']) && - (is_numeric($value['year']) && is_numeric($value['month']) && is_numeric($value['day'])) + if ( + isset($value['year'], $value['month'], $value['day']) && + ( + is_numeric($value['year']) && + is_numeric($value['month']) && + is_numeric($value['day']) + ) ) { $formatted .= sprintf('%d-%02d-%02d ', $value['year'], $value['month'], $value['day']); } @@ -1478,9 +1911,20 @@ protected static function _getDateString($value) if (isset($value['meridian'])) { $value['hour'] = strtolower($value['meridian']) === 'am' ? $value['hour'] : $value['hour'] + 12; } - $value += ['minute' => 0, 'second' => 0]; - if (is_numeric($value['hour']) && is_numeric($value['minute']) && is_numeric($value['second'])) { - $formatted .= sprintf('%02d:%02d:%02d', $value['hour'], $value['minute'], $value['second']); + $value += ['minute' => 0, 'second' => 0, 'microsecond' => 0]; + if ( + is_numeric($value['hour']) && + is_numeric($value['minute']) && + is_numeric($value['second']) && + is_numeric($value['microsecond']) + ) { + $formatted .= sprintf( + '%02d:%02d:%02d.%06d', + $value['hour'], + $value['minute'], + $value['second'], + $value['microsecond'], + ); } } @@ -1492,8 +1936,9 @@ protected static function _getDateString($value) * * @return void */ - protected static function _populateIp() + protected static function _populateIp(): void { + // phpcs:disable Generic.Files.LineLength if (!isset(static::$_pattern['IPv6'])) { $pattern = '((([0-9A-Fa-f]{1,4}:){7}(([0-9A-Fa-f]{1,4})|:))|(([0-9A-Fa-f]{1,4}:){6}'; $pattern .= '(:|((25[0-5]|2[0-4]\d|[01]?\d{1,2})(\.(25[0-5]|2[0-4]\d|[01]?\d{1,2})){3})'; @@ -1516,6 +1961,7 @@ protected static function _populateIp() $pattern = '(?:(?:25[0-5]|2[0-4][0-9]|(?:(?:1[0-9])?|[1-9]?)[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|(?:(?:1[0-9])?|[1-9]?)[0-9])'; static::$_pattern['IPv4'] = $pattern; } + // phpcs:enable Generic.Files.LineLength } /** @@ -1523,7 +1969,7 @@ protected static function _populateIp() * * @return void */ - protected static function _reset() + protected static function _reset(): void { static::$errors = []; } diff --git a/src/Validation/ValidationRule.php b/src/Validation/ValidationRule.php index e5dd4e5bf93..a712185440d 100644 --- a/src/Validation/ValidationRule.php +++ b/src/Validation/ValidationRule.php @@ -1,4 +1,6 @@ $validator The validator properties */ - public function __construct(array $validator = []) + public function __construct(array $validator) { $this->_addValidatorProps($validator); } @@ -86,9 +89,9 @@ public function __construct(array $validator = []) * * @return bool */ - public function isLast() + public function isLast(): bool { - return (bool)$this->_last; + return $this->_last; } /** @@ -97,19 +100,19 @@ public function isLast() * it is assumed that the rule failed and the error message was given as a result. * * @param mixed $value The data to validate - * @param array $providers associative array with objects or class names that will + * @param array $providers Associative array with objects or class names that will * be passed as the last argument for the validation method - * @param array $context A key value list of data that could be used as context + * @param array $context A key value list of data that could be used as context * during validation. Recognized keys are: - * - newRecord: (boolean) whether or not the data to be validated belongs to a + * - newRecord: (boolean) whether the data to be validated belongs to a * new record * - data: The full data that was passed to the validation process * - field: The name of the field that is being processed - * @return bool|string + * @return array|string|bool * @throws \InvalidArgumentException when the supplied rule is not a valid * callable for the configured scope */ - public function process($value, array $providers, array $context = []) + public function process(mixed $value, array $providers, array $context = []): array|string|bool { $context += ['data' => [], 'newRecord' => true, 'providers' => $providers]; @@ -117,29 +120,45 @@ public function process($value, array $providers, array $context = []) return true; } - if (!is_string($this->_rule) && is_callable($this->_rule)) { - $callable = $this->_rule; - $isCallable = true; - } else { + if (is_string($this->_rule)) { $provider = $providers[$this->_provider]; - $callable = [$provider, $this->_rule]; - $isCallable = is_callable($callable); - } + if ( + class_exists(Table::class) + && $provider instanceof Table + && !method_exists($provider, $this->_rule) + && $provider->behaviors()->hasMethod($this->_rule) + ) { + foreach ($provider->behaviors() as $behavior) { + if (in_array($this->_rule, $behavior->implementedMethods(), true)) { + $provider = $behavior; + break; + } + } + } - if (!$isCallable) { - $message = 'Unable to call method "%s" in "%s" provider for field "%s"'; - throw new InvalidArgumentException( - sprintf($message, $this->_rule, $this->_provider, $context['field']) - ); + /** @phpstan-ignore-next-line */ + $callable = [$provider, $this->_rule](...); + } else { + $callable = $this->_rule; + if (!$callable instanceof Closure) { + $callable = $callable(...); + } } + $args = [$value]; + if ($this->_pass) { - $args = array_values(array_merge([$value], $this->_pass, [$context])); - $result = $callable(...$args); - } else { - $result = $callable($value, $context); + $args = array_merge([$value], array_values($this->_pass)); + } + + $params = (new ReflectionFunction($callable))->getParameters(); + $lastParam = array_pop($params); + if ($lastParam && $lastParam->getName() === 'context') { + $args['context'] = $context; } + $result = $callable(...$args); + if ($result === false) { return $this->_message ?: false; } @@ -150,28 +169,28 @@ public function process($value, array $providers, array $context = []) /** * Checks if the validation rule should be skipped * - * @param array $context A key value list of data that could be used as context + * @param array $context A key value list of data that could be used as context * during validation. Recognized keys are: - * - newRecord: (boolean) whether or not the data to be validated belongs to a + * - newRecord: (boolean) whether the data to be validated belongs to a * new record * - data: The full data that was passed to the validation process * - providers associative array with objects or class names that will * be passed as the last argument for the validation method * @return bool True if the ValidationRule should be skipped */ - protected function _skip($context) + protected function _skip(array $context): bool { - if (!is_string($this->_on) && is_callable($this->_on)) { - $function = $this->_on; + if (is_string($this->_on)) { + $newRecord = $context['newRecord']; - return !$function($context); + return ($this->_on === Validator::WHEN_CREATE && !$newRecord) + || ($this->_on === Validator::WHEN_UPDATE && $newRecord); } - $newRecord = $context['newRecord']; - if (!empty($this->_on)) { - if (($this->_on === 'create' && !$newRecord) || ($this->_on === 'update' && $newRecord)) { - return true; - } + if ($this->_on !== null) { + $function = $this->_on; + + return !$function($context); } return false; @@ -180,21 +199,21 @@ protected function _skip($context) /** * Sets the rule properties from the rule entry in validate * - * @param array $validator [optional] + * @param array $validator [optional] * @return void */ - protected function _addValidatorProps($validator = []) + protected function _addValidatorProps(array $validator = []): void { foreach ($validator as $key => $value) { - if (!isset($value) || empty($value)) { + if (!$value) { continue; } if ($key === 'rule' && is_array($value) && !is_callable($value)) { $this->_pass = array_slice($value, 1); $value = array_shift($value); } - if (in_array($key, ['rule', 'on', 'message', 'last', 'provider', 'pass'])) { - $this->{"_$key"} = $value; + if (in_array($key, ['rule', 'on', 'message', 'last', 'provider', 'pass'], true)) { + $this->{"_{$key}"} = $value; } } } @@ -205,11 +224,10 @@ protected function _addValidatorProps($validator = []) * @param string $property The name of the property to retrieve. * @return mixed */ - public function get($property) + public function get(string $property): mixed { $property = '_' . $property; - if (isset($this->{$property})) { - return $this->{$property}; - } + + return $this->{$property} ?? null; } } diff --git a/src/Validation/ValidationSet.php b/src/Validation/ValidationSet.php index d395fcd75f3..6e8d70f533c 100644 --- a/src/Validation/ValidationSet.php +++ b/src/Validation/ValidationSet.php @@ -1,4 +1,6 @@ + * @template-implements \IteratorAggregate */ class ValidationSet implements ArrayAccess, IteratorAggregate, Countable { - /** * Holds the ValidationRule objects * - * @var \Cake\Validation\ValidationRule[] + * @var array<\Cake\Validation\ValidationRule> */ - protected $_rules = []; + protected array $_rules = []; /** - * Denotes whether the fieldname key must be present in data array + * Denotes whether the field name key must be present in data array * - * @var bool|string + * @var callable|string|bool */ protected $_validatePresent = false; /** * Denotes if a field is allowed to be empty * - * @var bool|string|callable + * @var callable|string|bool */ protected $_allowEmpty = false; /** - * Sets whether a field is required to be present in data array. + * Returns whether a field can be left out. * - * If no argument is passed the currently set `validatePresent` value will be returned. + * @return callable|string|bool + */ + public function isPresenceRequired(): callable|string|bool + { + return $this->_validatePresent; + } + + /** + * Sets whether a field is required to be present in data array. * - * @param bool|string|null $validatePresent Valid values are true, false, 'create', 'update' - * @return bool|string + * @param callable|string|bool $validatePresent Valid values are true, false, 'create', 'update' or a callable. + * @return $this */ - public function isPresenceRequired($validatePresent = null) + public function requirePresence(callable|string|bool $validatePresent) { - if ($validatePresent === null) { - return $this->_validatePresent; - } + $this->_validatePresent = $validatePresent; - return $this->_validatePresent = $validatePresent; + return $this; } /** - * Sets whether a field value is allowed to be empty + * Returns whether a field can be left empty. * - * If no argument is passed the currently set `allowEmpty` value will be returned. + * @return callable|string|bool + */ + public function isEmptyAllowed(): callable|string|bool + { + return $this->_allowEmpty; + } + + /** + * Sets whether a field value is allowed to be empty. * - * @param bool|string|callable|null $allowEmpty Valid values are true, false, - * 'create', 'update' - * @return bool|string|callable + * @param callable|string|bool $allowEmpty Valid values are true, false, + * 'create', 'update' or a callable. + * @return $this */ - public function isEmptyAllowed($allowEmpty = null) + public function allowEmpty(callable|string|bool $allowEmpty) { - if ($allowEmpty === null) { - return $this->_allowEmpty; - } + $this->_allowEmpty = $allowEmpty; - return $this->_allowEmpty = $allowEmpty; + return $this; } /** * Gets a rule for a given name if exists * * @param string $name The name under which the rule is set. - * @return \Cake\Validation\ValidationRule + * @return \Cake\Validation\ValidationRule|null */ - public function rule($name) + public function rule(string $name): ?ValidationRule { - if (!empty($this->_rules[$name])) { - return $this->_rules[$name]; + if (empty($this->_rules[$name])) { + return null; } + + return $this->_rules[$name]; } /** * Returns all rules for this validation set * - * @return \Cake\Validation\ValidationRule[] + * @return array<\Cake\Validation\ValidationRule> */ - public function rules() + public function rules(): array { return $this->_rules; } + /** + * Returns whether a validation rule with the given name exists in this set. + * + * @param string $name The name to check + * @return bool + */ + public function has(string $name): bool + { + return array_key_exists($name, $this->_rules); + } + /** * Sets a ValidationRule $rule with a $name * @@ -119,12 +150,16 @@ public function rules() * @param string $name The name under which the rule should be set * @param \Cake\Validation\ValidationRule|array $rule The validation rule to be set * @return $this + * @throws \Cake\Core\Exception\CakeException If a rule with the same name already exists */ - public function add($name, $rule) + public function add(string $name, ValidationRule|array $rule) { if (!($rule instanceof ValidationRule)) { $rule = new ValidationRule($rule); } + if (array_key_exists($name, $this->_rules)) { + throw new CakeException("A validation rule with the name `{$name}` already exists"); + } $this->_rules[$name] = $rule; return $this; @@ -144,7 +179,7 @@ public function add($name, $rule) * @param string $name The name under which the rule should be unset * @return $this */ - public function remove($name) + public function remove(string $name) { unset($this->_rules[$name]); @@ -157,7 +192,7 @@ public function remove($name) * @param string $index name of the rule * @return bool */ - public function offsetExists($index) + public function offsetExists(mixed $index): bool { return isset($this->_rules[$index]); } @@ -168,7 +203,7 @@ public function offsetExists($index) * @param string $index name of the rule * @return \Cake\Validation\ValidationRule */ - public function offsetGet($index) + public function offsetGet(mixed $index): ValidationRule { return $this->_rules[$index]; } @@ -176,13 +211,13 @@ public function offsetGet($index) /** * Sets or replace a validation rule * - * @param string $index name of the rule - * @param \Cake\Validation\ValidationRule|array $rule Rule to add to $index + * @param string $offset name of the rule + * @param \Cake\Validation\ValidationRule|array $value Rule to add to $index * @return void */ - public function offsetSet($index, $rule) + public function offsetSet(mixed $offset, mixed $value): void { - $this->add($index, $rule); + $this->add($offset, $value); } /** @@ -191,7 +226,7 @@ public function offsetSet($index, $rule) * @param string $index name of the rule * @return void */ - public function offsetUnset($index) + public function offsetUnset(mixed $index): void { unset($this->_rules[$index]); } @@ -199,9 +234,9 @@ public function offsetUnset($index) /** * Returns an iterator for each of the rules to be applied * - * @return \ArrayIterator + * @return \Traversable */ - public function getIterator() + public function getIterator(): Traversable { return new ArrayIterator($this->_rules); } @@ -211,7 +246,7 @@ public function getIterator() * * @return int */ - public function count() + public function count(): int { return count($this->_rules); } diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php index f80bd591ea2..e407236b2ba 100644 --- a/src/Validation/Validator.php +++ b/src/Validation/Validator.php @@ -1,4 +1,6 @@ + * @template-implements \IteratorAggregate */ class Validator implements ArrayAccess, IteratorAggregate, Countable { + /** + * By using 'create' you can make fields required when records are first created. + * + * @var string + */ + public const WHEN_CREATE = 'create'; + + /** + * By using 'update', you can make fields required when they are updated. + * + * @var string + */ + public const WHEN_UPDATE = 'update'; + /** * Used to flag nested rules created with addNested() and addNestedMany() * * @var string */ - const NESTED = '_nested'; + public const NESTED = '_nested'; + + /** + * A flag for allowEmptyFor() + * + * When `null` is given, it will be recognized as empty. + * + * @var int + */ + public const EMPTY_NULL = 0; + + /** + * A flag for allowEmptyFor() + * + * When an empty string is given, it will be recognized as empty. + * + * @var int + */ + public const EMPTY_STRING = 1; + + /** + * A flag for allowEmptyFor() + * + * When an empty array is given, it will be recognized as empty. + * + * @var int + */ + public const EMPTY_ARRAY = 2; + + /** + * A flag for allowEmptyFor() + * + * The return value of \Psr\Http\Message\UploadedFileInterface::getError() + * method must be equal to `UPLOAD_ERR_NO_FILE`. + * + * @var int + */ + public const EMPTY_FILE = 4; + + /** + * A flag for allowEmptyFor() + * + * When an array is given, if it contains the `year` key, and only empty strings + * or null values, it will be recognized as empty. + * + * @var int + */ + public const EMPTY_DATE = 8; + + /** + * A flag for allowEmptyFor() + * + * When an array is given, if it contains the `hour` key, and only empty strings + * or null values, it will be recognized as empty. + * + * @var int + */ + public const EMPTY_TIME = 16; + + /** + * A combination of the all EMPTY_* flags + * + * @var int + */ + public const EMPTY_ALL = self::EMPTY_STRING + | self::EMPTY_ARRAY + | self::EMPTY_FILE + | self::EMPTY_DATE + | self::EMPTY_TIME; /** * Holds the ValidationSet objects array * - * @var array + * @var array */ - protected $_fields = []; + protected array $_fields = []; /** * An associative array of objects or classes containing methods * used for validation * - * @var array + * @var array */ - protected $_providers = []; + protected array $_providers = []; /** * An associative array of objects or classes used as a default provider list * - * @var array + * @var array */ - protected static $_defaultProviders = []; + protected static array $_defaultProviders = []; /** * Contains the validation messages associated with checking the presence * for each corresponding field. * - * @var array + * @var array */ - protected $_presenceMessages = []; + protected array $_presenceMessages = []; /** - * Whether or not to use I18n functions for translating default error messages + * Whether to use I18n functions for translating default error messages * * @var bool */ - protected $_useI18n = false; + protected bool $_useI18n; /** * Contains the validation messages associated with checking the emptiness * for each corresponding field. * - * @var array + * @var array */ - protected $_allowEmptyMessages = []; + protected array $_allowEmptyMessages = []; /** - * Constructor + * Contains the flags which specify what is empty for each corresponding field. * + * @var array + */ + protected array $_allowEmptyFlags = []; + + /** + * Whether to apply last flag to generated rule(s). + * + * @var bool + */ + protected bool $_stopOnFailure = false; + + /** + * Constructor */ public function __construct() { - $this->_useI18n = function_exists('__d'); + $this->_useI18n ??= function_exists('\Cake\I18n\__d'); $this->_providers = self::$_defaultProviders; + $this->_providers['default'] ??= Validation::class; } /** - * Returns an array of fields that have failed validation. On the current model. This method will - * actually run validation rules over data, not just return the messages. + * Whether to stop validation rule evaluation on the first failed rule. * - * @param array $data The data to be checked for errors - * @param bool $newRecord whether the data to be validated is new or to be updated. - * @return array Array of invalid fields + * When enabled, the first failing rule per field will cause validation to stop. + * When disabled, all rules will be run even if there are failures. + * + * @param bool $stopOnFailure If to apply last flag. + * @return $this */ - public function errors(array $data, $newRecord = true) + public function setStopOnFailure(bool $stopOnFailure = true) { - $errors = []; + $this->_stopOnFailure = $stopOnFailure; - $requiredMessage = 'This field is required'; - $emptyMessage = 'This field cannot be left empty'; + return $this; + } - if ($this->_useI18n) { - $requiredMessage = __d('cake', 'This field is required'); - $emptyMessage = __d('cake', 'This field cannot be left empty'); - } + /** + * Validates and returns an array of failed fields and their error messages. + * + * @param array $data The data to be checked for errors. + * Keys are field names, values are the field values to validate. + * @param bool $newRecord Whether the data to be validated is new or to be updated. + * @param array $context Additional validation context. + * @return array> Array of validation errors. + * Outer keys are field names, inner keys are validation rule names, + * values are error messages. When using `addNested()` or `addNestedMany()`, + * values may be nested error arrays. Special rule names: '_required', '_empty'. + */ + public function validate(array $data, bool $newRecord = true, array $context = []): array + { + $errors = []; foreach ($this->_fields as $name => $field) { + if (!empty($context['fields']) && !in_array($name, $context['fields'], true)) { + continue; + } + + $name = (string)$name; $keyPresent = array_key_exists($name, $data); $providers = $this->_providers; - $context = compact('data', 'newRecord', 'field', 'providers'); + $context = compact('data', 'newRecord', 'field', 'providers') + $context; if (!$keyPresent && !$this->_checkPresence($field, $context)) { - $errors[$name]['_required'] = isset($this->_presenceMessages[$name]) - ? $this->_presenceMessages[$name] - : $requiredMessage; + $errors[$name]['_required'] = $this->getRequiredMessage($name); continue; } if (!$keyPresent) { @@ -129,12 +249,16 @@ public function errors(array $data, $newRecord = true) } $canBeEmpty = $this->_canBeEmpty($field, $context); - $isEmpty = $this->_fieldIsEmpty($data[$name]); + + $flags = static::EMPTY_NULL; + if (isset($this->_allowEmptyFlags[$name])) { + $flags = $this->_allowEmptyFlags[$name]; + } + + $isEmpty = $this->isEmpty($data[$name], $flags); if (!$canBeEmpty && $isEmpty) { - $errors[$name]['_empty'] = isset($this->_allowEmptyMessages[$name]) - ? $this->_allowEmptyMessages[$name] - : $emptyMessage; + $errors[$name]['_empty'] = $this->getNotEmptyMessage($name); continue; } @@ -142,7 +266,7 @@ public function errors(array $data, $newRecord = true) continue; } - $result = $this->_processRules($name, $field, $data, $newRecord); + $result = $this->_processRules($name, $field, $data, $newRecord, $context); if ($result) { $errors[$name] = $result; } @@ -156,13 +280,13 @@ public function errors(array $data, $newRecord = true) * passed a ValidationSet as second argument, it will replace any other rule set defined * before * - * @param string $name [optional] The fieldname to fetch. + * @param string $name [optional] The field name to fetch. * @param \Cake\Validation\ValidationSet|null $set The set of rules for field * @return \Cake\Validation\ValidationSet */ - public function field($name, ValidationSet $set = null) + public function field(string $name, ?ValidationSet $set = null): ValidationSet { - if (empty($this->_fields[$name])) { + if (!isset($this->_fields[$name])) { $set = $set ?: new ValidationSet(); $this->_fields[$name] = $set; } @@ -171,12 +295,12 @@ public function field($name, ValidationSet $set = null) } /** - * Check whether or not a validator contains any rules for the given field. + * Check whether a validator contains any rules for the given field. * * @param string $name The field name to check. * @return bool */ - public function hasField($name) + public function hasField(string $name): bool { return isset($this->_fields[$name]); } @@ -188,10 +312,10 @@ public function hasField($name) * when called will receive the full list of providers stored in this validator. * * @param string $name The name under which the provider should be set. - * @param object|string $object Provider object or class name. + * @param object|class-string $object Provider object or class name. * @return $this */ - public function setProvider($name, $object) + public function setProvider(string $name, object|string $object) { $this->_providers[$name] = $object; @@ -202,45 +326,32 @@ public function setProvider($name, $object) * Returns the provider stored under that name if it exists. * * @param string $name The name under which the provider should be set. - * @return object|string|null + * @return object|class-string|null */ - public function getProvider($name) + public function getProvider(string $name): object|string|null { - if (isset($this->_providers[$name])) { - return $this->_providers[$name]; - } - if ($name !== 'default') { - return null; - } - - $this->_providers[$name] = new RulesProvider(); - - return $this->_providers[$name]; + return $this->_providers[$name] ?? null; } /** * Returns the default provider stored under that name if it exists. * * @param string $name The name under which the provider should be retrieved. - * @return object|string|null + * @return object|class-string|null */ - public static function getDefaultProvider($name) + public static function getDefaultProvider(string $name): object|string|null { - if (!isset(self::$_defaultProviders[$name])) { - return null; - } - - return self::$_defaultProviders[$name]; + return self::$_defaultProviders[$name] ?? null; } /** * Associates an object to a name so it can be used as a default provider. * * @param string $name The name under which the provider should be set. - * @param object|string $object Provider object or class name. + * @param object|class-string $object Provider object or class name. * @return void */ - public static function addDefaultProvider($name, $object) + public static function addDefaultProvider(string $name, object|string $object): void { self::$_defaultProviders[$name] = $object; } @@ -248,42 +359,19 @@ public static function addDefaultProvider($name, $object) /** * Get the list of default providers. * - * @return array + * @return array */ - public static function getDefaultProviders() + public static function getDefaultProviders(): array { return array_keys(self::$_defaultProviders); } - /** - * Associates an object to a name so it can be used as a provider. Providers are - * objects or class names that can contain methods used during validation of for - * deciding whether a validation rule can be applied. All validation methods, - * when called will receive the full list of providers stored in this validator. - * - * If called with no arguments, it will return the provider stored under that name if - * it exists, otherwise it returns this instance of chaining. - * - * @deprecated 3.4.0 Use setProvider()/getProvider() instead. - * @param string $name The name under which the provider should be set. - * @param null|object|string $object Provider object or class name. - * @return $this|object|string|null - */ - public function provider($name, $object = null) - { - if ($object !== null) { - return $this->setProvider($name, $object); - } - - return $this->getProvider($name); - } - /** * Get the list of providers in this validator. * - * @return array + * @return array */ - public function providers() + public function providers(): array { return array_keys($this->_providers); } @@ -294,7 +382,7 @@ public function providers() * @param string $field name of the field to check * @return bool */ - public function offsetExists($field) + public function offsetExists(mixed $field): bool { return isset($this->_fields[$field]); } @@ -302,30 +390,31 @@ public function offsetExists($field) /** * Returns the rule set for a field * - * @param string $field name of the field to check + * @param string|int $field name of the field to check * @return \Cake\Validation\ValidationSet */ - public function offsetGet($field) + public function offsetGet(mixed $field): ValidationSet { - return $this->field($field); + return $this->field((string)$field); } /** * Sets the rule set for a field * - * @param string $field name of the field to set - * @param array|\Cake\Validation\ValidationSet $rules set of rules to apply to field + * @param string $offset name of the field to set + * @param \Cake\Validation\ValidationSet|array $value set of rules to apply to field * @return void */ - public function offsetSet($field, $rules) + public function offsetSet(mixed $offset, mixed $value): void { - if (!$rules instanceof ValidationSet) { + if (!$value instanceof ValidationSet) { $set = new ValidationSet(); - foreach ((array)$rules as $name => $rule) { + foreach ($value as $name => $rule) { $set->add($name, $rule); } + $value = $set; } - $this->_fields[$field] = $rules; + $this->_fields[$offset] = $value; } /** @@ -334,7 +423,7 @@ public function offsetSet($field, $rules) * @param string $field name of the field to unset * @return void */ - public function offsetUnset($field) + public function offsetUnset(mixed $field): void { unset($this->_fields[$field]); } @@ -342,9 +431,9 @@ public function offsetUnset($field) /** * Returns an iterator for each of the fields to be validated * - * @return \ArrayIterator + * @return \Traversable */ - public function getIterator() + public function getIterator(): Traversable { return new ArrayIterator($this->_fields); } @@ -354,7 +443,7 @@ public function getIterator() * * @return int */ - public function count() + public function count(): int { return count($this->_fields); } @@ -379,12 +468,13 @@ public function count() * * @param string $field The name of the field from which the rule will be added * @param array|string $name The alias for a single rule or multiple rules array - * @param array|\Cake\Validation\ValidationRule $rule the rule to add + * @param \Cake\Validation\ValidationRule|array $rule the rule to add + * @throws \InvalidArgumentException If numeric index cannot be resolved to a string one * @return $this */ - public function add($field, $name, $rule = []) + public function add(string $field, array|string $name, ValidationRule|array $rule = []) { - $field = $this->field($field); + $validationSet = $this->field($field); if (!is_array($name)) { $rules = [$name => $rule]; @@ -393,7 +483,19 @@ public function add($field, $name, $rule = []) } foreach ($rules as $name => $rule) { - $field->add($name, $rule); + if (is_array($rule)) { + $rule += [ + 'rule' => $name, + 'last' => $this->_stopOnFailure, + ]; + } + if (!is_string($name)) { + throw new InvalidArgumentException( + 'You cannot add validation rules without a `name` key. Update rules array to have string keys.', + ); + } + + $validationSet->add($name, $rule); } return $this; @@ -414,21 +516,34 @@ public function add($field, $name, $rule = []) * * @param string $field The root field for the nested validator. * @param \Cake\Validation\Validator $validator The nested validator. + * @param string|null $message The error message when the rule fails. + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns + * true when the validation rule should be applied. * @return $this */ - public function addNested($field, Validator $validator) - { - $field = $this->field($field); - $field->add(static::NESTED, ['rule' => function ($value, $context) use ($validator) { + public function addNested( + string $field, + Validator $validator, + ?string $message = null, + Closure|string|null $when = null, + ) { + $extra = array_filter(['message' => $message, 'on' => $when]); + + $validationSet = $this->field($field); + $validationSet->add(static::NESTED, $extra + ['rule' => function ($value, $context) use ($validator, $message) { if (!is_array($value)) { return false; } - foreach ($this->providers() as $provider) { - $validator->setProvider($provider, $this->getProvider($provider)); + foreach ($this->providers() as $name) { + /** @var object|class-string $provider */ + $provider = $this->getProvider($name); + $validator->setProvider($name, $provider); } - $errors = $validator->errors($value, $context['newRecord']); + $errors = $validator->validate($value, $context['newRecord'], ['parentContext' => $context]); - return empty($errors) ? true : $errors; + $message = $message ? [static::NESTED => $message] : []; + + return $errors === [] ? true : $errors + $message; }]); return $this; @@ -449,30 +564,47 @@ public function addNested($field, Validator $validator) * * @param string $field The root field for the nested validator. * @param \Cake\Validation\Validator $validator The nested validator. + * @param string|null $message The error message when the rule fails. + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns + * true when the validation rule should be applied. * @return $this */ - public function addNestedMany($field, Validator $validator) - { - $field = $this->field($field); - $field->add(static::NESTED, ['rule' => function ($value, $context) use ($validator) { + public function addNestedMany( + string $field, + Validator $validator, + ?string $message = null, + Closure|string|null $when = null, + ) { + $extra = array_filter(['message' => $message, 'on' => $when]); + + $validationSet = $this->field($field); + $validationSet->add(static::NESTED, $extra + ['rule' => function ($value, $context) use ($validator, $message) { if (!is_array($value)) { return false; } - foreach ($this->providers() as $provider) { - $validator->setProvider($provider, $this->getProvider($provider)); + foreach ($this->providers() as $name) { + /** @var object|class-string $provider */ + $provider = $this->getProvider($name); + $validator->setProvider($name, $provider); } $errors = []; foreach ($value as $i => $row) { if (!is_array($row)) { return false; } - $check = $validator->errors($row, $context['newRecord']); - if (!empty($check)) { + $check = $validator->validate( + $row, + $context['newRecord'], + ['parentContext' => $context, 'nestedManyIndex' => $i], + ); + if ($check) { $errors[$i] = $check; } } - return empty($errors) ? true : $errors; + $message = $message ? [static::NESTED => $message] : []; + + return $errors === [] ? true : $errors + $message; }]); return $this; @@ -493,7 +625,7 @@ public function addNestedMany($field, Validator $validator) * @param string|null $rule the name of the rule to be removed * @return $this */ - public function remove($field, $rule = null) + public function remove(string $field, ?string $rule = null) { if ($rule === null) { unset($this->_fields[$field]); @@ -515,29 +647,30 @@ public function remove($field, $rule = null) * You can also set mode and message for all passed fields, the individual * setting takes precedence over group settings. * - * @param string|array $field the name of the field or list of fields. - * @param bool|string|callable $mode Valid values are true, false, 'create', 'update'. - * If a callable is passed then the field will be required only when the callback + * @param array|string $field the name of the field or list of fields. + * @param \Closure|string|bool $mode Valid values are true, false, 'create', 'update'. + * If a Closure is passed then the field will be required only when the callback * returns true. * @param string|null $message The message to show if the field presence validation fails. * @return $this */ - public function requirePresence($field, $mode = true, $message = null) + public function requirePresence(array|string $field, Closure|string|bool $mode = true, ?string $message = null) { $defaults = [ 'mode' => $mode, - 'message' => $message + 'message' => $message, ]; - if (!is_array($field)) { + if (is_string($field)) { $field = $this->_convertValidatorToArray($field, $defaults); } foreach ($field as $fieldName => $setting) { - $settings = $this->_convertValidatorToArray($fieldName, $defaults, $setting); + $settings = $this->_convertValidatorToArray((string)$fieldName, $defaults, $setting); + /** @var string $fieldName */ $fieldName = current(array_keys($settings)); - $this->field($fieldName)->isPresenceRequired($settings[$fieldName]['mode']); + $this->field((string)$fieldName)->requirePresence($settings[$fieldName]['mode']); if ($settings[$fieldName]['message']) { $this->_presenceMessages[$fieldName] = $settings[$fieldName]['message']; } @@ -547,47 +680,34 @@ public function requirePresence($field, $mode = true, $message = null) } /** - * Allows a field to be empty. You can also pass array. - * Using an array will let you provide the following keys: + * Low-level method to indicate that a field can be empty. * - * - `when` individual when condition for field - * - 'message' individual message for field + * This method should generally not be used, and instead you should + * use: * - * You can also set when and message for all passed fields, the individual setting - * takes precedence over group settings. + * - `allowEmptyString()` + * - `allowEmptyArray()` + * - `allowEmptyFile()` + * - `allowEmptyDate()` + * - `allowEmptyDatetime()` + * - `allowEmptyTime()` * - * This is the opposite of notEmpty() which requires a field to not be empty. - * By using $mode equal to 'create' or 'update', you can allow fields to be empty - * when records are first created, or when they are updated. + * Should be used as their APIs are simpler to operate and read. + * + * You can also set flags, when and message for all passed fields, the individual + * setting takes precedence over group settings. * * ### Example: * * ``` * // Email can be empty - * $validator->allowEmpty('email'); + * $validator->allowEmptyFor('email', Validator::EMPTY_STRING); * * // Email can be empty on create - * $validator->allowEmpty('email', 'create'); + * $validator->allowEmptyFor('email', Validator::EMPTY_STRING, Validator::WHEN_CREATE); * * // Email can be empty on update - * $validator->allowEmpty('email', 'update'); - * - * // Email and subject can be empty on update - * $validator->allowEmpty(['email', 'subject'], 'update'); - * - * // Email can be always empty, subject and content can be empty on update. - * $validator->allowEmpty( - * [ - * 'email' => [ - * 'when' => true - * ], - * 'content' => [ - * 'message' => 'Content cannot be empty' - * ], - * 'subject' - * ], - * 'update' - * ); + * $validator->allowEmptyFor('email', Validator::EMPTY_STRING, Validator::WHEN_UPDATE); * ``` * * It is possible to conditionally allow emptiness on a field by passing a callback @@ -595,168 +715,350 @@ public function requirePresence($field, $mode = true, $message = null) * argument: * * ``` - * $validator->allowEmpty('email', function ($context) { - * return !$context['newRecord'] || $context['data']['role'] === 'admin'; + * $validator->allowEmpty('email', Validator::EMPTY_STRING, function ($context) { + * return !$context['newRecord'] || $context['data']['role'] === 'admin'; * }); * ``` * - * This method will correctly detect empty file uploads and date/time/datetime fields. + * If you want to allow other kind of empty data on a field, you need to pass other + * flags: * - * Because this and `notEmpty()` modify the same internal state, the last - * method called will take precedence. + * ``` + * $validator->allowEmptyFor('photo', Validator::EMPTY_FILE); + * $validator->allowEmptyFor('published', Validator::EMPTY_STRING | Validator::EMPTY_DATE | Validator::EMPTY_TIME); + * $validator->allowEmptyFor('items', Validator::EMPTY_STRING | Validator::EMPTY_ARRAY); + * ``` + * + * You can also use convenience wrappers of this method. The following calls are the + * same as above: + * + * ``` + * $validator->allowEmptyFile('photo'); + * $validator->allowEmptyDateTime('published'); + * $validator->allowEmptyArray('items'); + * ``` * - * @param string|array $field the name of the field or a list of fields - * @param bool|string|callable $when Indicates when the field is allowed to be empty - * Valid values are true (always), 'create', 'update'. If a callable is passed then + * @param string $field The name of the field. + * @param int|null $flags A bitmask of EMPTY_* flags which specify what is empty. + * If no flags/bitmask is provided only `null` will be allowed as empty value. + * @param \Closure|string|bool $when Indicates when the field is allowed to be empty + * Valid values are true, false, 'create', 'update'. If a Closure is passed then * the field will allowed to be empty only when the callback returns true. * @param string|null $message The message to show if the field is not + * @since 3.7.0 + * @return $this + */ + public function allowEmptyFor( + string $field, + ?int $flags = null, + Closure|string|bool $when = true, + ?string $message = null, + ) { + $this->field($field)->allowEmpty($when); + if ($message) { + $this->_allowEmptyMessages[$field] = $message; + } + if ($flags !== null) { + $this->_allowEmptyFlags[$field] = $flags; + } + + return $this; + } + + /** + * Allows a field to be an empty string. + * + * This method is equivalent to calling allowEmptyFor() with EMPTY_STRING flag. + * + * @param string $field The name of the field. + * @param string|null $message The message to show if the field is not + * @param \Closure|string|bool $when Indicates when the field is allowed to be empty + * Valid values are true, false, 'create', 'update'. If a Closure is passed then + * the field will allowed to be empty only when the callback returns true. * @return $this + * @see \Cake\Validation\Validator::allowEmptyFor() For detail usage */ - public function allowEmpty($field, $when = true, $message = null) + public function allowEmptyString(string $field, ?string $message = null, Closure|string|bool $when = true) { - $settingsDefault = [ - 'when' => $when, - 'message' => $message - ]; + return $this->allowEmptyFor($field, self::EMPTY_STRING, $when, $message); + } - if (!is_array($field)) { - $field = $this->_convertValidatorToArray($field, $settingsDefault); - } + /** + * Requires a field to not be an empty string. + * + * Opposite to allowEmptyString() + * + * @param string $field The name of the field. + * @param string|null $message The message to show if the field is empty. + * @param \Closure|string|bool $when Indicates when the field is not allowed + * to be empty. Valid values are false (never), 'create', 'update'. If a + * Closure is passed then the field will be required to be not empty when + * the callback returns true. + * @return $this + * @see \Cake\Validation\Validator::allowEmptyString() + * @since 3.8.0 + */ + public function notEmptyString(string $field, ?string $message = null, Closure|string|bool $when = false) + { + $when = $this->invertWhenClause($when); - foreach ($field as $fieldName => $setting) { - $settings = $this->_convertValidatorToArray($fieldName, $settingsDefault, $setting); - $fieldName = current(array_keys($settings)); + return $this->allowEmptyFor($field, self::EMPTY_STRING, $when, $message); + } - $this->field($fieldName)->isEmptyAllowed($settings[$fieldName]['when']); - if ($settings[$fieldName]['message']) { - $this->_allowEmptyMessages[$fieldName] = $settings[$fieldName]['message']; - } - } + /** + * Allows a field to be an empty array. + * + * This method is equivalent to calling allowEmptyFor() with EMPTY_STRING + + * EMPTY_ARRAY flags. + * + * @param string $field The name of the field. + * @param string|null $message The message to show if the field is not + * @param \Closure|string|bool $when Indicates when the field is allowed to be empty + * Valid values are true, false, 'create', 'update'. If a Closure is passed then + * the field will allowed to be empty only when the callback returns true. + * @return $this + * @since 3.7.0 + * @see \Cake\Validation\Validator::allowEmptyFor() for examples. + */ + public function allowEmptyArray(string $field, ?string $message = null, Closure|string|bool $when = true) + { + return $this->allowEmptyFor($field, self::EMPTY_STRING | self::EMPTY_ARRAY, $when, $message); + } - return $this; + /** + * Require a field to be a non-empty array + * + * Opposite to allowEmptyArray() + * + * @param string $field The name of the field. + * @param string|null $message The message to show if the field is empty. + * @param \Closure|string|bool $when Indicates when the field is not allowed + * to be empty. Valid values are false (never), 'create', 'update'. If a + * Closure is passed then the field will be required to be not empty when + * the callback returns true. + * @return $this + * @see \Cake\Validation\Validator::allowEmptyArray() + */ + public function notEmptyArray(string $field, ?string $message = null, Closure|string|bool $when = false) + { + $when = $this->invertWhenClause($when); + + return $this->allowEmptyFor($field, self::EMPTY_STRING | self::EMPTY_ARRAY, $when, $message); } /** - * Converts validator to fieldName => $settings array + * Allows a field to be an empty file. + * + * This method is equivalent to calling allowEmptyFor() with EMPTY_FILE flag. + * File fields will not accept `''`, or `[]` as empty values. Only `null` and a file + * upload with `error` equal to `UPLOAD_ERR_NO_FILE` will be treated as empty. * - * @param int|string $fieldName name of field - * @param array $defaults default settings - * @param string|array $settings settings from data - * @return array + * @param string $field The name of the field. + * @param string|null $message The message to show if the field is not + * @param \Closure|string|bool $when Indicates when the field is allowed to be empty + * Valid values are true, 'create', 'update'. If a Closure is passed then + * the field will allowed to be empty only when the callback returns true. + * @return $this + * @since 3.7.0 + * @see \Cake\Validation\Validator::allowEmptyFor() For detail usage */ - protected function _convertValidatorToArray($fieldName, $defaults = [], $settings = []) + public function allowEmptyFile(string $field, ?string $message = null, Closure|string|bool $when = true) { - if (is_string($settings)) { - $fieldName = $settings; - $settings = []; - } - if (!is_array($settings)) { - throw new InvalidArgumentException( - sprintf('Invalid settings for "%s". Settings must be an array.', $fieldName) - ); - } - $settings += $defaults; + return $this->allowEmptyFor($field, self::EMPTY_FILE, $when, $message); + } - return [$fieldName => $settings]; + /** + * Require a field to be a not-empty file. + * + * Opposite to allowEmptyFile() + * + * @param string $field The name of the field. + * @param string|null $message The message to show if the field is empty. + * @param \Closure|string|bool $when Indicates when the field is not allowed + * to be empty. Valid values are false (never), 'create', 'update'. If a + * Closure is passed then the field will be required to be not empty when + * the callback returns true. + * @return $this + * @since 3.8.0 + * @see \Cake\Validation\Validator::allowEmptyFile() + */ + public function notEmptyFile(string $field, ?string $message = null, Closure|string|bool $when = false) + { + $when = $this->invertWhenClause($when); + + return $this->allowEmptyFor($field, self::EMPTY_FILE, $when, $message); } /** - * Sets a field to require a non-empty value. You can also pass array. - * Using an array will let you provide the following keys: + * Allows a field to be an empty date. * - * - `when` individual when condition for field - * - `message` individual error message for field + * Empty date values are `null`, `''`, `[]` and arrays where all values are `''` + * and the `year` key is present. * - * You can also set `when` and `message` for all passed fields, the individual setting - * takes precedence over group settings. + * @param string $field The name of the field. + * @param string|null $message The message to show if the field is not + * @param \Closure|string|bool $when Indicates when the field is allowed to be empty + * Valid values are true, false, 'create', 'update'. If a Closure is passed then + * the field will allowed to be empty only when the callback returns true. + * @return $this + * @see \Cake\Validation\Validator::allowEmptyFor() for examples + */ + public function allowEmptyDate(string $field, ?string $message = null, Closure|string|bool $when = true) + { + return $this->allowEmptyFor($field, self::EMPTY_STRING | self::EMPTY_DATE, $when, $message); + } + + /** + * Require a non-empty date value * - * This is the opposite of `allowEmpty()` which allows a field to be empty. - * By using $mode equal to 'create' or 'update', you can make fields required - * when records are first created, or when they are updated. + * @param string $field The name of the field. + * @param string|null $message The message to show if the field is empty. + * @param \Closure|string|bool $when Indicates when the field is not allowed + * to be empty. Valid values are false (never), 'create', 'update'. If a + * Closure is passed then the field will be required to be not empty when + * the callback returns true. + * @return $this + * @see \Cake\Validation\Validator::allowEmptyDate() for examples + */ + public function notEmptyDate(string $field, ?string $message = null, Closure|string|bool $when = false) + { + $when = $this->invertWhenClause($when); + + return $this->allowEmptyFor($field, self::EMPTY_STRING | self::EMPTY_DATE, $when, $message); + } + + /** + * Allows a field to be an empty time. * - * ### Example: + * Empty date values are `null`, `''`, `[]` and arrays where all values are `''` + * and the `hour` key is present. * - * ``` - * $message = 'This field cannot be empty'; - * - * // Email cannot be empty - * $validator->notEmpty('email'); - * - * // Email can be empty on update, but not create - * $validator->notEmpty('email', $message, 'create'); - * - * // Email can be empty on create, but required on update. - * $validator->notEmpty('email', $message, 'update'); - * - * // Email and title can be empty on create, but are required on update. - * $validator->notEmpty(['email', 'title'], $message, 'update'); - * - * // Email can be empty on create, title must always be not empty - * $validator->notEmpty( - * [ - * 'email', - * 'title' => [ - * 'when' => true, - * 'message' => 'Title cannot be empty' - * ] - * ], - * $message, - * 'update' - * ); - * ``` + * This method is equivalent to calling allowEmptyFor() with EMPTY_STRING + + * EMPTY_TIME flags. * - * It is possible to conditionally disallow emptiness on a field by passing a callback - * as the third argument. The callback will receive the validation context array as - * argument: + * @param string $field The name of the field. + * @param string|null $message The message to show if the field is not + * @param \Closure|string|bool $when Indicates when the field is allowed to be empty + * Valid values are true, false, 'create', 'update'. If a Closure is passed then + * the field will allowed to be empty only when the callback returns true. + * @return $this + * @since 3.7.0 + * @see \Cake\Validation\Validator::allowEmptyFor() for examples. + */ + public function allowEmptyTime(string $field, ?string $message = null, Closure|string|bool $when = true) + { + return $this->allowEmptyFor($field, self::EMPTY_STRING | self::EMPTY_TIME, $when, $message); + } + + /** + * Require a field to be a non-empty time. * - * ``` - * $validator->notEmpty('email', 'Email is required', function ($context) { - * return $context['newRecord'] && $context['data']['role'] !== 'admin'; - * }); - * ``` + * Opposite to allowEmptyTime() * - * Because this and `allowEmpty()` modify the same internal state, the last - * method called will take precedence. + * @param string $field The name of the field. + * @param string|null $message The message to show if the field is empty. + * @param \Closure|string|bool $when Indicates when the field is not allowed + * to be empty. Valid values are false (never), 'create', 'update'. If a + * Closure is passed then the field will be required to be not empty when + * the callback returns true. + * @return $this + * @since 3.8.0 + * @see \Cake\Validation\Validator::allowEmptyTime() + */ + public function notEmptyTime(string $field, ?string $message = null, Closure|string|bool $when = false) + { + $when = $this->invertWhenClause($when); + + return $this->allowEmptyFor($field, self::EMPTY_STRING | self::EMPTY_TIME, $when, $message); + } + + /** + * Allows a field to be an empty date/time. + * + * Empty date values are `null`, `''`, `[]` and arrays where all values are `''` + * and the `year` and `hour` keys are present. * - * @param string|array $field the name of the field or list of fields + * This method is equivalent to calling allowEmptyFor() with EMPTY_STRING + + * EMPTY_DATE + EMPTY_TIME flags. + * + * @param string $field The name of the field. * @param string|null $message The message to show if the field is not - * @param bool|string|callable $when Indicates when the field is not allowed - * to be empty. Valid values are true (always), 'create', 'update'. If a - * callable is passed then the field will allowed to be empty only when - * the callback returns false. + * @param \Closure|string|bool $when Indicates when the field is allowed to be empty + * Valid values are true, false, 'create', 'update'. If a Closure is passed then + * the field will allowed to be empty only when the callback returns false. * @return $this + * @since 3.7.0 + * @see \Cake\Validation\Validator::allowEmptyFor() for examples. */ - public function notEmpty($field, $message = null, $when = false) + public function allowEmptyDateTime(string $field, ?string $message = null, Closure|string|bool $when = true) { - $defaults = [ - 'when' => $when, - 'message' => $message - ]; + return $this->allowEmptyFor($field, self::EMPTY_STRING | self::EMPTY_DATE | self::EMPTY_TIME, $when, $message); + } - if (!is_array($field)) { - $field = $this->_convertValidatorToArray($field, $defaults); - } + /** + * Require a field to be a non empty date/time. + * + * Opposite to allowEmptyDateTime + * + * @param string $field The name of the field. + * @param string|null $message The message to show if the field is empty. + * @param \Closure|string|bool $when Indicates when the field is not allowed + * to be empty. Valid values are false (never), 'create', 'update'. If a + * Closure is passed then the field will be required to be not empty when + * the callback returns true. + * @return $this + * @since 3.8.0 + * @see \Cake\Validation\Validator::allowEmptyDateTime() + */ + public function notEmptyDateTime(string $field, ?string $message = null, Closure|string|bool $when = false) + { + $when = $this->invertWhenClause($when); - foreach ($field as $fieldName => $setting) { - $settings = $this->_convertValidatorToArray($fieldName, $defaults, $setting); - $fieldName = current(array_keys($settings)); - $whenSetting = $settings[$fieldName]['when']; + return $this->allowEmptyFor($field, self::EMPTY_STRING | self::EMPTY_DATE | self::EMPTY_TIME, $when, $message); + } - if ($whenSetting === 'create' || $whenSetting === 'update') { - $whenSetting = $whenSetting === 'create' ? 'update' : 'create'; - } elseif (is_callable($whenSetting)) { - $whenSetting = function ($context) use ($whenSetting) { - return !$whenSetting($context); - }; - } + /** + * Converts validator to fieldName => $settings array + * + * @param string $fieldName name of field + * @param array $defaults default settings + * @param array|string|int $settings settings from data + * @return array> + * @throws \InvalidArgumentException + */ + protected function _convertValidatorToArray( + string $fieldName, + array $defaults = [], + array|string|int $settings = [], + ): array { + if (!is_array($settings)) { + $fieldName = (string)$settings; + $settings = []; + } + $settings += $defaults; - $this->field($fieldName)->isEmptyAllowed($whenSetting); - if ($settings[$fieldName]['message']) { - $this->_allowEmptyMessages[$fieldName] = $settings[$fieldName]['message']; - } + return [$fieldName => $settings]; + } + + /** + * Invert a when clause for creating notEmpty rules + * + * @param \Closure|string|bool $when Indicates when the field is not allowed + * to be empty. Valid values are true (always), 'create', 'update'. If a + * Closure is passed then the field will allowed to be empty only when + * the callback returns false. + * @return \Closure|string|bool + */ + protected function invertWhenClause(Closure|string|bool $when): Closure|string|bool + { + if ($when === static::WHEN_CREATE || $when === static::WHEN_UPDATE) { + return $when === static::WHEN_CREATE ? static::WHEN_UPDATE : static::WHEN_CREATE; + } + if ($when instanceof Closure) { + return fn($context) => !$when($context); } - return $this; + return $when; } /** @@ -764,13 +1066,21 @@ public function notEmpty($field, $message = null, $when = false) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::notBlank() * @return $this */ - public function notBlank($field, $message = null, $when = null) + public function notBlank(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'This field cannot be left empty'; + } else { + $message = __d('cake', 'This field cannot be left empty'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'notBlank', $extra + [ @@ -783,13 +1093,21 @@ public function notBlank($field, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::alphaNumeric() * @return $this */ - public function alphaNumeric($field, $message = null, $when = null) + public function alphaNumeric(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be alphanumeric'; + } else { + $message = __d('cake', 'The provided value must be alphanumeric'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'alphaNumeric', $extra + [ @@ -797,26 +1115,132 @@ public function alphaNumeric($field, $message = null, $when = null) ]); } + /** + * Add a non-alphanumeric rule to a field. + * + * @param string $field The field you want to apply the rule to. + * @param string|null $message The error message when the rule fails. + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns + * true when the validation rule should be applied. + * @see \Cake\Validation\Validation::notAlphaNumeric() + * @return $this + */ + public function notAlphaNumeric(string $field, ?string $message = null, Closure|string|null $when = null) + { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must not be alphanumeric'; + } else { + $message = __d('cake', 'The provided value must not be alphanumeric'); + } + } + + $extra = array_filter(['on' => $when, 'message' => $message]); + + return $this->add($field, 'notAlphaNumeric', $extra + [ + 'rule' => 'notAlphaNumeric', + ]); + } + + /** + * Add an ascii-alphanumeric rule to a field. + * + * @param string $field The field you want to apply the rule to. + * @param string|null $message The error message when the rule fails. + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns + * true when the validation rule should be applied. + * @see \Cake\Validation\Validation::asciiAlphaNumeric() + * @return $this + */ + public function asciiAlphaNumeric(string $field, ?string $message = null, Closure|string|null $when = null) + { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be ASCII-alphanumeric'; + } else { + $message = __d('cake', 'The provided value must be ASCII-alphanumeric'); + } + } + + $extra = array_filter(['on' => $when, 'message' => $message]); + + return $this->add($field, 'asciiAlphaNumeric', $extra + [ + 'rule' => 'asciiAlphaNumeric', + ]); + } + + /** + * Add a non-ascii alphanumeric rule to a field. + * + * @param string $field The field you want to apply the rule to. + * @param string|null $message The error message when the rule fails. + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns + * true when the validation rule should be applied. + * @see \Cake\Validation\Validation::notAlphaNumeric() + * @return $this + */ + public function notAsciiAlphaNumeric(string $field, ?string $message = null, Closure|string|null $when = null) + { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must not be ASCII-alphanumeric'; + } else { + $message = __d('cake', 'The provided value must not be ASCII-alphanumeric'); + } + } + + $extra = array_filter(['on' => $when, 'message' => $message]); + + return $this->add($field, 'notAsciiAlphaNumeric', $extra + [ + 'rule' => 'notAsciiAlphaNumeric', + ]); + } + /** * Add an rule that ensures a string length is within a range. * * @param string $field The field you want to apply the rule to. * @param array $range The inclusive minimum and maximum length you want permitted. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::alphaNumeric() * @return $this - */ - public function lengthBetween($field, array $range, $message = null, $when = null) - { + * @throws \InvalidArgumentException + */ + public function lengthBetween( + string $field, + array $range, + ?string $message = null, + Closure|string|null $when = null, + ) { if (count($range) !== 2) { throw new InvalidArgumentException('The $range argument requires 2 numbers'); } + $lowerBound = array_shift($range); + $upperBound = array_shift($range); + + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf( + 'The length of the provided value must be between `%s` and `%s`, inclusively', + $lowerBound, + $upperBound, + ); + } else { + $message = __d( + 'cake', + 'The length of the provided value must be between `{0}` and `{1}`, inclusively', + $lowerBound, + $upperBound, + ); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'lengthBetween', $extra + [ - 'rule' => ['lengthBetween', array_shift($range), array_shift($range)], + 'rule' => ['lengthBetween', $lowerBound, $upperBound], ]); } @@ -824,20 +1248,54 @@ public function lengthBetween($field, array $range, $message = null, $when = nul * Add a credit card rule to a field. * * @param string $field The field you want to apply the rule to. - * @param string $type The type of cards you want to allow. Defaults to 'all'. + * @param array|string $type The type of cards you want to allow. Defaults to 'all'. * You can also supply an array of accepted card types. e.g `['mastercard', 'visa', 'amex']` * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. - * @see \Cake\Validation\Validation::cc() + * @see \Cake\Validation\Validation::creditCard() * @return $this */ - public function creditCard($field, $type = 'all', $message = null, $when = null) - { + public function creditCard( + string $field, + array|string $type = 'all', + ?string $message = null, + Closure|string|null $when = null, + ) { + if (is_array($type)) { + $typeEnumeration = implode(', ', $type); + } else { + $typeEnumeration = $type; + } + + if ($message === null) { + if (!$this->_useI18n) { + if ($type === 'all') { + $message = 'The provided value must be a valid credit card number of any type'; + } else { + $message = sprintf( + 'The provided value must be a valid credit card number of these types: `%s`', + $typeEnumeration, + ); + } + } elseif ($type === 'all') { + $message = __d( + 'cake', + 'The provided value must be a valid credit card number of any type', + ); + } else { + $message = __d( + 'cake', + 'The provided value must be a valid credit card number of these types: `{0}`', + $typeEnumeration, + ); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'creditCard', $extra + [ - 'rule' => ['cc', $type, true], + 'rule' => ['creditCard', $type, true], ]); } @@ -845,19 +1303,31 @@ public function creditCard($field, $type = 'all', $message = null, $when = null) * Add a greater than comparison rule to a field. * * @param string $field The field you want to apply the rule to. - * @param int|float $value The value user data must be greater than. + * @param float|int $value The value user data must be greater than. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::comparison() * @return $this */ - public function greaterThan($field, $value, $message = null, $when = null) - { + public function greaterThan( + string $field, + float|int $value, + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must be greater than `%s`', $value); + } else { + $message = __d('cake', 'The provided value must be greater than `{0}`', $value); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'greaterThan', $extra + [ - 'rule' => ['comparison', '>', $value] + 'rule' => ['comparison', Validation::COMPARE_GREATER, $value], ]); } @@ -865,181 +1335,571 @@ public function greaterThan($field, $value, $message = null, $when = null) * Add a greater than or equal to comparison rule to a field. * * @param string $field The field you want to apply the rule to. - * @param int|float $value The value user data must be greater than or equal to. + * @param float|int $value The value user data must be greater than or equal to. + * @param string|null $message The error message when the rule fails. + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns + * true when the validation rule should be applied. + * @see \Cake\Validation\Validation::comparison() + * @return $this + */ + public function greaterThanOrEqual( + string $field, + float|int $value, + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must be greater than or equal to `%s`', $value); + } else { + $message = __d('cake', 'The provided value must be greater than or equal to `{0}`', $value); + } + } + + $extra = array_filter(['on' => $when, 'message' => $message]); + + return $this->add($field, 'greaterThanOrEqual', $extra + [ + 'rule' => ['comparison', Validation::COMPARE_GREATER_OR_EQUAL, $value], + ]); + } + + /** + * Add a less than comparison rule to a field. + * + * @param string $field The field you want to apply the rule to. + * @param float|int $value The value user data must be less than. + * @param string|null $message The error message when the rule fails. + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns + * true when the validation rule should be applied. + * @see \Cake\Validation\Validation::comparison() + * @return $this + */ + public function lessThan( + string $field, + float|int $value, + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must be less than `%s`', $value); + } else { + $message = __d('cake', 'The provided value must be less than `{0}`', $value); + } + } + + $extra = array_filter(['on' => $when, 'message' => $message]); + + return $this->add($field, 'lessThan', $extra + [ + 'rule' => ['comparison', Validation::COMPARE_LESS, $value], + ]); + } + + /** + * Add a less than or equal comparison rule to a field. + * + * @param string $field The field you want to apply the rule to. + * @param float|int $value The value user data must be less than or equal to. + * @param string|null $message The error message when the rule fails. + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns + * true when the validation rule should be applied. + * @see \Cake\Validation\Validation::comparison() + * @return $this + */ + public function lessThanOrEqual( + string $field, + float|int $value, + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must be less than or equal to `%s`', $value); + } else { + $message = __d('cake', 'The provided value must be less than or equal to `{0}`', $value); + } + } + + $extra = array_filter(['on' => $when, 'message' => $message]); + + return $this->add($field, 'lessThanOrEqual', $extra + [ + 'rule' => ['comparison', Validation::COMPARE_LESS_OR_EQUAL, $value], + ]); + } + + /** + * Add a equal to comparison rule to a field. + * + * @param string $field The field you want to apply the rule to. + * @param mixed $value The value user data must be equal to. + * @param string|null $message The error message when the rule fails. + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns + * true when the validation rule should be applied. + * @see \Cake\Validation\Validation::comparison() + * @return $this + */ + public function equals( + string $field, + mixed $value, + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must be equal to `%s`', $value); + } else { + $message = __d('cake', 'The provided value must be equal to `{0}`', $value); + } + } + + $extra = array_filter(['on' => $when, 'message' => $message]); + + return $this->add($field, 'equals', $extra + [ + 'rule' => ['comparison', Validation::COMPARE_EQUAL, $value], + ]); + } + + /** + * Add a not equal to comparison rule to a field. + * + * @param string $field The field you want to apply the rule to. + * @param mixed $value The value user data must be not be equal to. + * @param string|null $message The error message when the rule fails. + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns + * true when the validation rule should be applied. + * @see \Cake\Validation\Validation::comparison() + * @return $this + */ + public function notEquals( + string $field, + mixed $value, + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must not be equal to `%s`', $value); + } else { + $message = __d('cake', 'The provided value must not be equal to `{0}`', $value); + } + } + + $extra = array_filter(['on' => $when, 'message' => $message]); + + return $this->add($field, 'notEquals', $extra + [ + 'rule' => ['comparison', Validation::COMPARE_NOT_EQUAL, $value], + ]); + } + + /** + * Add a rule to compare two fields to each other. + * + * If both fields have the exact same value the rule will pass. + * + * @param string $field The field you want to apply the rule to. + * @param string $secondField The field you want to compare against. + * @param string|null $message The error message when the rule fails. + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns + * true when the validation rule should be applied. + * @see \Cake\Validation\Validation::compareFields() + * @return $this + */ + public function sameAs( + string $field, + string $secondField, + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must be same as `%s`', $secondField); + } else { + $message = __d('cake', 'The provided value must be same as `{0}`', $secondField); + } + } + + $extra = array_filter(['on' => $when, 'message' => $message]); + + return $this->add($field, 'sameAs', $extra + [ + 'rule' => ['compareFields', $secondField, Validation::COMPARE_SAME], + ]); + } + + /** + * Add a rule to compare that two fields have different values. + * + * @param string $field The field you want to apply the rule to. + * @param string $secondField The field you want to compare against. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. - * @see \Cake\Validation\Validation::comparison() + * @see \Cake\Validation\Validation::compareFields() * @return $this - */ - public function greaterThanOrEqual($field, $value, $message = null, $when = null) - { + * @since 3.6.0 + */ + public function notSameAs( + string $field, + string $secondField, + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must not be same as `%s`', $secondField); + } else { + $message = __d('cake', 'The provided value must not be same as `{0}`', $secondField); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); - return $this->add($field, 'greaterThanOrEqual', $extra + [ - 'rule' => ['comparison', '>=', $value] + return $this->add($field, 'notSameAs', $extra + [ + 'rule' => ['compareFields', $secondField, Validation::COMPARE_NOT_SAME], ]); } /** - * Add a less than comparison rule to a field. + * Add a rule to compare one field is equal to another. * * @param string $field The field you want to apply the rule to. - * @param int|float $value The value user data must be less than. + * @param string $secondField The field you want to compare against. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. - * @see \Cake\Validation\Validation::comparison() + * @see \Cake\Validation\Validation::compareFields() * @return $this - */ - public function lessThan($field, $value, $message = null, $when = null) - { + * @since 3.6.0 + */ + public function equalToField( + string $field, + string $secondField, + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must be equal to the one of field `%s`', $secondField); + } else { + $message = __d( + 'cake', + 'The provided value must be equal to the one of field `{0}`', + $secondField, + ); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); - return $this->add($field, 'lessThan', $extra + [ - 'rule' => ['comparison', '<', $value] + return $this->add($field, 'equalToField', $extra + [ + 'rule' => ['compareFields', $secondField, Validation::COMPARE_EQUAL], ]); } /** - * Add a less than or equal comparison rule to a field. + * Add a rule to compare one field is not equal to another. * * @param string $field The field you want to apply the rule to. - * @param int|float $value The value user data must be less than or equal to. + * @param string $secondField The field you want to compare against. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. - * @see \Cake\Validation\Validation::comparison() + * @see \Cake\Validation\Validation::compareFields() * @return $this - */ - public function lessThanOrEqual($field, $value, $message = null, $when = null) - { + * @since 3.6.0 + */ + public function notEqualToField( + string $field, + string $secondField, + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must not be equal to the one of field `%s`', $secondField); + } else { + $message = __d( + 'cake', + 'The provided value must not be equal to the one of field `{0}`', + $secondField, + ); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); - return $this->add($field, 'lessThanOrEqual', $extra + [ - 'rule' => ['comparison', '<=', $value] + return $this->add($field, 'notEqualToField', $extra + [ + 'rule' => ['compareFields', $secondField, Validation::COMPARE_NOT_EQUAL], ]); } /** - * Add a equal to comparison rule to a field. + * Add a rule to compare one field is greater than another. * * @param string $field The field you want to apply the rule to. - * @param int|float $value The value user data must be equal to. + * @param string $secondField The field you want to compare against. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. - * @see \Cake\Validation\Validation::comparison() + * @see \Cake\Validation\Validation::compareFields() * @return $this - */ - public function equals($field, $value, $message = null, $when = null) - { + * @since 3.6.0 + */ + public function greaterThanField( + string $field, + string $secondField, + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must be greater than the one of field `%s`', $secondField); + } else { + $message = __d( + 'cake', + 'The provided value must be greater than the one of field `{0}`', + $secondField, + ); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); - return $this->add($field, 'equals', $extra + [ - 'rule' => ['comparison', '==', $value] + return $this->add($field, 'greaterThanField', $extra + [ + 'rule' => ['compareFields', $secondField, Validation::COMPARE_GREATER], ]); } /** - * Add a not equal to comparison rule to a field. + * Add a rule to compare one field is greater than or equal to another. * * @param string $field The field you want to apply the rule to. - * @param int|float $value The value user data must be not be equal to. + * @param string $secondField The field you want to compare against. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. - * @see \Cake\Validation\Validation::comparison() + * @see \Cake\Validation\Validation::compareFields() * @return $this - */ - public function notEquals($field, $value, $message = null, $when = null) - { + * @since 3.6.0 + */ + public function greaterThanOrEqualToField( + string $field, + string $secondField, + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf( + 'The provided value must be greater than or equal to the one of field `%s`', + $secondField, + ); + } else { + $message = __d( + 'cake', + 'The provided value must be greater than or equal to the one of field `{0}`', + $secondField, + ); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); - return $this->add($field, 'notEquals', $extra + [ - 'rule' => ['comparison', '!=', $value] + return $this->add($field, 'greaterThanOrEqualToField', $extra + [ + 'rule' => ['compareFields', $secondField, Validation::COMPARE_GREATER_OR_EQUAL], ]); } /** - * Add a rule to compare two fields to each other. + * Add a rule to compare one field is less than another. * - * If both fields have the exact same value the rule will pass. - * - * @param mixed $field The field you want to apply the rule to. - * @param mixed $secondField The field you want to compare against. + * @param string $field The field you want to apply the rule to. + * @param string $secondField The field you want to compare against. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. - * @see \Cake\Validation\Validation::compareWith() + * @see \Cake\Validation\Validation::compareFields() * @return $this - */ - public function sameAs($field, $secondField, $message = null, $when = null) - { + * @since 3.6.0 + */ + public function lessThanField( + string $field, + string $secondField, + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must be less than the one of field `%s`', $secondField); + } else { + $message = __d( + 'cake', + 'The provided value must be less than the one of field `{0}`', + $secondField, + ); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); - return $this->add($field, 'sameAs', $extra + [ - 'rule' => ['compareWith', $secondField] + return $this->add($field, 'lessThanField', $extra + [ + 'rule' => ['compareFields', $secondField, Validation::COMPARE_LESS], ]); } /** - * Add a rule to check if a field contains non alpha numeric characters. + * Add a rule to compare one field is less than or equal to another. * * @param string $field The field you want to apply the rule to. - * @param int $limit The minimum number of non-alphanumeric fields required. + * @param string $secondField The field you want to compare against. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. - * @see \Cake\Validation\Validation::containsNonAlphaNumeric() + * @see \Cake\Validation\Validation::compareFields() * @return $this - */ - public function containsNonAlphaNumeric($field, $limit = 1, $message = null, $when = null) - { + * @since 3.6.0 + */ + public function lessThanOrEqualToField( + string $field, + string $secondField, + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf( + 'The provided value must be less than or equal to the one of field `%s`', + $secondField, + ); + } else { + $message = __d( + 'cake', + 'The provided value must be less than or equal to the one of field `{0}`', + $secondField, + ); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); - return $this->add($field, 'containsNonAlphaNumeric', $extra + [ - 'rule' => ['containsNonAlphaNumeric', $limit] + return $this->add($field, 'lessThanOrEqualToField', $extra + [ + 'rule' => ['compareFields', $secondField, Validation::COMPARE_LESS_OR_EQUAL], ]); } /** * Add a date format validation rule to a field. * + * Years are valid from 0001 to 2999. + * + * ### Formats: + * + * - `ymd` 2006-12-27 or 06-12-27 separators can be a space, period, dash, forward slash + * - `dmy` 27-12-2006 or 27-12-06 separators can be a space, period, dash, forward slash + * - `mdy` 12-27-2006 or 12-27-06 separators can be a space, period, dash, forward slash + * - `dMy` 27 December 2006 or 27 Dec 2006 + * - `Mdy` December 27, 2006 or Dec 27, 2006 comma is optional + * - `My` December 2006 or Dec 2006 + * - `my` 12/2006 or 12/06 separators can be a space, period, dash, forward slash + * - `ym` 2006/12 or 06/12 separators can be a space, period, dash, forward slash + * - `y` 2006 just the year without any separators + * * @param string $field The field you want to apply the rule to. - * @param array $formats A list of accepted date formats. + * @param array $formats A list of accepted date formats. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::date() * @return $this */ - public function date($field, $formats = ['ymd'], $message = null, $when = null) - { + public function date( + string $field, + array $formats = ['ymd'], + ?string $message = null, + Closure|string|null $when = null, + ) { + $formatEnumeration = implode(', ', $formats); + + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf( + 'The provided value must be a date of one of these formats: `%s`', + $formatEnumeration, + ); + } else { + $message = __d( + 'cake', + 'The provided value must be a date of one of these formats: `{0}`', + $formatEnumeration, + ); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'date', $extra + [ - 'rule' => ['date', $formats] + 'rule' => ['date', $formats], ]); } /** * Add a date time format validation rule to a field. * + * All values matching the "date" core validation rule, and the "time" one will be valid + * + * Years are valid from 0001 to 2999. + * + * ### Formats: + * + * - `ymd` 2006-12-27 or 06-12-27 separators can be a space, period, dash, forward slash + * - `dmy` 27-12-2006 or 27-12-06 separators can be a space, period, dash, forward slash + * - `mdy` 12-27-2006 or 12-27-06 separators can be a space, period, dash, forward slash + * - `dMy` 27 December 2006 or 27 Dec 2006 + * - `Mdy` December 27, 2006 or Dec 27, 2006 comma is optional + * - `My` December 2006 or Dec 2006 + * - `my` 12/2006 or 12/06 separators can be a space, period, dash, forward slash + * - `ym` 2006/12 or 06/12 separators can be a space, period, dash, forward slash + * - `y` 2006 just the year without any separators + * + * Time is validated as 24hr (HH:MM[:SS][.FFFFFF]) or am/pm ([H]H:MM[a|p]m) + * + * Seconds and fractional seconds (microseconds) are allowed but optional + * in 24hr format. + * * @param string $field The field you want to apply the rule to. - * @param array $formats A list of accepted date formats. + * @param array $formats A list of accepted date formats. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::datetime() * @return $this */ - public function dateTime($field, $formats = ['ymd'], $message = null, $when = null) - { + public function dateTime( + string $field, + array $formats = ['ymd'], + ?string $message = null, + Closure|string|null $when = null, + ) { + $formatEnumeration = implode(', ', $formats); + + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf( + 'The provided value must be a date and time of one of these formats: `%s`', + $formatEnumeration, + ); + } else { + $message = __d( + 'cake', + 'The provided value must be a date and time of one of these formats: `{0}`', + $formatEnumeration, + ); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'dateTime', $extra + [ - 'rule' => ['datetime', $formats] + 'rule' => ['datetime', $formats], ]); } @@ -1048,17 +1908,25 @@ public function dateTime($field, $formats = ['ymd'], $message = null, $when = nu * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::time() * @return $this */ - public function time($field, $message = null, $when = null) + public function time(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be a time'; + } else { + $message = __d('cake', 'The provided value must be a time'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'time', $extra + [ - 'rule' => 'time' + 'rule' => 'time', ]); } @@ -1068,17 +1936,29 @@ public function time($field, $message = null, $when = null) * @param string $field The field you want to apply the rule to. * @param string $type Parser type, one out of 'date', 'time', and 'datetime' * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::localizedTime() * @return $this */ - public function localizedTime($field, $type = 'datetime', $message = null, $when = null) - { + public function localizedTime( + string $field, + string $type = 'datetime', + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be a localized time, date or date and time'; + } else { + $message = __d('cake', 'The provided value must be a localized time, date or date and time'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'localizedTime', $extra + [ - 'rule' => ['localizedTime', $type] + 'rule' => ['localizedTime', $type], ]); } @@ -1087,17 +1967,25 @@ public function localizedTime($field, $type = 'datetime', $message = null, $when * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::boolean() * @return $this */ - public function boolean($field, $message = null, $when = null) + public function boolean(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be a boolean'; + } else { + $message = __d('cake', 'The provided value must be a boolean'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'boolean', $extra + [ - 'rule' => 'boolean' + 'rule' => 'boolean', ]); } @@ -1107,17 +1995,42 @@ public function boolean($field, $message = null, $when = null) * @param string $field The field you want to apply the rule to. * @param int|null $places The number of decimal places to require. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::decimal() * @return $this */ - public function decimal($field, $places = null, $message = null, $when = null) - { + public function decimal( + string $field, + ?int $places = null, + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + if ($places === null) { + $message = 'The provided value must be decimal with any number of decimal places, including none'; + } else { + $message = sprintf('The provided value must be decimal with `%s` decimal places', $places); + } + } elseif ($places === null) { + $message = __d( + 'cake', + 'The provided value must be decimal with any number of decimal places, including none', + ); + } else { + $message = __d( + 'cake', + 'The provided value must be decimal with `{0}` decimal places', + $places, + ); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'decimal', $extra + [ - 'rule' => ['decimal', $places] + 'rule' => ['decimal', $places], ]); } @@ -1125,19 +2038,72 @@ public function decimal($field, $places = null, $message = null, $when = null) * Add an email validation rule to a field. * * @param string $field The field you want to apply the rule to. - * @param bool $checkMX Whether or not to check the MX records. + * @param bool $checkMX Whether to check the MX records. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::email() * @return $this */ - public function email($field, $checkMX = false, $message = null, $when = null) - { + public function email( + string $field, + bool $checkMX = false, + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be an e-mail address'; + } else { + $message = __d('cake', 'The provided value must be an e-mail address'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'email', $extra + [ - 'rule' => ['email', $checkMX] + 'rule' => ['email', $checkMX], + ]); + } + + /** + * Add a backed enum validation rule to a field. + * + * @param string $field The field you want to apply the rule to. + * @param class-string<\BackedEnum> $enumClassName The valid backed enum class name. + * @param string|null $message The error message when the rule fails. + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns + * true when the validation rule should be applied. + * @return $this + * @see \Cake\Validation\Validation::enum() + * @since 5.0.3 + */ + public function enum( + string $field, + string $enumClassName, + ?string $message = null, + Closure|string|null $when = null, + ) { + if (!in_array(BackedEnum::class, (array)class_implements($enumClassName), true)) { + throw new InvalidArgumentException( + 'The `$enumClassName` argument must be the classname of a valid backed enum.', + ); + } + + if ($message === null) { + $cases = array_map(fn(BackedEnum $case) => $case->value, $enumClassName::cases()); + $caseOptions = implode('`, `', $cases); + if (!$this->_useI18n) { + $message = sprintf('The provided value must be one of `%s`', $caseOptions); + } else { + $message = __d('cake', 'The provided value must be one of `{0}`', $caseOptions); + } + } + + $extra = array_filter(['on' => $when, 'message' => $message]); + + return $this->add($field, 'enum', $extra + [ + 'rule' => ['enum', $enumClassName], ]); } @@ -1148,17 +2114,25 @@ public function email($field, $checkMX = false, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::ip() * @return $this */ - public function ip($field, $message = null, $when = null) + public function ip(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be an IP address'; + } else { + $message = __d('cake', 'The provided value must be an IP address'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'ip', $extra + [ - 'rule' => 'ip' + 'rule' => 'ip', ]); } @@ -1167,17 +2141,25 @@ public function ip($field, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::ip() * @return $this */ - public function ipv4($field, $message = null, $when = null) + public function ipv4(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be an IPv4 address'; + } else { + $message = __d('cake', 'The provided value must be an IPv4 address'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'ipv4', $extra + [ - 'rule' => ['ip', 'ipv4'] + 'rule' => ['ip', 'ipv4'], ]); } @@ -1186,17 +2168,25 @@ public function ipv4($field, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::ip() * @return $this */ - public function ipv6($field, $message = null, $when = null) + public function ipv6(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be an IPv6 address'; + } else { + $message = __d('cake', 'The provided value must be an IPv6 address'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'ipv6', $extra + [ - 'rule' => ['ip', 'ipv6'] + 'rule' => ['ip', 'ipv6'], ]); } @@ -1206,17 +2196,25 @@ public function ipv6($field, $message = null, $when = null) * @param string $field The field you want to apply the rule to. * @param int $min The minimum length required. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::minLength() * @return $this */ - public function minLength($field, $min, $message = null, $when = null) + public function minLength(string $field, int $min, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must be at least `%s` characters long', $min); + } else { + $message = __d('cake', 'The provided value must be at least `{0}` characters long', $min); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'minLength', $extra + [ - 'rule' => ['minLength', $min] + 'rule' => ['minLength', $min], ]); } @@ -1226,17 +2224,25 @@ public function minLength($field, $min, $message = null, $when = null) * @param string $field The field you want to apply the rule to. * @param int $min The minimum length required. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::minLengthBytes() * @return $this */ - public function minLengthBytes($field, $min, $message = null, $when = null) + public function minLengthBytes(string $field, int $min, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must be at least `%s` bytes long', $min); + } else { + $message = __d('cake', 'The provided value must be at least `{0}` bytes long', $min); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'minLengthBytes', $extra + [ - 'rule' => ['minLengthBytes', $min] + 'rule' => ['minLengthBytes', $min], ]); } @@ -1246,17 +2252,25 @@ public function minLengthBytes($field, $min, $message = null, $when = null) * @param string $field The field you want to apply the rule to. * @param int $max The maximum length allowed. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::maxLength() * @return $this */ - public function maxLength($field, $max, $message = null, $when = null) + public function maxLength(string $field, int $max, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must be at most `%s` characters long', $max); + } else { + $message = __d('cake', 'The provided value must be at most `{0}` characters long', $max); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'maxLength', $extra + [ - 'rule' => ['maxLength', $max] + 'rule' => ['maxLength', $max], ]); } @@ -1266,17 +2280,25 @@ public function maxLength($field, $max, $message = null, $when = null) * @param string $field The field you want to apply the rule to. * @param int $max The maximum length allowed. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::maxLengthBytes() * @return $this */ - public function maxLengthBytes($field, $max, $message = null, $when = null) + public function maxLengthBytes(string $field, int $max, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must be at most `%s` bytes long', $max); + } else { + $message = __d('cake', 'The provided value must be at most `{0}` bytes long', $max); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'maxLengthBytes', $extra + [ - 'rule' => ['maxLengthBytes', $max] + 'rule' => ['maxLengthBytes', $max], ]); } @@ -1285,17 +2307,25 @@ public function maxLengthBytes($field, $max, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::numeric() * @return $this */ - public function numeric($field, $message = null, $when = null) + public function numeric(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be numeric'; + } else { + $message = __d('cake', 'The provided value must be numeric'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'numeric', $extra + [ - 'rule' => 'numeric' + 'rule' => 'numeric', ]); } @@ -1304,17 +2334,25 @@ public function numeric($field, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::naturalNumber() * @return $this */ - public function naturalNumber($field, $message = null, $when = null) + public function naturalNumber(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be a natural number'; + } else { + $message = __d('cake', 'The provided value must be a natural number'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'naturalNumber', $extra + [ - 'rule' => ['naturalNumber', false] + 'rule' => ['naturalNumber', false], ]); } @@ -1323,17 +2361,25 @@ public function naturalNumber($field, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::naturalNumber() * @return $this */ - public function nonNegativeInteger($field, $message = null, $when = null) + public function nonNegativeInteger(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be a non-negative integer'; + } else { + $message = __d('cake', 'The provided value must be a non-negative integer'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'nonNegativeInteger', $extra + [ - 'rule' => ['naturalNumber', true] + 'rule' => ['naturalNumber', true], ]); } @@ -1343,20 +2389,41 @@ public function nonNegativeInteger($field, $message = null, $when = null) * @param string $field The field you want to apply the rule to. * @param array $range The inclusive upper and lower bounds of the valid range. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::range() * @return $this + * @throws \InvalidArgumentException */ - public function range($field, array $range, $message = null, $when = null) + public function range(string $field, array $range, ?string $message = null, Closure|string|null $when = null) { if (count($range) !== 2) { throw new InvalidArgumentException('The $range argument requires 2 numbers'); } + $lowerBound = array_shift($range); + $upperBound = array_shift($range); + + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf( + 'The provided value must be between `%s` and `%s`, inclusively', + $lowerBound, + $upperBound, + ); + } else { + $message = __d( + 'cake', + 'The provided value must be between `{0}` and `{1}`, inclusively', + $lowerBound, + $upperBound, + ); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'range', $extra + [ - 'rule' => ['range', array_shift($range), array_shift($range)] + 'rule' => ['range', $lowerBound, $upperBound], ]); } @@ -1367,17 +2434,25 @@ public function range($field, array $range, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::url() * @return $this */ - public function url($field, $message = null, $when = null) + public function url(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be a URL'; + } else { + $message = __d('cake', 'The provided value must be a URL'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'url', $extra + [ - 'rule' => ['url', false] + 'rule' => ['url', false], ]); } @@ -1388,37 +2463,59 @@ public function url($field, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::url() * @return $this */ - public function urlWithProtocol($field, $message = null, $when = null) + public function urlWithProtocol(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be a URL with protocol'; + } else { + $message = __d('cake', 'The provided value must be a URL with protocol'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'urlWithProtocol', $extra + [ - 'rule' => ['url', true] + 'rule' => ['url', true], ]); } /** - * Add a validation rule to ensure the field value is within a whitelist. + * Add a validation rule to ensure the field value is within an allowed list. * * @param string $field The field you want to apply the rule to. * @param array $list The list of valid options. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::inList() * @return $this */ - public function inList($field, array $list, $message = null, $when = null) + public function inList(string $field, array $list, ?string $message = null, Closure|string|null $when = null) { + $listEnumeration = implode(', ', $list); + + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must be one of: `%s`', $listEnumeration); + } else { + $message = __d( + 'cake', + 'The provided value must be one of: `{0}`', + $listEnumeration, + ); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'inList', $extra + [ - 'rule' => ['inList', $list] + 'rule' => ['inList', $list], ]); } @@ -1427,39 +2524,57 @@ public function inList($field, array $list, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::uuid() * @return $this */ - public function uuid($field, $message = null, $when = null) + public function uuid(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be a UUID'; + } else { + $message = __d('cake', 'The provided value must be a UUID'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'uuid', $extra + [ - 'rule' => 'uuid' + 'rule' => 'uuid', ]); } /** * Add a validation rule to ensure the field is an uploaded file * - * For options see Cake\Validation\Validation::uploadedFile() - * * @param string $field The field you want to apply the rule to. - * @param array $options An array of options. + * @param array $options An array of options. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. - * @see \Cake\Validation\Validation::uploadedFile() + * @see \Cake\Validation\Validation::uploadedFile() For options * @return $this */ - public function uploadedFile($field, array $options, $message = null, $when = null) - { + public function uploadedFile( + string $field, + array $options, + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be an uploaded file'; + } else { + $message = __d('cake', 'The provided value must be an uploaded file'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'uploadedFile', $extra + [ - 'rule' => ['uploadedFile', $options] + 'rule' => ['uploadedFile', $options], ]); } @@ -1470,17 +2585,25 @@ public function uploadedFile($field, array $options, $message = null, $when = nu * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. - * @see \Cake\Validation\Validation::uuid() + * @see \Cake\Validation\Validation::geoCoordinate() * @return $this */ - public function latLong($field, $message = null, $when = null) + public function latLong(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be a latitude/longitude coordinate'; + } else { + $message = __d('cake', 'The provided value must be a latitude/longitude coordinate'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'latLong', $extra + [ - 'rule' => 'geoCoordinate' + 'rule' => 'geoCoordinate', ]); } @@ -1489,17 +2612,25 @@ public function latLong($field, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::latitude() * @return $this */ - public function latitude($field, $message = null, $when = null) + public function latitude(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be a latitude'; + } else { + $message = __d('cake', 'The provided value must be a latitude'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'latitude', $extra + [ - 'rule' => 'latitude' + 'rule' => 'latitude', ]); } @@ -1508,17 +2639,25 @@ public function latitude($field, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::longitude() * @return $this */ - public function longitude($field, $message = null, $when = null) + public function longitude(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be a longitude'; + } else { + $message = __d('cake', 'The provided value must be a longitude'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'longitude', $extra + [ - 'rule' => 'longitude' + 'rule' => 'longitude', ]); } @@ -1527,17 +2666,25 @@ public function longitude($field, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::ascii() * @return $this */ - public function ascii($field, $message = null, $when = null) + public function ascii(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be ASCII bytes only'; + } else { + $message = __d('cake', 'The provided value must be ASCII bytes only'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'ascii', $extra + [ - 'rule' => 'ascii' + 'rule' => 'ascii', ]); } @@ -1546,17 +2693,25 @@ public function ascii($field, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::utf8() * @return $this */ - public function utf8($field, $message = null, $when = null) + public function utf8(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be UTF-8 bytes only'; + } else { + $message = __d('cake', 'The provided value must be UTF-8 bytes only'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'utf8', $extra + [ - 'rule' => ['utf8', ['extended' => false]] + 'rule' => ['utf8', ['extended' => false]], ]); } @@ -1567,17 +2722,25 @@ public function utf8($field, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::utf8() * @return $this */ - public function utf8Extended($field, $message = null, $when = null) + public function utf8Extended(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be 3 and 4 byte UTF-8 sequences only'; + } else { + $message = __d('cake', 'The provided value must be 3 and 4 byte UTF-8 sequences only'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'utf8Extended', $extra + [ - 'rule' => ['utf8', ['extended' => true]] + 'rule' => ['utf8', ['extended' => true]], ]); } @@ -1586,17 +2749,25 @@ public function utf8Extended($field, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::isInteger() * @return $this */ - public function integer($field, $message = null, $when = null) + public function integer(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be an integer'; + } else { + $message = __d('cake', 'The provided value must be an integer'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'integer', $extra + [ - 'rule' => 'isInteger' + 'rule' => 'isInteger', ]); } @@ -1605,18 +2776,26 @@ public function integer($field, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::isArray() * @return $this */ - public function isArray($field, $message = null, $when = null) + public function array(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = 'The provided value must be an array'; + } else { + $message = __d('cake', 'The provided value must be an array'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); - return $this->add($field, 'isArray', $extra + [ - 'rule' => 'isArray' - ]); + return $this->add($field, 'array', $extra + [ + 'rule' => 'isArray', + ]); } /** @@ -1624,17 +2803,24 @@ public function isArray($field, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::isScalar() * @return $this */ - public function scalar($field, $message = null, $when = null) + public function scalar(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + $message = 'The provided value must be scalar'; + if ($this->_useI18n) { + $message = __d('cake', 'The provided value must be scalar'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'scalar', $extra + [ - 'rule' => 'isScalar' + 'rule' => 'isScalar', ]); } @@ -1643,13 +2829,20 @@ public function scalar($field, $message = null, $when = null) * * @param string $field The field you want to apply the rule to. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::hexColor() * @return $this */ - public function hexColor($field, $message = null, $when = null) + public function hexColor(string $field, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + $message = 'The provided value must be a hex color'; + if ($this->_useI18n) { + $message = __d('cake', 'The provided value must be a hex color'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'hexColor', $extra + [ @@ -1661,22 +2854,33 @@ public function hexColor($field, $message = null, $when = null) * Add a validation rule for a multiple select. Comparison is case sensitive by default. * * @param string $field The field you want to apply the rule to. - * @param array $options The options for the validator. Includes the options defined in + * @param array $options The options for the validator. Includes the options defined in * \Cake\Validation\Validation::multiple() and the `caseInsensitive` parameter. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::multiple() * @return $this */ - public function multipleOptions($field, array $options = [], $message = null, $when = null) - { + public function multipleOptions( + string $field, + array $options = [], + ?string $message = null, + Closure|string|null $when = null, + ) { + if ($message === null) { + $message = 'The provided value must be a set of multiple options'; + if ($this->_useI18n) { + $message = __d('cake', 'The provided value must be a set of multiple options'); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); - $caseInsensitive = isset($options['caseInsensitive']) ? $options['caseInsensitive'] : false; + $caseInsensitive = $options['caseInsensitive'] ?? false; unset($options['caseInsensitive']); return $this->add($field, 'multipleOptions', $extra + [ - 'rule' => ['multiple', $options, $caseInsensitive] + 'rule' => ['multiple', $options, $caseInsensitive], ]); } @@ -1687,13 +2891,21 @@ public function multipleOptions($field, array $options = [], $message = null, $w * @param string $field The field you want to apply the rule to. * @param int $count The number of elements the array should at least have * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::numElements() * @return $this */ - public function hasAtLeast($field, $count, $message = null, $when = null) + public function hasAtLeast(string $field, int $count, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must have at least `%s` elements', $count); + } else { + $message = __d('cake', 'The provided value must have at least `{0}` elements', $count); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'hasAtLeast', $extra + [ @@ -1702,8 +2914,8 @@ public function hasAtLeast($field, $count, $message = null, $when = null) $value = $value['_ids']; } - return Validation::numElements($value, '>=', $count); - } + return Validation::numElements($value, Validation::COMPARE_GREATER_OR_EQUAL, $count); + }, ]); } @@ -1714,13 +2926,21 @@ public function hasAtLeast($field, $count, $message = null, $when = null) * @param string $field The field you want to apply the rule to. * @param int $count The number maximum amount of elements the field should have * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @see \Cake\Validation\Validation::numElements() * @return $this */ - public function hasAtMost($field, $count, $message = null, $when = null) + public function hasAtMost(string $field, int $count, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must have at most `%s` elements', $count); + } else { + $message = __d('cake', 'The provided value must have at most `{0}` elements', $count); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'hasAtMost', $extra + [ @@ -1729,20 +2949,20 @@ public function hasAtMost($field, $count, $message = null, $when = null) $value = $value['_ids']; } - return Validation::numElements($value, '<=', $count); - } + return Validation::numElements($value, Validation::COMPARE_LESS_OR_EQUAL, $count); + }, ]); } /** - * Returns whether or not a field can be left empty for a new or already existing + * Returns whether a field can be left empty for a new or already existing * record. * * @param string $field Field name. * @param bool $newRecord whether the data to be validated is new or to be updated. * @return bool */ - public function isEmptyAllowed($field, $newRecord) + public function isEmptyAllowed(string $field, bool $newRecord): bool { $providers = $this->_providers; $data = []; @@ -1752,14 +2972,14 @@ public function isEmptyAllowed($field, $newRecord) } /** - * Returns whether or not a field can be left out for a new or already existing + * Returns whether a field can be left out for a new or already existing * record. * * @param string $field Field name. * @param bool $newRecord Whether the data to be validated is new or to be updated. * @return bool */ - public function isPresenceRequired($field, $newRecord) + public function isPresenceRequired(string $field, bool $newRecord): bool { $providers = $this->_providers; $data = []; @@ -1769,46 +2989,104 @@ public function isPresenceRequired($field, $newRecord) } /** - * Returns whether or not a field matches against a regular expression. + * Returns whether a field matches against a regular expression. * * @param string $field Field name. * @param string $regex Regular expression. * @param string|null $message The error message when the rule fails. - * @param string|callable|null $when Either 'create' or 'update' or a callable that returns + * @param \Closure|string|null $when Either 'create' or 'update' or a Closure that returns * true when the validation rule should be applied. * @return $this */ - public function regex($field, $regex, $message = null, $when = null) + public function regex(string $field, string $regex, ?string $message = null, Closure|string|null $when = null) { + if ($message === null) { + if (!$this->_useI18n) { + $message = sprintf('The provided value must match against the pattern `%s`', $regex); + } else { + $message = __d('cake', 'The provided value must match against the pattern `{0}`', $regex); + } + } + $extra = array_filter(['on' => $when, 'message' => $message]); return $this->add($field, 'regex', $extra + [ - 'rule' => ['custom', $regex] + 'rule' => ['custom', $regex], ]); } + /** + * Gets the required message for a field + * + * @param string $field Field name + * @return string|null + */ + public function getRequiredMessage(string $field): ?string + { + if (!isset($this->_fields[$field])) { + return null; + } + + if (isset($this->_presenceMessages[$field])) { + return $this->_presenceMessages[$field]; + } + + if (!$this->_useI18n) { + return 'This field is required'; + } + + return __d('cake', 'This field is required'); + } + + /** + * Gets the notEmpty message for a field + * + * @param string $field Field name + * @return string|null + */ + public function getNotEmptyMessage(string $field): ?string + { + if (!isset($this->_fields[$field])) { + return null; + } + + foreach ($this->_fields[$field] as $rule) { + if ($rule->get('rule') === 'notBlank' && $rule->get('message')) { + return $rule->get('message'); + } + } + + if (isset($this->_allowEmptyMessages[$field])) { + return $this->_allowEmptyMessages[$field]; + } + + if (!$this->_useI18n) { + return 'This field cannot be left empty'; + } + + return __d('cake', 'This field cannot be left empty'); + } + /** * Returns false if any validation for the passed rule set should be stopped * due to the field missing in the data array * * @param \Cake\Validation\ValidationSet $field The set of rules for a field. - * @param array $context A key value list of data containing the validation context. + * @param array $context A key value list of data containing the validation context. * @return bool */ - protected function _checkPresence($field, $context) + protected function _checkPresence(ValidationSet $field, array $context): bool { $required = $field->isPresenceRequired(); - if (!is_string($required) && is_callable($required)) { + if ($required instanceof Closure) { return !$required($context); } $newRecord = $context['newRecord']; - if (in_array($required, ['create', 'update'], true)) { - return ( - ($required === 'create' && !$newRecord) || - ($required === 'update' && $newRecord) - ); + if (in_array($required, [static::WHEN_CREATE, static::WHEN_UPDATE], true)) { + return ($required === static::WHEN_CREATE && !$newRecord) || + ($required === static::WHEN_UPDATE && $newRecord); } return !$required; @@ -1818,47 +3096,74 @@ protected function _checkPresence($field, $context) * Returns whether the field can be left blank according to `allowEmpty` * * @param \Cake\Validation\ValidationSet $field the set of rules for a field - * @param array $context a key value list of data containing the validation context. + * @param array $context a key value list of data containing the validation context. * @return bool */ - protected function _canBeEmpty($field, $context) + protected function _canBeEmpty(ValidationSet $field, array $context): bool { $allowed = $field->isEmptyAllowed(); - if (!is_string($allowed) && is_callable($allowed)) { + if ($allowed instanceof Closure) { return $allowed($context); } $newRecord = $context['newRecord']; - if (in_array($allowed, ['create', 'update'], true)) { - $allowed = ( - ($allowed === 'create' && $newRecord) || - ($allowed === 'update' && !$newRecord) - ); + if (in_array($allowed, [static::WHEN_CREATE, static::WHEN_UPDATE], true)) { + $allowed = ($allowed === static::WHEN_CREATE && $newRecord) || + ($allowed === static::WHEN_UPDATE && !$newRecord); } - return $allowed; + return (bool)$allowed; } /** * Returns true if the field is empty in the passed data array * - * @param mixed $data value to check against + * @param mixed $data Value to check against. + * @param int $flags A bitmask of EMPTY_* flags which specify what is empty * @return bool */ - protected function _fieldIsEmpty($data) + protected function isEmpty(mixed $data, int $flags): bool { - if (empty($data) && !is_bool($data) && !is_numeric($data)) { + if ($data === null) { + return true; + } + + if ($data === '' && ($flags & self::EMPTY_STRING)) { + return true; + } + + $arrayTypes = self::EMPTY_ARRAY | self::EMPTY_DATE | self::EMPTY_TIME; + if ($data === [] && ($flags & $arrayTypes)) { return true; } - $isArray = is_array($data); - if ($isArray && (isset($data['year']) || isset($data['hour']))) { - $value = implode('', $data); - return strlen($value) === 0; + if (is_array($data)) { + $allFieldsAreEmpty = true; + foreach ($data as $field) { + if ($field !== null && $field !== '') { + $allFieldsAreEmpty = false; + break; + } + } + + if ($allFieldsAreEmpty) { + if (($flags & self::EMPTY_DATE) && isset($data['year'])) { + return true; + } + + if (($flags & self::EMPTY_TIME) && isset($data['hour'])) { + return true; + } + } } - if ($isArray && isset($data['name'], $data['type'], $data['tmp_name'], $data['error'])) { - return (int)$data['error'] === UPLOAD_ERR_NO_FILE; + + if ( + ($flags & self::EMPTY_FILE) + && $data instanceof UploadedFileInterface + && $data->getError() === UPLOAD_ERR_NO_FILE + ) { + return true; } return false; @@ -1872,21 +3177,27 @@ protected function _fieldIsEmpty($data) * @param \Cake\Validation\ValidationSet $rules the list of rules for a field * @param array $data the full data passed to the validator * @param bool $newRecord whether is it a new record or an existing one - * @return array - */ - protected function _processRules($field, ValidationSet $rules, $data, $newRecord) - { + * @param array $context Additional validation context. + * @return array + */ + protected function _processRules( + string $field, + ValidationSet $rules, + array $data, + bool $newRecord, + array $context = [], + ): array { $errors = []; - // Loading default provider in case there is none - $this->getProvider('default'); - $message = 'The provided value is invalid'; + $context = compact('newRecord', 'data', 'field') + $context; - if ($this->_useI18n) { + if (!$this->_useI18n) { + $message = 'The provided value is invalid'; + } else { $message = __d('cake', 'The provided value is invalid'); } foreach ($rules as $name => $rule) { - $result = $rule->process($data[$field], $this->_providers, compact('newRecord', 'data', 'field')); + $result = $rule->process($data[$field], $this->_providers, $context); if ($result === true) { continue; } @@ -1910,9 +3221,9 @@ protected function _processRules($field, ValidationSet $rules, $data, $newRecord /** * Get the printable version of this object. * - * @return array + * @return array */ - public function __debugInfo() + public function __debugInfo(): array { $fields = []; foreach ($this->_fields as $name => $fieldSet) { @@ -1926,9 +3237,11 @@ public function __debugInfo() return [ '_presenceMessages' => $this->_presenceMessages, '_allowEmptyMessages' => $this->_allowEmptyMessages, + '_allowEmptyFlags' => $this->_allowEmptyFlags, '_useI18n' => $this->_useI18n, + '_stopOnFailure' => $this->_stopOnFailure, '_providers' => array_keys($this->_providers), - '_fields' => $fields + '_fields' => $fields, ]; } } diff --git a/src/Validation/ValidatorAwareInterface.php b/src/Validation/ValidatorAwareInterface.php index e7f67b2a073..ee7425506bd 100644 --- a/src/Validation/ValidatorAwareInterface.php +++ b/src/Validation/ValidatorAwareInterface.php @@ -1,16 +1,18 @@ */ - protected $_validatorClass = '\Cake\Validation\Validator'; + protected string $_validatorClass = Validator::class; /** * A list of validation objects indexed by name * - * @var \Cake\Validation\Validator[] - */ - protected $_validators = []; - - /** - * Returns the validation rules tagged with $name. It is possible to have - * multiple different named validation sets, this is useful when you need - * to use varying rules when saving from different routines in your system. - * - * There are two different ways of creating and naming validation sets: by - * creating a new method inside your own Table subclass, or by building - * the validator object yourself and storing it using this method. - * - * For example, if you wish to create a validation set called 'forSubscription', - * you will need to create a method in your Table subclass as follows: - * - * ``` - * public function validationForSubscription($validator) - * { - * return $validator - * ->add('email', 'valid-email', ['rule' => 'email']) - * ->add('password', 'valid', ['rule' => 'notBlank']) - * ->requirePresence('username'); - * } - * ``` - * - * Otherwise, you can build the object by yourself and store it in the Table object: - * - * ``` - * $validator = new \Cake\Validation\Validator($table); - * $validator - * ->add('email', 'valid-email', ['rule' => 'email']) - * ->add('password', 'valid', ['rule' => 'notBlank']) - * ->allowEmpty('bio'); - * $table->validator('forSubscription', $validator); - * ``` - * - * You can implement the method in `validationDefault` in your Table subclass - * should you wish to have a validation set that applies in cases where no other - * set is specified. - * - * @param string|null $name the name of the validation set to return - * @param \Cake\Validation\Validator|null $validator The validator instance to store, - * use null to get a validator. - * @return \Cake\Validation\Validator - * @throws \RuntimeException - * @deprecated 3.5.0 Use getValidator/setValidator instead. + * @var array<\Cake\Validation\Validator> */ - public function validator($name = null, Validator $validator = null) - { - if ($validator !== null) { - $name = $name ?: self::DEFAULT_VALIDATOR; - $this->setValidator($name, $validator); - } - - return $this->getValidator($name); - } + protected array $_validators = []; /** * Returns the validation rules tagged with $name. It is possible to have * multiple different named validation sets, this is useful when you need * to use varying rules when saving from different routines in your system. * - * If a validator has not been set earlier, this method will build a valiator + * If a validator has not been set earlier, this method will build a validator * using a method inside your class. * * For example, if you wish to create a validation set called 'forSubscription', @@ -121,11 +70,12 @@ public function validator($name = null, Validator $validator = null) * ``` * public function validationForSubscription($validator) * { - * return $validator - * ->add('email', 'valid-email', ['rule' => 'email']) - * ->add('password', 'valid', ['rule' => 'notBlank']) - * ->requirePresence('username'); + * return $validator + * ->add('email', 'valid-email', ['rule' => 'email']) + * ->add('password', 'valid', ['rule' => 'notBlank']) + * ->requirePresence('username'); * } + * * $validator = $this->getValidator('forSubscription'); * ``` * @@ -140,12 +90,11 @@ public function validator($name = null, Validator $validator = null) * @param string|null $name The name of the validation set to return. * @return \Cake\Validation\Validator */ - public function getValidator($name = null) + public function getValidator(?string $name = null): Validator { - $name = $name ?: self::DEFAULT_VALIDATOR; + $name = $name ?: static::DEFAULT_VALIDATOR; if (!isset($this->_validators[$name])) { - $validator = $this->createValidator($name); - $this->setValidator($name, $validator); + $this->setValidator($name, $this->createValidator($name)); } return $this->_validators[$name]; @@ -160,26 +109,34 @@ public function getValidator($name = null) * * @param string $name The name of the validation set to create. * @return \Cake\Validation\Validator - * @throws \RuntimeException + * @throws \InvalidArgumentException */ - protected function createValidator($name) + protected function createValidator(string $name): Validator { $method = 'validation' . ucfirst($name); if (!$this->validationMethodExists($method)) { - $message = sprintf('The %s::%s() validation method does not exists.', __CLASS__, $method); - throw new RuntimeException($message); + $message = sprintf('The `%s::%s()` validation method does not exist.', static::class, $method); + throw new InvalidArgumentException($message); } - $validator = new $this->_validatorClass; + $validator = new $this->_validatorClass(); $validator = $this->$method($validator); if ($this instanceof EventDispatcherInterface) { - $event = defined(self::class . '::BUILD_VALIDATOR_EVENT') ? self::BUILD_VALIDATOR_EVENT : 'Model.buildValidator'; + $event = defined(static::class . '::BUILD_VALIDATOR_EVENT') + ? static::BUILD_VALIDATOR_EVENT + : 'Model.buildValidator'; $this->dispatchEvent($event, compact('validator', 'name')); } - if (!$validator instanceof Validator) { - throw new RuntimeException(sprintf('The %s::%s() validation method must return an instance of %s.', __CLASS__, $method, Validator::class)); - } + assert( + $validator instanceof Validator, + sprintf( + 'The `%s::%s()` validation method must return an instance of `%s`.', + static::class, + $method, + Validator::class, + ), + ); return $validator; } @@ -190,11 +147,11 @@ protected function createValidator($name) * You can build the object by yourself and store it in your object: * * ``` - * $validator = new \Cake\Validation\Validator($table); + * $validator = new \Cake\Validation\Validator(); * $validator - * ->add('email', 'valid-email', ['rule' => 'email']) - * ->add('password', 'valid', ['rule' => 'notBlank']) - * ->allowEmpty('bio'); + * ->add('email', 'valid-email', ['rule' => 'email']) + * ->add('password', 'valid', ['rule' => 'notBlank']) + * ->allowEmpty('bio'); * $this->setValidator('forSubscription', $validator); * ``` * @@ -202,21 +159,21 @@ protected function createValidator($name) * @param \Cake\Validation\Validator $validator Validator object to be set. * @return $this */ - public function setValidator($name, Validator $validator) + public function setValidator(string $name, Validator $validator) { - $validator->setProvider(self::VALIDATOR_PROVIDER_NAME, $this); + $validator->setProvider(static::VALIDATOR_PROVIDER_NAME, $this); $this->_validators[$name] = $validator; return $this; } /** - * Checks whether or not a validator has been set. + * Checks whether a validator has been set. * * @param string $name The name of a validator. * @return bool */ - public function hasValidator($name) + public function hasValidator(string $name): bool { $method = 'validation' . ucfirst($name); if ($this->validationMethodExists($method)) { @@ -232,7 +189,7 @@ public function hasValidator($name) * @param string $name Validation method name. * @return bool */ - protected function validationMethodExists($name) + protected function validationMethodExists(string $name): bool { return method_exists($this, $name); } @@ -245,7 +202,7 @@ protected function validationMethodExists($name) * add some rules to it. * @return \Cake\Validation\Validator */ - public function validationDefault(Validator $validator) + public function validationDefault(Validator $validator): Validator { return $validator; } diff --git a/src/Validation/composer.json b/src/Validation/composer.json index 6fb11d5b956..a378cb81ee1 100644 --- a/src/Validation/composer.json +++ b/src/Validation/composer.json @@ -22,16 +22,27 @@ "source": "https://github.com/cakephp/validation" }, "require": { - "php": ">=5.6.0", - "cakephp/utility": "^3.0.0", - "psr/http-message": "^1.0.0" + "php": ">=8.2", + "cakephp/core": "^5.4.0", + "cakephp/utility": "^5.4.0", + "psr/http-message": "^1.1 || ^2.0" }, - "suggest": { - "cakephp/i18n": "If you want to use Validation::localizedTime()" + "require-dev": { + "cakephp/i18n": "^5.4.0" }, "autoload": { "psr-4": { "Cake\\Validation\\": "." } + }, + "suggest": { + "cakephp/i18n": "If you want to use Validation::localizedTime()" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-5.next": "5.5.x-dev" + } } } diff --git a/src/Validation/phpstan.neon.dist b/src/Validation/phpstan.neon.dist new file mode 100644 index 00000000000..6197f9e96ed --- /dev/null +++ b/src/Validation/phpstan.neon.dist @@ -0,0 +1,15 @@ +parameters: + level: 8 + treatPhpDocTypesAsCertain: false + bootstrapFiles: + - tests/phpstan-bootstrap.php + paths: + - ./ + excludePaths: + - vendor/ + ignoreErrors: + - + identifier: trait.unused + - + identifier: missingType.iterableValue + - "#^Parameter \\#1 \\$objectOrClass of class ReflectionEnum constructor expects class\\-string\\\\|UnitEnum, class\\-string given\\.$#" diff --git a/src/Validation/tests/phpstan-bootstrap.php b/src/Validation/tests/phpstan-bootstrap.php new file mode 100644 index 00000000000..0e60e7fbe4e --- /dev/null +++ b/src/Validation/tests/phpstan-bootstrap.php @@ -0,0 +1,60 @@ + 'App', + 'encoding' => 'UTF-8', +]); + +ini_set('intl.default_locale', 'en_US'); +ini_set('session.gc_divisor', '1'); +ini_set('assert.exception', '1'); diff --git a/src/View/AjaxView.php b/src/View/AjaxView.php index b35dc5cc2ab..d8a800e5889 100644 --- a/src/View/AjaxView.php +++ b/src/View/AjaxView.php @@ -1,4 +1,6 @@ type('ajax'); - } + public static function contentType(): string + { + return 'text/html'; } } diff --git a/src/View/Cell.php b/src/View/Cell.php index ba104442eb2..81ae83ca2f9 100644 --- a/src/View/Cell.php +++ b/src/View/Cell.php @@ -1,4 +1,6 @@ */ - protected $_validCellOptions = []; + protected array $_validCellOptions = []; /** * Caching setup. * * @var array|bool */ - protected $_cache = false; + protected array|bool $_cache = false; /** * Constructor. * - * @param \Cake\Http\ServerRequest|null $request The request to use in the cell. - * @param \Cake\Http\Response|null $response The response to use in the cell. - * @param \Cake\Event\EventManager|null $eventManager The eventManager to bind events to. - * @param array $cellOptions Cell options to apply. + * @param \Cake\Http\ServerRequest $request The request to use in the cell. + * @param \Cake\Http\Response $response The response to use in the cell. + * @param \Cake\Event\EventManagerInterface|null $eventManager The eventManager to bind events to. + * @param array $cellOptions Cell options to apply. */ public function __construct( - ServerRequest $request = null, - Response $response = null, - EventManager $eventManager = null, - array $cellOptions = [] + ServerRequest $request, + Response $response, + ?EventManagerInterface $eventManager = null, + array $cellOptions = [], ) { if ($eventManager !== null) { $this->setEventManager($eventManager); } $this->request = $request; $this->response = $response; - $this->modelFactory('Table', [$this->getTableLocator(), 'get']); - $this->_validCellOptions = array_merge(['action', 'args'], $this->_validCellOptions); + $this->_validCellOptions = array_merge(['action', 'args', 'plugin'], $this->_validCellOptions); foreach ($this->_validCellOptions as $var) { if (isset($cellOptions[$var])) { $this->{$var} = $cellOptions[$var]; @@ -160,6 +139,37 @@ public function __construct( if (!empty($cellOptions['cache'])) { $this->_cache = $cellOptions['cache']; } + + $this->initialize(); + } + + /** + * Initialization hook method. + * + * Implement this method to avoid having to overwrite + * the constructor and calling parent::__construct(). + * + * @return void + */ + public function initialize(): void + { + } + + /** + * Get the view builder being used. + * + * @return \Cake\View\ViewBuilder + */ + public function viewBuilder(): ViewBuilder + { + if ($this->_viewBuilder === null) { + $this->_viewBuilder = new ViewBuilder(); + if ($this->plugin !== null) { + $this->_viewBuilder->setPlugin($this->plugin); + } + } + + return $this->_viewBuilder; } /** @@ -168,54 +178,58 @@ public function __construct( * @param string|null $template Custom template name to render. If not provided (null), the last * value will be used. This value is automatically set by `CellTrait::cell()`. * @return string The rendered cell. - * @throws \Cake\View\Exception\MissingCellViewException When a MissingTemplateException is raised during rendering. + * @throws \Cake\View\Exception\MissingCellTemplateException|\BadMethodCallException */ - public function render($template = null) + public function render(?string $template = null): string { $cache = []; if ($this->_cache) { $cache = $this->_cacheConfig($this->action, $template); } - $render = function () use ($template) { + $render = function () use ($template): string { try { + $this->dispatchEvent('Cell.beforeAction', [$this, $this->action, $this->args]); $reflect = new ReflectionMethod($this, $this->action); $reflect->invokeArgs($this, $this->args); - } catch (ReflectionException $e) { + $this->dispatchEvent('Cell.afterAction', [$this, $this->action, $this->args]); + } catch (ReflectionException) { throw new BadMethodCallException(sprintf( - 'Class %s does not have a "%s" method.', - get_class($this), - $this->action + 'Class `%s` does not have a `%s` method.', + static::class, + $this->action, )); } $builder = $this->viewBuilder(); - if ($template !== null && - strpos($template, '/') === false && - strpos($template, '.') === false - ) { - $template = Inflector::underscore($template); - } - if ($template === null) { - $template = $builder->getTemplate() ?: $this->template; + if ($template !== null) { + $builder->setTemplate($template); } - $builder->setLayout(false) - ->setTemplate($template); - $className = get_class($this); + $className = static::class; $namePrefix = '\View\Cell\\'; $name = substr($className, strpos($className, $namePrefix) + strlen($namePrefix)); $name = substr($name, 0, -4); if (!$builder->getTemplatePath()) { - $builder->setTemplatePath('Cell' . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $name)); + $builder->setTemplatePath( + static::TEMPLATE_FOLDER . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $name), + ); } + $template = $builder->getTemplate(); - $this->View = $this->createView(); + $view = $this->createView(); try { - return $this->View->render($template); + return $view->render($template, false); } catch (MissingTemplateException $e) { - throw new MissingCellViewException(['file' => $template, 'name' => $name]); + $attributes = $e->getAttributes(); + throw new MissingCellTemplateException( + $name, + $attributes['file'], + $attributes['paths'], + null, + $e, + ); } }; @@ -235,17 +249,17 @@ public function render($template = null) * @param string|null $template The name of the template to be rendered. * @return array The cache configuration. */ - protected function _cacheConfig($action, $template = null) + protected function _cacheConfig(string $action, ?string $template = null): array { - if (empty($this->_cache)) { + if (!$this->_cache) { return []; } $template = $template ?: 'default'; - $key = 'cell_' . Inflector::underscore(get_class($this)) . '_' . $action . '_' . $template; + $key = 'cell_' . Inflector::underscore(static::class) . '_' . $action . '_' . $template; $key = str_replace('\\', '_', $key); $default = [ 'config' => 'default', - 'key' => $key + 'key' => $key, ]; if ($this->_cache === true) { return $default; @@ -265,34 +279,43 @@ protected function _cacheConfig($action, $template = null) * @return string Rendered cell * @throws \Error Include error details for PHP 7 fatal errors. */ - public function __toString() + public function __toString(): string { try { return $this->render(); } catch (Exception $e) { - trigger_error(sprintf('Could not render cell - %s [%s, line %d]', $e->getMessage(), $e->getFile(), $e->getLine()), E_USER_WARNING); + trigger_error(sprintf( + 'Could not render cell - %s [%s, line %d]', + $e->getMessage(), + $e->getFile(), + $e->getLine(), + ), E_USER_WARNING); return ''; + /** @phpstan-ignore-next-line */ } catch (Error $e) { - throw new Error(sprintf('Could not render cell - %s [%s, line %d]', $e->getMessage(), $e->getFile(), $e->getLine())); + throw new Error(sprintf( + 'Could not render cell - %s [%s, line %d]', + $e->getMessage(), + $e->getFile(), + $e->getLine(), + ), 0, $e); } } /** * Debug info. * - * @return array + * @return array */ - public function __debugInfo() + public function __debugInfo(): array { return [ - 'plugin' => $this->plugin, 'action' => $this->action, 'args' => $this->args, - 'template' => $this->template, - 'viewClass' => $this->viewClass, 'request' => $this->request, 'response' => $this->response, + 'viewBuilder' => $this->viewBuilder(), ]; } } diff --git a/src/View/CellTrait.php b/src/View/CellTrait.php index cee1eff44a9..d3177373046 100644 --- a/src/View/CellTrait.php +++ b/src/View/CellTrait.php @@ -1,4 +1,6 @@ 'v1', 'a2' => 'v2'])` maps to `View\Cell\TagCloud::smallList(v1, v2)` - * @param array $options Options for Cell's constructor + * @param array $options Options for Cell's constructor * @return \Cake\View\Cell The cell instance * @throws \Cake\View\Exception\MissingCellException If Cell class was not found. - * @throws \BadMethodCallException If Cell class does not specified cell action. */ - protected function cell($cell, array $data = [], array $options = []) + protected function cell(string $cell, array $data = [], array $options = []): Cell { $parts = explode('::', $cell); if (count($parts) === 2) { - list($pluginAndCell, $action) = [$parts[0], $parts[1]]; + [$pluginAndCell, $action] = [$parts[0], $parts[1]]; } else { - list($pluginAndCell, $action) = [$parts[0], 'display']; + [$pluginAndCell, $action] = [$parts[0], 'display']; } - list($plugin) = pluginSplit($pluginAndCell); + [$plugin] = pluginSplit($pluginAndCell); $className = App::className($pluginAndCell, 'View/Cell', 'Cell'); if (!$className) { throw new MissingCellException(['className' => $pluginAndCell . 'Cell']); } - if (!empty($data)) { - $data = array_values($data); - } $options = ['action' => $action, 'args' => $data] + $options; - $cell = $this->_createCell($className, $action, $plugin, $options); - return $cell; + return $this->_createCell($className, $action, $plugin, $options); } /** @@ -86,44 +83,44 @@ protected function cell($cell, array $data = [], array $options = []) * * @param string $className The cell classname. * @param string $action The action name. - * @param string $plugin The plugin name. - * @param array $options The constructor options for the cell. + * @param string|null $plugin The plugin name. + * @param array $options The constructor options for the cell. * @return \Cake\View\Cell */ - protected function _createCell($className, $action, $plugin, $options) + protected function _createCell(string $className, string $action, ?string $plugin, array $options): Cell { - /* @var \Cake\View\Cell $instance */ + if ($plugin) { + $options['plugin'] = $plugin; + } + + /** @var \Cake\View\Cell $instance */ $instance = new $className($this->request, $this->response, $this->getEventManager(), $options); - $instance->template = Inflector::underscore($action); $builder = $instance->viewBuilder(); - if (!empty($plugin)) { + $builder->setTemplate(Inflector::underscore($action)); + + if ($plugin) { $builder->setPlugin($plugin); } - if (!empty($this->helpers)) { - $builder->setHelpers($this->helpers); - $instance->helpers = $this->helpers; - } if ($this instanceof View) { - if (!empty($this->theme)) { + $builder->addHelpers($this->helpers); + + if ($this->theme) { $builder->setTheme($this->theme); } - $class = get_class($this); - $builder->setClassName($class); - $instance->viewClass = $class; + $builder->setClassName(static::class); return $instance; } if (method_exists($this, 'viewBuilder')) { $builder->setTheme($this->viewBuilder()->getTheme()); - } - if (isset($this->viewClass)) { - $builder->setClassName($this->viewClass); - $instance->viewClass = $this->viewClass; + if ($this->viewBuilder()->getClassName() !== null) { + $builder->setClassName($this->viewBuilder()->getClassName()); + } } return $instance; diff --git a/src/View/Exception/MissingCellException.php b/src/View/Exception/MissingCellException.php index 1c2a4a29336..ab5e2aab2bb 100644 --- a/src/View/Exception/MissingCellException.php +++ b/src/View/Exception/MissingCellException.php @@ -1,4 +1,6 @@ $paths The path list that template could not be found in. + * @param int|null $code The code of the error. + * @param \Throwable|null $previous the previous exception. + */ + public function __construct( + string $name, + string $file, + array $paths = [], + ?int $code = null, + ?Throwable $previous = null, + ) { + $this->name = $name; + + parent::__construct($file, $paths, $code, $previous); + } + + /** + * Get the passed in attributes + * + * @return array{name: string, file: string, paths: array} + */ + public function getAttributes(): array + { + return [ + 'name' => $this->name, + 'file' => $this->file, + 'paths' => $this->paths, + ]; + } +} diff --git a/src/View/Exception/MissingCellViewException.php b/src/View/Exception/MissingCellViewException.php deleted file mode 100644 index 330e1ea90d3..00000000000 --- a/src/View/Exception/MissingCellViewException.php +++ /dev/null @@ -1,24 +0,0 @@ - + */ + protected array $paths; + + /** + * @var string + */ + protected string $type = 'Template'; + + /** + * Constructor + * + * @param array|string $file Either the file name as a string, or in an array for backwards compatibility. + * @param array $paths The path list that template could not be found in. + * @param int|null $code The code of the error. + * @param \Throwable|null $previous the previous exception. + */ + public function __construct(array|string $file, array $paths = [], ?int $code = null, ?Throwable $previous = null) + { + if (is_array($file)) { + $this->filename = (string)array_pop($file); + $this->templateName = array_pop($file); + } else { + $this->filename = $file; + $this->templateName = null; + } + $this->paths = $paths; + + parent::__construct($this->formatMessage(), $code, $previous); + } + + /** + * Get the formatted exception message. + * + * @return string + */ + public function formatMessage(): string + { + $name = $this->templateName ?? $this->filename; + $message = "{$this->type} file `{$name}` could not be found."; + if ($this->paths) { + $message .= "\n\nThe following paths were searched:\n\n"; + foreach ($this->paths as $path) { + $message .= "- `{$path}{$this->filename}`\n"; + } + } + + return $message; + } - protected $_messageTemplate = 'Template file "%s" is missing.'; + /** + * Get the passed in attributes + * + * @return array{file: string, paths: array} + */ + public function getAttributes(): array + { + return [ + 'file' => $this->filename, + 'paths' => $this->paths, + ]; + } } diff --git a/src/View/Exception/MissingViewException.php b/src/View/Exception/MissingViewException.php index 5fe5e073b6b..9f587dda43c 100644 --- a/src/View/Exception/MissingViewException.php +++ b/src/View/Exception/MissingViewException.php @@ -1,4 +1,6 @@ [ + * 'id' => '1', + * 'title' => 'First post!', + * ], * 'schema' => [ * 'id' => ['type' => 'integer'], * 'title' => ['type' => 'string', 'length' => 255], @@ -51,39 +59,34 @@ * ] * ], * 'defaults' => [ - * 'id' => 1, - * 'title' => 'First post!', - * ] + * 'title' => 'Default title', + * ], + * 'required' => [ + * 'id' => true, // will use default required message + * 'title' => 'Please enter a title', + * 'body' => false, + * ], * ]; * ``` */ class ArrayContext implements ContextInterface { - - /** - * The request object. - * - * @var \Cake\Http\ServerRequest - */ - protected $_request; - /** * Context data for this object. * - * @var array + * @var array */ - protected $_context; + protected array $_context; /** * Constructor. * - * @param \Cake\Http\ServerRequest $request The request object. * @param array $context Context info. */ - public function __construct(ServerRequest $request, array $context) + public function __construct(array $context) { - $this->_request = $request; $context += [ + 'data' => [], 'schema' => [], 'required' => [], 'defaults' => [], @@ -95,18 +98,19 @@ public function __construct(ServerRequest $request, array $context) /** * Get the fields used in the context as a primary key. * - * @return array + * @return array */ - public function primaryKey() + public function getPrimaryKey(): array { - if (empty($this->_context['schema']['_constraints']) || + if ( + empty($this->_context['schema']['_constraints']) || !is_array($this->_context['schema']['_constraints']) ) { return []; } foreach ($this->_context['schema']['_constraints'] as $data) { if (isset($data['type']) && $data['type'] === 'primary') { - return isset($data['columns']) ? (array)$data['columns'] : []; + return (array)($data['columns'] ?? []); } } @@ -114,17 +118,17 @@ public function primaryKey() } /** - * {@inheritDoc} + * @inheritDoc */ - public function isPrimaryKey($field) + public function isPrimaryKey(string $field): bool { - $primaryKey = $this->primaryKey(); + $primaryKey = $this->getPrimaryKey(); - return in_array($field, $primaryKey); + return in_array($field, $primaryKey, true); } /** - * Returns whether or not this form is for a create operation. + * Returns whether this form is for a create operation. * * For this method to return true, both the primary key constraint * must be defined in the 'schema' data, and the 'defaults' data must @@ -132,9 +136,9 @@ public function isPrimaryKey($field) * * @return bool */ - public function isCreate() + public function isCreate(): bool { - $primary = $this->primaryKey(); + $primary = $this->getPrimaryKey(); foreach ($primary as $column) { if (!empty($this->_context['defaults'][$column])) { return false; @@ -147,29 +151,29 @@ public function isCreate() /** * Get the current value for a given field. * - * This method will coalesce the current request data and the 'defaults' - * array. + * This method will coalesce the current data and the 'defaults' array. * * @param string $field A dot separated path to the field a value * is needed for. - * @param array $options Options: - * - `default`: Default value to return if no value found in request - * data or context record. + * @param array $options Options: + * + * - `default`: Default value to return if no value found in data or + * context record. * - `schemaDefault`: Boolean indicating whether default value from - * context's schema should be used if it's not explicitly provided. + * context's schema should be used if it's not explicitly provided. * @return mixed */ - public function val($field, $options = []) + public function val(string $field, array $options = []): mixed { $options += [ 'default' => null, - 'schemaDefault' => true + 'schemaDefault' => true, ]; - $val = $this->_request->getData($field); - if ($val !== null) { - return $val; + if (Hash::check($this->_context['data'], $field)) { + return Hash::get($this->_context['data'], $field); } + if ($options['default'] !== null || !$options['schemaDefault']) { return $options['default']; } @@ -177,7 +181,7 @@ public function val($field, $options = []) return null; } - // Using Hash::check here incase the default value is actually null + // Using Hash::check here in case the default value is actually null if (Hash::check($this->_context['defaults'], $field)) { return Hash::get($this->_context['defaults'], $field); } @@ -191,29 +195,72 @@ public function val($field, $options = []) * In this context class, this is simply defined by the 'required' array. * * @param string $field A dot separated path to check required-ness for. - * @return bool + * @return bool|null */ - public function isRequired($field) + public function isRequired(string $field): ?bool { if (!is_array($this->_context['required'])) { - return false; + return null; + } + + $required = Hash::get($this->_context['required'], $field) + ?? Hash::get($this->_context['required'], $this->stripNesting($field)); + + if ($required || $required === '0') { + return true; + } + + return $required !== null ? (bool)$required : null; + } + + /** + * @inheritDoc + */ + public function getRequiredMessage(string $field): ?string + { + if (!is_array($this->_context['required'])) { + return null; + } + $required = Hash::get($this->_context['required'], $field) + ?? Hash::get($this->_context['required'], $this->stripNesting($field)); + + if ($required === false) { + return null; } - $required = Hash::get($this->_context['required'], $field); - if ($required === null) { - $required = Hash::get($this->_context['required'], $this->stripNesting($field)); + + if ($required === true) { + return __d('cake', 'This field cannot be left empty'); } - return (bool)$required; + return $required; } /** - * {@inheritDoc} + * Get field length from validation + * + * In this context class, this is simply defined by the 'length' array. + * + * @param string $field A dot separated path to check required-ness for. + * @return int|null */ - public function fieldNames() + public function getMaxLength(string $field): ?int + { + if (!is_array($this->_context['schema'])) { + return null; + } + + return Hash::get($this->_context['schema'], "{$field}.length"); + } + + /** + * @inheritDoc + */ + public function fieldNames(): array { $schema = $this->_context['schema']; unset($schema['_constraints'], $schema['_indexes']); + /** @var array */ return array_keys($schema); } @@ -221,21 +268,19 @@ public function fieldNames() * Get the abstract field type for a given field name. * * @param string $field A dot separated path to get a schema type for. - * @return null|string An abstract data type or null. - * @see \Cake\Database\Type + * @return string|null An abstract data type or null. + * @see \Cake\Database\TypeFactory */ - public function type($field) + public function type(string $field): ?string { if (!is_array($this->_context['schema'])) { return null; } - $schema = Hash::get($this->_context['schema'], $field); - if ($schema === null) { - $schema = Hash::get($this->_context['schema'], $this->stripNesting($field)); - } + $schema = Hash::get($this->_context['schema'], $field) + ?? Hash::get($this->_context['schema'], $this->stripNesting($field)); - return isset($schema['type']) ? $schema['type'] : null; + return $schema['type'] ?? null; } /** @@ -244,33 +289,33 @@ public function type($field) * @param string $field A dot separated path to get additional data on. * @return array An array of data describing the additional attributes on a field. */ - public function attributes($field) + public function attributes(string $field): array { if (!is_array($this->_context['schema'])) { return []; } - $schema = Hash::get($this->_context['schema'], $field); - if ($schema === null) { - $schema = Hash::get($this->_context['schema'], $this->stripNesting($field)); - } - $whitelist = ['length' => null, 'precision' => null]; + $schema = Hash::get($this->_context['schema'], $field) + ?? Hash::get($this->_context['schema'], $this->stripNesting($field)); - return array_intersect_key((array)$schema, $whitelist); + return array_intersect_key( + (array)$schema, + array_flip(static::VALID_ATTRIBUTES), + ); } /** - * Check whether or not a field has an error attached to it + * Check whether a field has an error attached to it * * @param string $field A dot separated path to check errors on. * @return bool Returns true if the errors for the field are not empty. */ - public function hasError($field) + public function hasError(string $field): bool { if (empty($this->_context['errors'])) { return false; } - return (bool)Hash::check($this->_context['errors'], $field); + return Hash::check($this->_context['errors'], $field); } /** @@ -280,13 +325,13 @@ public function hasError($field) * @return array An array of errors, an empty array will be returned when the * context has no errors. */ - public function error($field) + public function error(string $field): array { if (empty($this->_context['errors'])) { return []; } - return Hash::get($this->_context['errors'], $field); + return (array)Hash::get($this->_context['errors'], $field); } /** @@ -297,8 +342,8 @@ public function error($field) * @param string $field A dot separated path * @return string A string with stripped numeric nesting */ - protected function stripNesting($field) + protected function stripNesting(string $field): string { - return preg_replace('/\.\d*\./', '.', $field); + return (string)preg_replace('/\.\d*\./', '.', $field); } } diff --git a/src/View/Form/ContextFactory.php b/src/View/Form/ContextFactory.php index 8956f5cbac8..a34989bb32d 100644 --- a/src/View/Form/ContextFactory.php +++ b/src/View/Form/ContextFactory.php @@ -1,4 +1,6 @@ */ - protected $providers = []; + protected array $providers = []; /** * Constructor. @@ -51,43 +52,53 @@ public function __construct(array $providers = []) * * @param array $providers Array of provider callables. Each element should * be of form `['type' => 'a-string', 'callable' => ..]` - * @return \Cake\View\Form\ContextFactory + * @return static */ - public static function createWithDefaults(array $providers = []) + public static function createWithDefaults(array $providers = []): static { $providers = [ [ 'type' => 'orm', 'callable' => function ($request, $data) { - if (is_array($data['entity']) || $data['entity'] instanceof Traversable) { + if ($data['entity'] instanceof EntityInterface) { + return new EntityContext($data); + } + if (isset($data['table'])) { + return new EntityContext($data); + } + if (is_iterable($data['entity'])) { $pass = (new Collection($data['entity']))->first() !== null; if ($pass) { - return new EntityContext($request, $data); + return new EntityContext($data); } + + return new NullContext($data); } - if ($data['entity'] instanceof EntityInterface) { - return new EntityContext($request, $data); - } - if (is_array($data['entity']) && empty($data['entity']['schema'])) { - return new EntityContext($request, $data); + }, + ], + [ + 'type' => 'form', + 'callable' => function ($request, $data) { + if ($data['entity'] instanceof Form) { + return new FormContext($data); } - } + }, ], [ 'type' => 'array', 'callable' => function ($request, $data) { if (is_array($data['entity']) && isset($data['entity']['schema'])) { - return new ArrayContext($request, $data['entity']); + return new ArrayContext($data['entity']); } - } + }, ], [ - 'type' => 'form', + 'type' => 'null', 'callable' => function ($request, $data) { - if ($data['entity'] instanceof Form) { - return new FormContext($request, $data); + if ($data['entity'] === null) { + return new NullContext($data); } - } + }, ], ] + $providers; @@ -109,7 +120,7 @@ public static function createWithDefaults(array $providers = []) * when the form context is the correct type. * @return $this */ - public function addProvider($type, callable $check) + public function addProvider(string $type, callable $check) { $this->providers = [$type => ['type' => $type, 'callable' => $check]] + $this->providers; @@ -123,15 +134,15 @@ public function addProvider($type, callable $check) * If no type can be matched a NullContext will be returned. * * @param \Cake\Http\ServerRequest $request Request instance. - * @param array $data The data to get a context provider for. + * @param array $data The data to get a context provider for. * @return \Cake\View\Form\ContextInterface Context provider. - * @throws \RuntimeException when the context class does not implement the - * ContextInterface. + * @throws \Cake\Core\Exception\CakeException When a context instance cannot be generated for given entity. */ - public function get(ServerRequest $request, array $data = []) + public function get(ServerRequest $request, array $data = []): ContextInterface { $data += ['entity' => null]; + $context = null; foreach ($this->providers as $provider) { $check = $provider['callable']; $context = $check($request, $data); @@ -139,14 +150,12 @@ public function get(ServerRequest $request, array $data = []) break; } } - if (!isset($context)) { - $context = new NullContext($request, $data); - } - if (!($context instanceof ContextInterface)) { - throw new RuntimeException(sprintf( - 'Context providers must return object implementing %s. Got "%s" instead.', - ContextInterface::class, - is_object($context) ? get_class($context) : gettype($context) + + if ($context === null) { + throw new CakeException(sprintf( + 'No context provider found for value of type `%s`.' + . ' Use `null` as 1st argument of FormHelper::create() to create a context-less form.', + get_debug_type($data['entity']), )); } diff --git a/src/View/Form/ContextInterface.php b/src/View/Form/ContextInterface.php index 3daa8f59c8f..b1eeaacd565 100644 --- a/src/View/Form/ContextInterface.php +++ b/src/View/Form/ContextInterface.php @@ -1,4 +1,6 @@ + */ + public const VALID_ATTRIBUTES = ['length', 'precision', 'comment', 'null', 'default']; /** * Get the fields used in the context as a primary key. * - * @return array + * @return array */ - public function primaryKey(); + public function getPrimaryKey(): array; /** * Returns true if the passed field name is part of the primary key for this context @@ -34,14 +40,14 @@ public function primaryKey(); * is needed for. * @return bool */ - public function isPrimaryKey($field); + public function isPrimaryKey(string $field): bool; /** - * Returns whether or not this form is for a create operation. + * Returns whether this form is for a create operation. * * @return bool */ - public function isCreate(); + public function isCreate(): bool; /** * Get the current value for a given field. @@ -49,16 +55,17 @@ public function isCreate(); * Classes implementing this method can optionally have a second argument * `$options`. Valid key for `$options` array are: * - * - `default`: Default value to return if no value found in request - * data or context record. + * - `default`: Default value to return if no value found in data or + * context record. * - `schemaDefault`: Boolean indicating whether default value from - * context's schema should be used if it's not explicitly provided. + * context's schema should be used if it's not explicitly provided. * * @param string $field A dot separated path to the field a value + * @param array $options Options. * is needed for. * @return mixed */ - public function val($field); + public function val(string $field, array $options = []): mixed; /** * Check if a given field is 'required'. @@ -66,25 +73,41 @@ public function val($field); * In this context class, this is simply defined by the 'required' array. * * @param string $field A dot separated path to check required-ness for. - * @return bool + * @return bool|null + */ + public function isRequired(string $field): ?bool; + + /** + * Gets the default "required" error message for a field + * + * @param string $field A dot separated path to the field name + * @return string|null + */ + public function getRequiredMessage(string $field): ?string; + + /** + * Get maximum length of a field from model validation. + * + * @param string $field Field name. + * @return int|null */ - public function isRequired($field); + public function getMaxLength(string $field): ?int; /** - * Get the fieldnames of the top level object in this context. + * Get the field names of the top level object in this context. * - * @return array A list of the field names in the context. + * @return array A list of the field names in the context. */ - public function fieldNames(); + public function fieldNames(): array; /** * Get the abstract field type for a given field name. * * @param string $field A dot separated path to get a schema type for. - * @return null|string An abstract data type or null. - * @see \Cake\Database\Type + * @return string|null An abstract data type or null. + * @see \Cake\Database\TypeFactory */ - public function type($field); + public function type(string $field): ?string; /** * Get an associative array of other attributes for a field name. @@ -92,15 +115,15 @@ public function type($field); * @param string $field A dot separated path to get additional data on. * @return array An array of data describing the additional attributes on a field. */ - public function attributes($field); + public function attributes(string $field): array; /** - * Check whether or not a field has an error attached to it + * Check whether a field has an error attached to it * * @param string $field A dot separated path to check errors on. * @return bool Returns true if the errors for the field are not empty. */ - public function hasError($field); + public function hasError(string $field): bool; /** * Get the errors for a given field @@ -109,5 +132,5 @@ public function hasError($field); * @return array An array of errors, an empty array will be returned when the * context has no errors. */ - public function error($field); + public function error(string $field): array; } diff --git a/src/View/Form/EntityContext.php b/src/View/Form/EntityContext.php index 83c961b15f7..2ce73a4da98 100644 --- a/src/View/Form/EntityContext.php +++ b/src/View/Form/EntityContext.php @@ -1,4 +1,6 @@ */ - protected $_context; + protected array $_context; /** * The name of the top level entity/table object. * * @var string */ - protected $_rootName; + protected string $_rootName; /** - * Boolean to track whether or not the entity is a + * Boolean to track whether the entity is a * collection. * * @var bool */ - protected $_isCollection = false; + protected bool $_isCollection = false; /** * A dictionary of tables * - * @var array + * @var array<\Cake\ORM\Table> */ - protected $_tables = []; + protected array $_tables = []; /** * Dictionary of validators. * - * @var \Cake\Validation\Validator[] + * @var array<\Cake\Validation\Validator> */ - protected $_validator = []; + protected array $_validator = []; /** * Constructor. * - * @param \Cake\Http\ServerRequest $request The request object. - * @param array $context Context info. + * @param array $context Context info. */ - public function __construct(ServerRequest $request, array $context) + public function __construct(array $context) { - $this->_request = $request; $context += [ 'entity' => null, 'table' => null, @@ -112,7 +111,7 @@ public function __construct(ServerRequest $request, array $context) * Prepare some additional data from the context. * * If the table option was provided to the constructor and it - * was a string, ORM\TableRegistry will be used to get the correct table instance. + * was a string, TableLocator will be used to get the correct table instance. * * If an object is provided as the table option, it will be used as is. * @@ -121,44 +120,42 @@ public function __construct(ServerRequest $request, array $context) * like arrays, Collection objects and ResultSets. * * @return void - * @throws \RuntimeException When a table object cannot be located/inferred. + * @throws \Cake\Core\Exception\CakeException When a table object cannot be located/inferred. */ - protected function _prepare() + protected function _prepare(): void { $table = $this->_context['table']; + + /** @var \Cake\Datasource\EntityInterface|iterable<\Cake\Datasource\EntityInterface|array> $entity */ $entity = $this->_context['entity']; - if (empty($table)) { - if (is_array($entity) || $entity instanceof Traversable) { + $this->_isCollection = is_iterable($entity); + + if (!$table) { + if ($this->_isCollection) { + /** @var iterable<\Cake\Datasource\EntityInterface|array> $entity */ foreach ($entity as $e) { $entity = $e; break; } } - $isEntity = $entity instanceof EntityInterface; - if ($isEntity) { - $table = $entity->source(); + if ($entity instanceof EntityInterface) { + $table = $entity->getSource(); } - if (!$table && $isEntity && get_class($entity) !== 'Cake\ORM\Entity') { - list(, $entityClass) = namespaceSplit(get_class($entity)); + if (!$table && $entity instanceof EntityInterface && $entity::class !== Entity::class) { + [, $entityClass] = namespaceSplit($entity::class); $table = Inflector::pluralize($entityClass); } } - if (is_string($table)) { - $table = TableRegistry::get($table); + if (is_string($table) && $table !== '') { + $table = $this->getTableLocator()->get($table); } - if (!($table instanceof RepositoryInterface)) { - throw new RuntimeException( - 'Unable to find table class for current entity' - ); + if (!($table instanceof Table)) { + throw new CakeException('Unable to find table class for current entity.'); } - $this->_isCollection = ( - is_array($entity) || - $entity instanceof Traversable - ); - - $alias = $this->_rootName = $table->getAlias(); + $alias = $table->getAlias(); + $this->_rootName = $alias; $this->_tables[$alias] = $table; } @@ -167,27 +164,30 @@ protected function _prepare() * * Gets the primary key columns from the root entity's schema. * - * @return array + * @return array */ - public function primaryKey() + public function getPrimaryKey(): array { return (array)$this->_tables[$this->_rootName]->getPrimaryKey(); } /** - * {@inheritDoc} + * @inheritDoc */ - public function isPrimaryKey($field) + public function isPrimaryKey(string $field): bool { $parts = explode('.', $field); $table = $this->_getTable($parts); + if (!$table) { + return false; + } $primaryKey = (array)$table->getPrimaryKey(); - return in_array(array_pop($parts), $primaryKey); + return in_array(array_pop($parts), $primaryKey, true); } /** - * Check whether or not this form is a create or update. + * Check whether this form is a create or update. * * If the context is for a single entity, the entity's isNew() method will * be used. If isNew() returns null, a create operation will be assumed. @@ -197,17 +197,17 @@ public function isPrimaryKey($field) * * @return bool */ - public function isCreate() + public function isCreate(): bool { $entity = $this->_context['entity']; - if (is_array($entity) || $entity instanceof Traversable) { + if (is_iterable($entity)) { foreach ($entity as $e) { $entity = $e; break; } } if ($entity instanceof EntityInterface) { - return $entity->isNew() !== false; + return $entity->isNew(); } return true; @@ -219,53 +219,59 @@ public function isCreate() * Traverses the entity data and finds the value for $path. * * @param string $field The dot separated path to the value. - * @param array $options Options: - * - `default`: Default value to return if no value found in request - * data or entity. + * @param array $options Options: + * + * - `default`: Default value to return if no value found in data or + * entity. * - `schemaDefault`: Boolean indicating whether default value from table * schema should be used if it's not explicitly provided. * @return mixed The value of the field or null on a miss. */ - public function val($field, $options = []) + public function val(string $field, array $options = []): mixed { $options += [ 'default' => null, - 'schemaDefault' => true + 'schemaDefault' => true, ]; - $val = $this->_request->getData($field); - if ($val !== null) { - return $val; - } - if (empty($this->_context['entity'])) { + if (!$this->_context['entity']) { return $options['default']; } $parts = explode('.', $field); $entity = $this->entity($parts); - if (end($parts) === '_ids' && !empty($entity)) { + if ($entity && end($parts) === '_ids') { return $this->_extractMultiple($entity, $parts); } if ($entity instanceof EntityInterface) { - $part = array_pop($parts); - $val = $entity->get($part); + $part = end($parts); + + if ($entity instanceof InvalidPropertyInterface) { + $val = $entity->getInvalidField($part); + if ($val !== null) { + return $val; + } + } + + $val = $entity->has($part) ? $entity->get($part) : null; if ($val !== null) { return $val; } - if ($options['default'] !== null + if ( + $options['default'] !== null || !$options['schemaDefault'] || !$entity->isNew() ) { return $options['default']; } - return $this->_schemaDefault($part, $entity); + return $this->_schemaDefault($parts); } if (is_array($entity) || $entity instanceof ArrayAccess) { $key = array_pop($parts); - return isset($entity[$key]) ? $entity[$key] : $options['default']; + return $entity[$key] ?? $options['default']; } return null; @@ -274,18 +280,18 @@ public function val($field, $options = []) /** * Get default value from table schema for given entity field. * - * @param string $field Field name. - * @param \Cake\Datasource\EntityInterface $entity The entity. + * @param array $parts Each one of the parts in a path for a field name * @return mixed */ - protected function _schemaDefault($field, $entity) + protected function _schemaDefault(array $parts): mixed { - $table = $this->_getTable($entity); - if ($table === false) { + $table = $this->_getTable($parts); + if ($table === null) { return null; } + $field = end($parts); $defaults = $table->getSchema()->defaultValues(); - if (!array_key_exists($field, $defaults)) { + if ($field === false || !array_key_exists($field, $defaults)) { return null; } @@ -296,13 +302,13 @@ protected function _schemaDefault($field, $entity) * Helper method used to extract all the primary key values out of an array, The * primary key column is guessed out of the provided $path array * - * @param array|\Traversable $values The list from which to extract primary keys from - * @param array $path Each one of the parts in a path for a field name + * @param mixed $values The list from which to extract primary keys from + * @param array $path Each one of the parts in a path for a field name * @return array|null */ - protected function _extractMultiple($values, $path) + protected function _extractMultiple(mixed $values, array $path): ?array { - if (!(is_array($values) || $values instanceof Traversable)) { + if (!is_iterable($values)) { return null; } $table = $this->_getTable($path, false); @@ -312,18 +318,19 @@ protected function _extractMultiple($values, $path) } /** - * Fetch the leaf entity for the given path. + * Fetch the entity or data value for a given path * - * This method will traverse the given path and find the leaf - * entity. If the path does not contain a leaf entity false - * will be returned. + * This method will traverse the given path and find the entity + * or array value for a given path. * - * @param array|null $path Each one of the parts in a path for a field name + * If you only want the terminal Entity for a path use `leafEntity` instead. + * + * @param array|null $path Each one of the parts in a path for a field name * or null to get the entity passed in constructor context. - * @return \Cake\Datasource\EntityInterface|\Traversable|array|bool - * @throws \RuntimeException When properties cannot be read. + * @return \Cake\Datasource\EntityInterface|iterable|null + * @throws \Cake\Core\Exception\CakeException When properties cannot be read. */ - public function entity($path = null) + public function entity(?array $path = null): EntityInterface|iterable|null { if ($path === null) { return $this->_context['entity']; @@ -331,7 +338,7 @@ public function entity($path = null) $oneElement = count($path) === 1; if ($oneElement && $this->_isCollection) { - return false; + return null; } $entity = $this->_context['entity']; if ($oneElement) { @@ -348,16 +355,15 @@ public function entity($path = null) $prop = $path[$i]; $next = $this->_getProp($entity, $prop); $isLast = ($i === $last); - if (!$isLast && $next === null && $prop !== '_ids') { $table = $this->_getTable($path); - - return $table->newEntity(); + if ($table) { + return $table->newEmptyEntity(); + } } $isTraversable = ( - is_array($next) || - $next instanceof Traversable || + is_iterable($next) || $next instanceof EntityInterface ); if ($isLast || !$isTraversable) { @@ -365,9 +371,75 @@ public function entity($path = null) } $entity = $next; } - throw new RuntimeException(sprintf( - 'Unable to fetch property "%s"', - implode('.', $path) + throw new CakeException(sprintf( + 'Unable to fetch property `%s`.', + implode('.', $path), + )); + } + + /** + * Fetch the terminal or leaf entity for the given path. + * + * Traverse the path until an entity cannot be found. Lists containing + * entities will be traversed if the first element contains an entity. + * Otherwise, the containing Entity will be assumed to be the terminal one. + * + * @param array|null $path Each one of the parts in a path for a field name + * or null to get the entity passed in constructor context. + * @return array Containing the found entity, and remaining un-matched path. + * @throws \Cake\Core\Exception\CakeException When properties cannot be read. + */ + protected function leafEntity(?array $path = null): array + { + if ($path === null) { + return $this->_context['entity']; + } + + $oneElement = count($path) === 1; + if ($oneElement && $this->_isCollection) { + throw new CakeException(sprintf( + 'Unable to fetch property `%s`.', + implode('.', $path), + )); + } + $entity = $this->_context['entity']; + if ($oneElement) { + return [$entity, $path]; + } + + if ($path[0] === $this->_rootName) { + $path = array_slice($path, 1); + } + + $len = count($path); + $leafEntity = $entity; + for ($i = 0; $i < $len; $i++) { + $prop = $path[$i]; + $next = $this->_getProp($entity, $prop); + + // Did not dig into an entity, return the current one. + if (is_array($entity) && (!$next instanceof EntityInterface && !$next instanceof Traversable)) { + return [$leafEntity, array_slice($path, $i - 1)]; + } + + if ($next instanceof EntityInterface) { + $leafEntity = $next; + } + + // If we are at the end of traversable elements + // return the last entity found. + $isTraversable = ( + is_iterable($next) || + $next instanceof EntityInterface + ); + if (!$isTraversable) { + return [$leafEntity, array_slice($path, $i)]; + } + $entity = $next; + } + throw new CakeException(sprintf( + 'Unable to fetch property `%s`.', + implode('.', $path), )); } @@ -378,7 +450,7 @@ public function entity($path = null) * @param string $field The next field to fetch. * @return mixed */ - protected function _getProp($target, $field) + protected function _getProp(mixed $target, string $field): mixed { if (is_array($target) && isset($target[$field])) { return $target[$field]; @@ -388,22 +460,24 @@ protected function _getProp($target, $field) } if ($target instanceof Traversable) { foreach ($target as $i => $val) { - if ($i == $field) { + if ((string)$i === $field) { return $val; } } return false; } + + return null; } /** * Check if a field should be marked as required. * * @param string $field The dot separated path to the field you want to check. - * @return bool + * @return bool|null */ - public function isRequired($field) + public function isRequired(string $field): ?bool { $parts = explode('.', $field); $entity = $this->entity($parts); @@ -415,26 +489,85 @@ public function isRequired($field) $validator = $this->_getValidator($parts); $fieldName = array_pop($parts); + if (!$validator->hasField($fieldName)) { - return false; + return null; + } + // If allowEmpty was given a callable (e.g. allowEmptyString('field', function(...) {})), + // we cannot evaluate it here because we don't have the submitted form data yet. + // Return null so FormHelper skips adding required="required" to the input. + if (is_callable($validator->field($fieldName)->isEmptyAllowed())) { + return null; } if ($this->type($field) !== 'boolean') { - return $validator->isEmptyAllowed($fieldName, $isNew) === false; + return !$validator->isEmptyAllowed($fieldName, $isNew); } return false; } + /** + * @inheritDoc + */ + public function getRequiredMessage(string $field): ?string + { + $parts = explode('.', $field); + + $validator = $this->_getValidator($parts); + $fieldName = array_pop($parts); + if (!$validator->hasField($fieldName)) { + return null; + } + + $ruleset = $validator->field($fieldName); + if ($ruleset->isEmptyAllowed()) { + return null; + } + + return $validator->getNotEmptyMessage($fieldName); + } + + /** + * Get field length from validation + * + * @param string $field The dot separated path to the field you want to check. + * @return int|null + */ + public function getMaxLength(string $field): ?int + { + $parts = explode('.', $field); + $validator = $this->_getValidator($parts); + $fieldName = array_pop($parts); + + if ($validator->hasField($fieldName)) { + foreach ($validator->field($fieldName)->rules() as $rule) { + if ($rule->get('rule') === 'maxLength') { + return $rule->get('pass')[0]; + } + } + } + + $attributes = $this->attributes($field); + if (empty($attributes['length'])) { + return null; + } + + return (int)$attributes['length']; + } + /** * Get the field names from the top level entity. * * If the context is for an array of entities, the 0th index will be used. * - * @return array Array of fieldnames in the table/entity. + * @return array Array of field names in the table/entity. */ - public function fieldNames() + public function fieldNames(): array { $table = $this->_getTable('0'); + if (!$table) { + return []; + } return $table->getSchema()->columns(); } @@ -443,24 +576,30 @@ public function fieldNames() * Get the validator associated to an entity based on naming * conventions. * - * @param array $parts Each one of the parts in a path for a field name + * @param array $parts Each one of the parts in a path for a field name * @return \Cake\Validation\Validator + * @throws \Cake\Core\Exception\CakeException If validator cannot be retrieved based on the parts. */ - protected function _getValidator($parts) + protected function _getValidator(array $parts): Validator { - $keyParts = array_filter(array_slice($parts, 0, -1), function ($part) { + $keyParts = array_filter(array_slice($parts, 0, -1), function (string $part) { return !is_numeric($part); }); $key = implode('.', $keyParts); - $entity = $this->entity($parts) ?: null; + $entity = $this->entity($parts); if (isset($this->_validator[$key])) { - $this->_validator[$key]->setProvider('entity', $entity); + if (is_object($entity)) { + $this->_validator[$key]->setProvider('entity', $entity); + } return $this->_validator[$key]; } $table = $this->_getTable($parts); + if (!$table) { + throw new InvalidArgumentException(sprintf('Validator not found: `%s`.', $key)); + } $alias = $table->getAlias(); $method = 'default'; @@ -471,7 +610,10 @@ protected function _getValidator($parts) } $validator = $table->getValidator($method); - $validator->setProvider('entity', $entity); + + if (is_object($entity)) { + $validator->setProvider('entity', $entity); + } return $this->_validator[$key] = $validator; } @@ -479,18 +621,18 @@ protected function _getValidator($parts) /** * Get the table instance from a property path * - * @param array $parts Each one of the parts in a path for a field name - * @param bool $fallback Whether or not to fallback to the last found table - * when a non-existent field/property is being encountered. - * @return \Cake\ORM\Table|bool Table instance or false + * @param \Cake\Datasource\EntityInterface|array|string $parts Each one of the parts in a path for a field name + * @param bool $fallback Whether to fallback to the last found table + * when a nonexistent field/property is being encountered. + * @return \Cake\ORM\Table|null Table instance or null */ - protected function _getTable($parts, $fallback = true) + protected function _getTable(EntityInterface|array|string $parts, bool $fallback = true): ?Table { if (!is_array($parts) || count($parts) === 1) { return $this->_tables[$this->_rootName]; } - $normalized = array_slice(array_filter($parts, function ($part) { + $normalized = array_slice(array_filter($parts, function (string $part) { return !is_numeric($part); }), 0, -1); @@ -506,24 +648,23 @@ protected function _getTable($parts, $fallback = true) $table = $this->_tables[$this->_rootName]; $assoc = null; foreach ($normalized as $part) { - if ($part === '_joinData') { - if ($assoc) { - $table = $assoc->junction(); - $assoc = null; - continue; - } - } else { - $assoc = $table->associations()->getByProperty($part); + if ($assoc instanceof BelongsToMany && $part === $assoc->getJunctionProperty()) { + $table = $assoc->junction(); + $assoc = null; + continue; } + $associationCollection = $table->associations(); + $assoc = $associationCollection->getByProperty($part); - if (!$assoc && $fallback) { - break; - } - if (!$assoc && !$fallback) { - return false; + if ($assoc === null) { + if ($fallback) { + break; + } + + return null; } - $table = $assoc->target(); + $table = $assoc->getTarget(); } return $this->_tables[$path] = $table; @@ -533,15 +674,14 @@ protected function _getTable($parts, $fallback = true) * Get the abstract field type for a given field name. * * @param string $field A dot separated path to get a schema type for. - * @return null|string An abstract data type or null. - * @see \Cake\Database\Type + * @return string|null An abstract data type or null. + * @see \Cake\Database\TypeFactory */ - public function type($field) + public function type(string $field): ?string { $parts = explode('.', $field); - $table = $this->_getTable($parts); - return $table->getSchema()->baseColumnType(array_pop($parts)); + return $this->_getTable($parts)?->getSchema()->baseColumnType(array_pop($parts)); } /** @@ -550,23 +690,27 @@ public function type($field) * @param string $field A dot separated path to get additional data on. * @return array An array of data describing the additional attributes on a field. */ - public function attributes($field) + public function attributes(string $field): array { $parts = explode('.', $field); $table = $this->_getTable($parts); - $column = (array)$table->getSchema()->getColumn(array_pop($parts)); - $whitelist = ['length' => null, 'precision' => null]; + if (!$table) { + return []; + } - return array_intersect_key($column, $whitelist); + return array_intersect_key( + (array)$table->getSchema()->getColumn(array_pop($parts)), + array_flip(static::VALID_ATTRIBUTES), + ); } /** - * Check whether or not a field has an error attached to it + * Check whether a field has an error attached to it * * @param string $field A dot separated path to check errors on. * @return bool Returns true if the errors for the field are not empty. */ - public function hasError($field) + public function hasError(string $field): bool { return $this->error($field) !== []; } @@ -577,13 +721,29 @@ public function hasError($field) * @param string $field A dot separated path to check errors on. * @return array An array of errors. */ - public function error($field) + public function error(string $field): array { $parts = explode('.', $field); - $entity = $this->entity($parts); + try { + /** + * @var \Cake\Datasource\EntityInterface|null $entity + * @var array $remainingParts + */ + [$entity, $remainingParts] = $this->leafEntity($parts); + } catch (CakeException) { + return []; + } + if ($entity instanceof EntityInterface && count($remainingParts) === 0) { + return $entity->getErrors(); + } if ($entity instanceof EntityInterface) { - return $entity->errors(array_pop($parts)); + $error = $entity->getError(implode('.', $remainingParts)); + if ($error) { + return $error; + } + + return $entity->getError(array_pop($parts)); } return []; diff --git a/src/View/Form/FormContext.php b/src/View/Form/FormContext.php index 0c0ea93a5a3..d487704f0bb 100644 --- a/src/View/Form/FormContext.php +++ b/src/View/Form/FormContext.php @@ -1,4 +1,6 @@ _request = $request; - $context += [ - 'entity' => null, - ]; + assert( + isset($context['entity']) && $context['entity'] instanceof Form, + "`\$context['entity']` must be an instance of " . Form::class, + ); + $this->_form = $context['entity']; + $this->_validator = $context['validator'] ?? null; } /** - * {@inheritDoc} + * @inheritDoc */ - public function primaryKey() + public function getPrimaryKey(): array { return []; } /** - * {@inheritDoc} + * @inheritDoc */ - public function isPrimaryKey($field) + public function isPrimaryKey(string $field): bool { return false; } /** - * {@inheritDoc} + * @inheritDoc */ - public function isCreate() + public function isCreate(): bool { return true; } /** - * {@inheritDoc} + * @inheritDoc */ - public function val($field, $options = []) + public function val(string $field, array $options = []): mixed { $options += [ 'default' => null, - 'schemaDefault' => true + 'schemaDefault' => true, ]; - $val = $this->_request->getData($field); + $val = $this->_form->getData($field); if ($val !== null) { return $val; } @@ -105,77 +112,120 @@ public function val($field, $options = []) * Get default value from form schema for given field. * * @param string $field Field name. - * @return mixed */ - protected function _schemaDefault($field) + protected function _schemaDefault(string $field): mixed { - $field = $this->_form->schema()->field($field); - if ($field) { - return $field['default']; + $field = $this->_form->getSchema()->field($field); + if (!$field) { + return null; } - return null; + return $field['default']; } /** - * {@inheritDoc} + * @inheritDoc */ - public function isRequired($field) + public function isRequired(string $field): ?bool { - $validator = $this->_form->validator(); + $validator = $this->_form->getValidator($this->_validator); if (!$validator->hasField($field)) { - return false; + return null; } if ($this->type($field) !== 'boolean') { - return $validator->isEmptyAllowed($field, $this->isCreate()) === false; + return !$validator->isEmptyAllowed($field, $this->isCreate()); } return false; } /** - * {@inheritDoc} + * @inheritDoc */ - public function fieldNames() + public function getRequiredMessage(string $field): ?string { - return $this->_form->schema()->fields(); + $parts = explode('.', $field); + + $validator = $this->_form->getValidator($this->_validator); + $fieldName = array_pop($parts); + if (!$validator->hasField($fieldName)) { + return null; + } + + $ruleset = $validator->field($fieldName); + if (!$ruleset->isEmptyAllowed()) { + return $validator->getNotEmptyMessage($fieldName); + } + + return null; + } + + /** + * @inheritDoc + */ + public function getMaxLength(string $field): ?int + { + $validator = $this->_form->getValidator($this->_validator); + if (!$validator->hasField($field)) { + return null; + } + foreach ($validator->field($field)->rules() as $rule) { + if ($rule->get('rule') === 'maxLength') { + return $rule->get('pass')[0]; + } + } + + $attributes = $this->attributes($field); + if (empty($attributes['length'])) { + return null; + } + + return $attributes['length']; } /** - * {@inheritDoc} + * @inheritDoc */ - public function type($field) + public function fieldNames(): array { - return $this->_form->schema()->fieldType($field); + return $this->_form->getSchema()->fields(); } /** - * {@inheritDoc} + * @inheritDoc */ - public function attributes($field) + public function type(string $field): ?string { - $column = (array)$this->_form->schema()->field($field); - $whiteList = ['length' => null, 'precision' => null]; + return $this->_form->getSchema()->fieldType($field); + } - return array_intersect_key($column, $whiteList); + /** + * @inheritDoc + */ + public function attributes(string $field): array + { + return array_intersect_key( + (array)$this->_form->getSchema()->field($field), + array_flip(static::VALID_ATTRIBUTES), + ); } /** - * {@inheritDoc} + * @inheritDoc */ - public function hasError($field) + public function hasError(string $field): bool { $errors = $this->error($field); - return count($errors) > 0; + return $errors !== []; } /** - * {@inheritDoc} + * @inheritDoc */ - public function error($field) + public function error(string $field): array { - return array_values((array)Hash::get($this->_form->errors(), $field, [])); + return (array)Hash::get($this->_form->getErrors(), $field, []); } } diff --git a/src/View/Form/NullContext.php b/src/View/Form/NullContext.php index f2e38b11e77..ce6351637cb 100644 --- a/src/View/Form/NullContext.php +++ b/src/View/Form/NullContext.php @@ -1,4 +1,6 @@ _request = $request; } /** - * {@inheritDoc} + * @inheritDoc */ - public function primaryKey() + public function getPrimaryKey(): array { return []; } /** - * {@inheritDoc} + * @inheritDoc */ - public function isPrimaryKey($field) + public function isPrimaryKey(string $field): bool { return false; } /** - * {@inheritDoc} + * @inheritDoc */ - public function isCreate() + public function isCreate(): bool { return true; } /** - * {@inheritDoc} + * @inheritDoc */ - public function val($field) + public function val(string $field, array $options = []): mixed { - return $this->_request->getData($field); + return null; } /** - * {@inheritDoc} + * @inheritDoc */ - public function isRequired($field) + public function isRequired(string $field): ?bool { - return false; + return null; + } + + /** + * @inheritDoc + */ + public function getRequiredMessage(string $field): ?string + { + return null; + } + + /** + * @inheritDoc + */ + public function getMaxLength(string $field): ?int + { + return null; } /** - * {@inheritDoc} + * @inheritDoc */ - public function fieldNames() + public function fieldNames(): array { return []; } /** - * {@inheritDoc} + * @inheritDoc */ - public function type($field) + public function type(string $field): ?string { return null; } /** - * {@inheritDoc} + * @inheritDoc */ - public function attributes($field) + public function attributes(string $field): array { return []; } /** - * {@inheritDoc} + * @inheritDoc */ - public function hasError($field) + public function hasError(string $field): bool { return false; } /** - * {@inheritDoc} + * @inheritDoc */ - public function error($field) + public function error(string $field): array { return []; } diff --git a/src/View/Helper.php b/src/View/Helper.php index 5dc6d7b1284..00d39cd1ca7 100644 --- a/src/View/Helper.php +++ b/src/View/Helper.php @@ -1,4 +1,6 @@ > */ - public $helpers = []; + protected array $helpers = []; /** * Default config for this helper. * - * @var array - */ - protected $_defaultConfig = []; - - /** - * A helper lookup table used to lazy load helper objects. - * - * @var array - */ - protected $_helperMap = []; - - /** - * The current theme name if any. - * - * @var string - */ - public $theme; - - /** - * Request object - * - * @var \Cake\Http\ServerRequest - */ - public $request; - - /** - * Plugin path - * - * @var string - */ - public $plugin; - - /** - * Holds the fields ['field_name' => ['type' => 'string', 'length' => 100]], - * primaryKey and validates ['field_name'] - * - * @var array + * @var array */ - public $fieldset = []; + protected array $_defaultConfig = []; /** - * Holds tag templates. + * Loaded helper instances. * - * @var array + * @var array> */ - public $tags = []; + protected array $helperInstances = []; /** * The View instance this helper is attached to * - * @var \Cake\View\View + * @var TView */ - protected $_View; + protected View $_View; /** * Default Constructor * - * @param \Cake\View\View $View The View this helper is being attached to. - * @param array $config Configuration settings for the helper. + * @param TView $view The View this helper is being attached to. + * @param array $config Configuration settings for the helper. */ - public function __construct(View $View, array $config = []) + public function __construct(View $view, array $config = []) { - $this->_View = $View; - $this->request = $View->request; - + $this->_View = $view; $this->setConfig($config); - if (!empty($this->helpers)) { - $this->_helperMap = $View->helpers()->normalizeArray($this->helpers); + if ($this->helpers) { + $this->helpers = $view->helpers()->normalizeArray($this->helpers); } $this->initialize($config); } - /** - * Provide non fatal errors on missing method calls. - * - * @param string $method Method to invoke - * @param array $params Array of params for the method. - * @return void - */ - public function __call($method, $params) - { - trigger_error(sprintf('Method %1$s::%2$s does not exist', get_class($this), $method), E_USER_WARNING); - } - /** * Lazy loads helpers. * * @param string $name Name of the property being accessed. - * @return \Cake\View\Helper|null Helper instance if helper with provided name exists + * @return \Cake\View\Helper<\Cake\View\View>|null Helper instance if helper with provided name exists */ - public function __get($name) + public function __get(string $name): ?Helper { - if (isset($this->_helperMap[$name]) && !isset($this->{$name})) { - $config = ['enabled' => false] + (array)$this->_helperMap[$name]['config']; - $this->{$name} = $this->_View->loadHelper($this->_helperMap[$name]['class'], $config); + if (isset($this->helperInstances[$name])) { + return $this->helperInstances[$name]; + } - return $this->{$name}; + if (isset($this->helpers[$name])) { + $config = ['enabled' => false] + $this->helpers[$name]; + + return $this->helperInstances[$name] = $this->_View->loadHelper($name, $config); } + + return null; } /** * Get the view instance this helper is bound to. * - * @return \Cake\View\View The bound view instance. + * @return TView The bound view instance. */ - public function getView() + public function getView(): View { return $this->_View; } @@ -168,34 +126,24 @@ public function getView() /** * Returns a string to be used as onclick handler for confirm dialogs. * - * @param string $message Message to be displayed * @param string $okCode Code to be executed after user chose 'OK' * @param string $cancelCode Code to be executed after user chose 'Cancel' - * @param array $options Array of options - * @return string onclick JS code + * @return string "onclick" JS code */ - protected function _confirm($message, $okCode, $cancelCode = '', $options = []) + protected function _confirm(string $okCode, string $cancelCode): string { - $message = str_replace('\\\n', '\n', json_encode($message)); - $confirm = "if (confirm({$message})) { {$okCode} } {$cancelCode}"; - // We cannot change the key here in 3.x, but the behavior is inverted in this case - $escape = isset($options['escape']) && $options['escape'] === false; - if ($escape) { - $confirm = h($confirm); - } - - return $confirm; + return "if (confirm(this.dataset.confirmMessage)) { {$okCode} } {$cancelCode}"; } /** * Adds the given class to the element options * - * @param array $options Array options/attributes to add a class to - * @param string|null $class The class name being added. - * @param string $key the key to use for class. - * @return array Array of options with $key set. + * @param array $options Array options/attributes to add a class to + * @param string $class The class name being added. + * @param string $key the key to use for class. Defaults to `'class'`. + * @return array Array of options with $key set. */ - public function addClass(array $options = [], $class = null, $key = 'class') + public function addClass(array $options, string $class, string $key = 'class'): array { if (isset($options[$key]) && is_array($options[$key])) { $options[$key][] = $class; @@ -217,9 +165,9 @@ public function addClass(array $options = [], $class = null, $key = 'class') * Override this method if you need to add non-conventional event listeners. * Or if you want helpers to listen to non-standard events. * - * @return array + * @return array */ - public function implementedEvents() + public function implementedEvents(): array { $eventMap = [ 'View.beforeRenderFile' => 'beforeRenderFile', @@ -227,7 +175,7 @@ public function implementedEvents() 'View.beforeRender' => 'beforeRender', 'View.afterRender' => 'afterRender', 'View.beforeLayout' => 'beforeLayout', - 'View.afterLayout' => 'afterLayout' + 'View.afterLayout' => 'afterLayout', ]; $events = []; foreach ($eventMap as $event => $method) { @@ -244,10 +192,10 @@ public function implementedEvents() * * Implement this method to avoid having to overwrite the constructor and call parent. * - * @param array $config The configuration settings provided to this helper. + * @param array $config The configuration settings provided to this helper. * @return void */ - public function initialize(array $config) + public function initialize(array $config): void { } @@ -255,16 +203,12 @@ public function initialize(array $config) * Returns an array that can be used to describe the internal state of this * object. * - * @return array + * @return array */ - public function __debugInfo() + public function __debugInfo(): array { return [ 'helpers' => $this->helpers, - 'theme' => $this->theme, - 'plugin' => $this->plugin, - 'fieldset' => $this->fieldset, - 'tags' => $this->tags, 'implementedEvents' => $this->implementedEvents(), '_config' => $this->getConfig(), ]; diff --git a/src/View/Helper/BreadcrumbsHelper.php b/src/View/Helper/BreadcrumbsHelper.php index 4a0a6665790..8f2cb55a9b3 100644 --- a/src/View/Helper/BreadcrumbsHelper.php +++ b/src/View/Helper/BreadcrumbsHelper.php @@ -1,4 +1,6 @@ */ class BreadcrumbsHelper extends Helper { - use StringTemplateTrait; /** * Other helpers used by BreadcrumbsHelper. * - * @var array + * @var array> */ - public $helpers = ['Url']; + protected array $helpers = ['Url']; /** * Default config for the helper. * - * @var array + * @var array */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ 'templates' => [ 'wrapper' => '{{content}}', 'item' => '{{title}}{{separator}}', 'itemWithoutLink' => '{{title}}{{separator}}', - 'separator' => '{{separator}}' - ] + 'separator' => '{{separator}}', + ], ]; /** @@ -54,34 +57,39 @@ class BreadcrumbsHelper extends Helper * * @var array */ - protected $crumbs = []; + protected array $crumbs = []; /** * Add a crumb to the end of the trail. * - * @param string|array $title If provided as a string, it represents the title of the crumb. + * @param array|string $title If provided as a string, it represents the title of the crumb. * Alternatively, if you want to add multiple crumbs at once, you can provide an array, with each values being a * single crumb. Arrays are expected to be of this form: + * * - *title* The title of the crumb * - *link* The link of the crumb. If not provided, no link will be made * - *options* Options of the crumb. See description of params option of this method. - * @param string|array|null $url URL of the crumb. Either a string, an array of route params to pass to + * + * @param array|string|null $url URL of the crumb. Either a string, an array of route params to pass to * Url::build() or null / empty if the crumb does not have a link. - * @param array $options Array of options. These options will be used as attributes HTML attribute the crumb will + * @param array $options Array of options. These options will be used as HTML attributes the crumb will * be rendered in (a
  • tag by default). It accepts two special keys: + * * - *innerAttrs*: An array that allows you to define attributes for the inner element of the crumb (by default, to - * the link) + * the link) * - *templateVars*: Specific template vars in case you override the templates provided. * @return $this */ - public function add($title, $url = null, array $options = []) + public function add(array|string $title, array|string|null $url = null, array $options = []) { if (is_array($title)) { - foreach ($title as $crumb) { - $this->crumbs[] = $crumb + ['title' => '', 'url' => null, 'options' => []]; - } + deprecationWarning( + '5.3.0', + 'Passing an array as the first argument to BreadcrumbsHelper::add() is deprecated. ' . + 'Use addMany() instead.', + ); - return $this; + return $this->addMany($title, $options); } $this->crumbs[] = compact('title', 'url', 'options'); @@ -89,35 +97,61 @@ public function add($title, $url = null, array $options = []) return $this; } + /** + * Add multiple crumbs to the end of the trail. + * + * @param array}> $crumbs Array of crumbs to add. + * @param array $options Shared options for all crumbs. These options will be used as defaults + * for each crumb, with individual crumb options taking precedence. These options will be used as attributes + * HTML attribute the crumb will be rendered in (a
  • tag by default). It accepts two special keys: + * + * - *innerAttrs*: An array that allows you to define attributes for the inner element of the crumb (by default, to + * the link) + * - *templateVars*: Specific template vars in case you override the templates provided. + * @return $this + */ + public function addMany(array $crumbs, array $options = []) + { + foreach ($crumbs as $crumb) { + $crumb += ['title' => '', 'url' => null, 'options' => []]; + $crumb['options'] += $options; + $this->crumbs[] = $crumb; + } + + return $this; + } + /** * Prepend a crumb to the start of the queue. * - * @param string $title If provided as a string, it represents the title of the crumb. + * @param array|string $title If provided as a string, it represents the title of the crumb. * Alternatively, if you want to add multiple crumbs at once, you can provide an array, with each values being a * single crumb. Arrays are expected to be of this form: + * * - *title* The title of the crumb * - *link* The link of the crumb. If not provided, no link will be made * - *options* Options of the crumb. See description of params option of this method. - * @param string|array|null $url URL of the crumb. Either a string, an array of route params to pass to + * + * @param array|string|null $url URL of the crumb. Either a string, an array of route params to pass to * Url::build() or null / empty if the crumb does not have a link. - * @param array $options Array of options. These options will be used as attributes HTML attribute the crumb will + * @param array $options Array of options. These options will be used as HTML attributes the crumb will * be rendered in (a
  • tag by default). It accepts two special keys: + * * - *innerAttrs*: An array that allows you to define attributes for the inner element of the crumb (by default, to - * the link) + * the link) * - *templateVars*: Specific template vars in case you override the templates provided. * @return $this */ - public function prepend($title, $url = null, array $options = []) + public function prepend(array|string $title, array|string|null $url = null, array $options = []) { if (is_array($title)) { - $crumbs = []; - foreach ($title as $crumb) { - $crumbs[] = $crumb + ['title' => '', 'url' => null, 'options' => []]; - } - - array_splice($this->crumbs, 0, 0, $crumbs); + deprecationWarning( + '5.3.0', + 'Passing an array as the first argument to BreadcrumbsHelper::prepend() is deprecated. ' . + 'Use prependMany() instead.', + ); - return $this; + return $this->prependMany($title, $options); } array_unshift($this->crumbs, compact('title', 'url', 'options')); @@ -125,29 +159,59 @@ public function prepend($title, $url = null, array $options = []) return $this; } + /** + * Prepend multiple crumbs to the start of the queue. + * + * @param array}> $crumbs Array of crumbs to prepend. + * @param array $options Shared options for all crumbs. These options will be used as defaults + * for each crumb, with individual crumb options taking precedence. These options will be used as attributes + * HTML attribute the crumb will be rendered in (a
  • tag by default). It accepts two special keys: + * + * - *innerAttrs*: An array that allows you to define attributes for the inner element of the crumb (by default, to + * the link) + * - *templateVars*: Specific template vars in case you override the templates provided. + * @return $this + */ + public function prependMany(array $crumbs, array $options = []) + { + $prepend = []; + foreach ($crumbs as $crumb) { + $crumb += ['title' => '', 'url' => null, 'options' => []]; + $crumb['options'] += $options; + $prepend[] = $crumb; + } + + array_splice($this->crumbs, 0, 0, $prepend); + + return $this; + } + /** * Insert a crumb at a specific index. * * If the index already exists, the new crumb will be inserted, - * and the existing element will be shifted one index greater. - * If the index is out of bounds, it will throw an exception. + * before the existing element, shifting the existing element one index + * greater than before. + * + * If the index is out of bounds, an exception will be thrown. * * @param int $index The index to insert at. * @param string $title Title of the crumb. - * @param string|array|null $url URL of the crumb. Either a string, an array of route params to pass to + * @param array|string|null $url URL of the crumb. Either a string, an array of route params to pass to * Url::build() or null / empty if the crumb does not have a link. - * @param array $options Array of options. These options will be used as attributes HTML attribute the crumb will + * @param array $options Array of options. These options will be used as HTML attributes the crumb will * be rendered in (a
  • tag by default). It accepts two special keys: + * * - *innerAttrs*: An array that allows you to define attributes for the inner element of the crumb (by default, to - * the link) + * the link) * - *templateVars*: Specific template vars in case you override the templates provided. * @return $this - * @throws LogicException In case the index is out of bound + * @throws \LogicException In case the index is out of bound */ - public function insertAt($index, $title, $url = null, array $options = []) + public function insertAt(int $index, string $title, array|string|null $url = null, array $options = []) { - if (!isset($this->crumbs[$index])) { - throw new LogicException(sprintf("No crumb could be found at index '%s'", $index)); + if (!isset($this->crumbs[$index]) && $index !== count($this->crumbs)) { + throw new LogicException(sprintf('No crumb could be found at index `%s`.', $index)); } array_splice($this->crumbs, $index, 0, [compact('title', 'url', 'options')]); @@ -163,22 +227,27 @@ public function insertAt($index, $title, $url = null, array $options = []) * * @param string $matchingTitle The title of the crumb you want to insert this one before. * @param string $title Title of the crumb. - * @param string|array|null $url URL of the crumb. Either a string, an array of route params to pass to + * @param array|string|null $url URL of the crumb. Either a string, an array of route params to pass to * Url::build() or null / empty if the crumb does not have a link. - * @param array $options Array of options. These options will be used as attributes HTML attribute the crumb will + * @param array $options Array of options. These options will be used as HTML attributes the crumb will * be rendered in (a
  • tag by default). It accepts two special keys: + * * - *innerAttrs*: An array that allows you to define attributes for the inner element of the crumb (by default, to - * the link) + * the link) * - *templateVars*: Specific template vars in case you override the templates provided. * @return $this - * @throws LogicException In case the matching crumb can not be found + * @throws \LogicException In case the matching crumb can not be found */ - public function insertBefore($matchingTitle, $title, $url = null, array $options = []) - { + public function insertBefore( + string $matchingTitle, + string $title, + array|string|null $url = null, + array $options = [], + ) { $key = $this->findCrumb($matchingTitle); if ($key === null) { - throw new LogicException(sprintf("No crumb matching '%s' could be found.", $matchingTitle)); + throw new LogicException(sprintf('No crumb matching `%s` could be found.', $matchingTitle)); } return $this->insertAt($key, $title, $url, $options); @@ -192,22 +261,27 @@ public function insertBefore($matchingTitle, $title, $url = null, array $options * * @param string $matchingTitle The title of the crumb you want to insert this one after. * @param string $title Title of the crumb. - * @param string|array|null $url URL of the crumb. Either a string, an array of route params to pass to + * @param array|string|null $url URL of the crumb. Either a string, an array of route params to pass to * Url::build() or null / empty if the crumb does not have a link. - * @param array $options Array of options. These options will be used as attributes HTML attribute the crumb will + * @param array $options Array of options. These options will be used as HTML attributes the crumb will * be rendered in (a
  • tag by default). It accepts two special keys: + * * - *innerAttrs*: An array that allows you to define attributes for the inner element of the crumb (by default, to - * the link) + * the link) * - *templateVars*: Specific template vars in case you override the templates provided. * @return $this - * @throws LogicException In case the matching crumb can not be found. + * @throws \LogicException In case the matching crumb can not be found. */ - public function insertAfter($matchingTitle, $title, $url = null, array $options = []) - { + public function insertAfter( + string $matchingTitle, + string $title, + array|string|null $url = null, + array $options = [], + ) { $key = $this->findCrumb($matchingTitle); if ($key === null) { - throw new LogicException(sprintf("No crumb matching '%s' could be found.", $matchingTitle)); + throw new LogicException(sprintf('No crumb matching `%s` could be found.', $matchingTitle)); } return $this->insertAt($key + 1, $title, $url, $options); @@ -218,7 +292,7 @@ public function insertAfter($matchingTitle, $title, $url = null, array $options * * @return array */ - public function getCrumbs() + public function getCrumbs(): array { return $this->crumbs; } @@ -238,18 +312,20 @@ public function reset() /** * Renders the breadcrumbs trail. * - * @param array $attributes Array of attributes applied to the `wrapper` template. Accepts the `templateVars` key to + * @param array $attributes Array of attributes applied to the `wrapper` template. Accepts the `templateVars` key to * allow the insertion of custom template variable in the template. - * @param array $separator Array of attributes for the `separator` template. + * @param array $separator Array of attributes for the `separator` template. * Possible properties are : + * * - *separator* The string to be displayed as a separator * - *templateVars* Allows the insertion of custom template variable in the template * - *innerAttrs* To provide attributes in case your separator is divided in two elements. + * * All other properties will be converted as HTML attributes and will replace the *attrs* key in the template. * If you use the default for this option (empty), it will not render a separator. * @return string The breadcrumbs trail */ - public function render(array $attributes = [], array $separator = []) + public function render(array $attributes = [], array $separator = []): string { if (!$this->crumbs) { return ''; @@ -267,7 +343,7 @@ public function render(array $attributes = [], array $separator = []) $separator['attrs'] = $templater->formatAttributes( $separator, - ['innerAttrs', 'separator'] + ['innerAttrs', 'separator'], ); $separatorString = $this->formatTemplate('separator', $separator); @@ -292,27 +368,25 @@ public function render(array $attributes = [], array $separator = []) 'title' => $title, 'url' => $url, 'separator' => '', - 'templateVars' => isset($options['templateVars']) ? $options['templateVars'] : [] + 'templateVars' => $options['templateVars'] ?? [], ]; if (!$url) { $template = 'itemWithoutLink'; } - if ($separatorString && $key !== ($crumbsCount - 1)) { + if ($separatorString && $key !== $crumbsCount - 1) { $templateParams['separator'] = $separatorString; } $crumbTrail .= $this->formatTemplate($template, $templateParams); } - $crumbTrail = $this->formatTemplate('wrapper', [ + return $this->formatTemplate('wrapper', [ 'content' => $crumbTrail, 'attrs' => $templater->formatAttributes($attributes, ['templateVars']), - 'templateVars' => isset($attributes['templateVars']) ? $attributes['templateVars'] : [] + 'templateVars' => $attributes['templateVars'] ?? [], ]); - - return $crumbTrail; } /** @@ -322,7 +396,7 @@ public function render(array $attributes = [], array $separator = []) * @param string $title Title to find. * @return int|null Index of the crumb found, or null if it can not be found. */ - protected function findCrumb($title) + protected function findCrumb(string $title): ?int { foreach ($this->crumbs as $key => $crumb) { if ($crumb['title'] === $title) { diff --git a/src/View/Helper/FlashHelper.php b/src/View/Helper/FlashHelper.php index 8cdbf6175bb..6448b72346d 100644 --- a/src/View/Helper/FlashHelper.php +++ b/src/View/Helper/FlashHelper.php @@ -1,4 +1,6 @@ */ class FlashHelper extends Helper { - /** * Used to render the message set in FlashComponent::set() * @@ -62,29 +64,20 @@ class FlashHelper extends Helper * element. * * @param string $key The [Flash.]key you are rendering in the view. - * @param array $options Additional options to use for the creation of this flash message. + * @param array $options Additional options to use for the creation of this flash message. * Supports the 'params', and 'element' keys that are used in the helper. * @return string|null Rendered flash message or null if flash key does not exist * in session. - * @throws \UnexpectedValueException If value for flash settings key is not an array. */ - public function render($key = 'flash', array $options = []) + public function render(string $key = 'flash', array $options = []): ?string { - if (!$this->request->getSession()->check("Flash.$key")) { + $messages = $this->_View->getRequest()->getFlash()->consume($key); + if ($messages === null) { return null; } - $flash = $this->request->session()->read("Flash.$key"); - if (!is_array($flash)) { - throw new UnexpectedValueException(sprintf( - 'Value for flash setting key "%s" must be an array.', - $key - )); - } - $this->request->getSession()->delete("Flash.$key"); - $out = ''; - foreach ($flash as $message) { + foreach ($messages as $message) { $message = $options + $message; $out .= $this->_View->element($message['element'], $message); } @@ -95,9 +88,9 @@ public function render($key = 'flash', array $options = []) /** * Event listeners. * - * @return array + * @return array */ - public function implementedEvents() + public function implementedEvents(): array { return []; } diff --git a/src/View/Helper/FormHelper.php b/src/View/Helper/FormHelper.php index 027e5a1f02e..a624b18a2b6 100644 --- a/src/View/Helper/FormHelper.php +++ b/src/View/Helper/FormHelper.php @@ -1,4 +1,6 @@ */ class FormHelper extends Helper { - use IdGeneratorTrait; - use SecureFieldTokenTrait; use StringTemplateTrait; /** * Other helpers used by FormHelper * - * @var array - */ - public $helpers = ['Url', 'Html']; - - /** - * The various pickers that make up a datetime picker. - * - * @var array + * @var array> */ - protected $_datetimeParts = ['year', 'month', 'day', 'hour', 'minute', 'second', 'meridian']; - - /** - * Special options used for datetime inputs. - * - * @var array - */ - protected $_datetimeOptions = [ - 'interval', 'round', 'monthNames', 'minYear', 'maxYear', - 'orderYear', 'timeFormat', 'second' - ]; + protected array $helpers = ['Url', 'Html']; /** * Default config for the helper. * - * @var array + * @var array */ - protected $_defaultConfig = [ + protected array $_defaultConfig = [ 'idPrefix' => null, - 'errorClass' => 'form-error', + // Deprecated option, use templates.errorClass instead. + 'errorClass' => null, + 'defaultPostLinkBlock' => null, 'typeMap' => [ 'string' => 'text', 'text' => 'textarea', 'uuid' => 'string', 'datetime' => 'datetime', + 'datetimefractional' => 'datetime', 'timestamp' => 'datetime', + 'timestampfractional' => 'datetime', + 'timestamptimezone' => 'datetime', 'date' => 'date', 'time' => 'time', + 'year' => 'year', 'boolean' => 'checkbox', 'float' => 'number', 'integer' => 'number', @@ -105,13 +106,11 @@ class FormHelper extends Helper // Used for checkboxes in checkbox() and multiCheckbox(). 'checkbox' => '', // Input group wrapper for checkboxes created via control(). - 'checkboxFormGroup' => '{{label}}', - // Wrapper container for checkboxes. - 'checkboxWrapper' => '
    {{label}}
    ', - // Widget ordering for date/time/datetime pickers. - 'dateWidget' => '{{year}}{{month}}{{day}}{{hour}}{{minute}}{{second}}{{meridian}}', + 'checkboxFormGroup' => '{{input}}{{label}}', + // Wrapper container for checkboxes in a multicheckbox input + 'checkboxWrapper' => '
    {{input}}{{label}}
    ', // Error message wrapper elements. - 'error' => '
    {{content}}
    ', + 'error' => '
    {{content}}
    ', // Container for error items. 'errorList' => '
      {{content}}
    ', // Error item wrapper. @@ -127,15 +126,16 @@ class FormHelper extends Helper // General grouping container for control(). Defines input/label ordering. 'formGroup' => '{{label}}{{input}}', // Wrapper content used to hide other content. - 'hiddenBlock' => '
    {{content}}
    ', + 'hiddenBlock' => '{{content}}', // Generic input element. - 'input' => '', + 'input' => '', // Submit input element. - 'inputSubmit' => '', + 'inputSubmit' => '', // Container element used by control(). - 'inputContainer' => '
    {{content}}
    ', + 'inputContainer' => '
    {{content}}
    ', // Container element used by control() when a field has an error. - 'inputContainerError' => '
    {{content}}{{error}}
    ', + // phpcs:ignore + 'inputContainerError' => '
    {{content}}{{error}}
    ', // Label element when inputs are not nested inside the label. 'label' => '{{text}}', // Label element used for radio and multi-checkbox inputs. @@ -157,20 +157,41 @@ class FormHelper extends Helper // Radio input element, 'radio' => '', // Wrapping container for radio input/label, - 'radioWrapper' => '{{label}}', + 'radioWrapper' => '{{input}}{{label}}', // Textarea input element, 'textarea' => '', // Container for submit buttons. 'submitContainer' => '
    {{content}}
    ', - ] + // Confirm javascript template for postLink() + 'confirmJs' => '{{confirm}}', + // Templates for postLink() JS for ', - 'javascriptstart' => '', - 'javascriptend' => '' - ] + 'confirmJs' => '{{confirm}}', + ], ]; - /** - * Breadcrumbs. - * - * @var array - * @deprecated 3.3.6 Use the BreadcrumbsHelper instead - */ - protected $_crumbs = []; - /** * Names of script & css files that have been included once * - * @var array + * @var array */ - protected $_includedAssets = []; + protected array $_includedAssets = []; /** * Options for the currently opened script block buffer if any. * - * @var array - */ - protected $_scriptBlockOptions = []; - - /** - * Document type definitions - * - * @var array + * @var array */ - protected $_docTypes = [ - 'html4-strict' => '', - 'html4-trans' => '', - 'html4-frame' => '', - 'html5' => '', - 'xhtml-strict' => '', - 'xhtml-trans' => '', - 'xhtml-frame' => '', - 'xhtml11' => '' - ]; - - /** - * Constructor - * - * ### Settings - * - * - `templates` Either a filename to a config containing templates. - * Or an array of templates to load. See Cake\View\StringTemplate for - * template formatting. - * - * ### Customizing tag sets - * - * Using the `templates` option you can redefine the tag HtmlHelper will use. - * - * @param \Cake\View\View $View The View this helper is being attached to. - * @param array $config Configuration settings for the helper. - */ - public function __construct(View $View, array $config = []) - { - parent::__construct($View, $config); - $this->response = $this->_View->response ?: new Response(); - } - - /** - * Adds a link to the breadcrumbs array. - * - * @param string $name Text for link - * @param string|array|null $link URL for link (if empty it won't be a link) - * @param array $options Link attributes e.g. ['id' => 'selected'] - * @return $this - * @see \Cake\View\Helper\HtmlHelper::link() for details on $options that can be used. - * @link https://book.cakephp.org/3.0/en/views/helpers/html.html#creating-breadcrumb-trails-with-htmlhelper - * @deprecated 3.3.6 Use the BreadcrumbsHelper instead - */ - public function addCrumb($name, $link = null, array $options = []) - { - $this->_crumbs[] = [$name, $link, $options]; - - return $this; - } - - /** - * Returns a doctype string. - * - * Possible doctypes: - * - * - html4-strict: HTML4 Strict. - * - html4-trans: HTML4 Transitional. - * - html4-frame: HTML4 Frameset. - * - html5: HTML5. Default value. - * - xhtml-strict: XHTML1 Strict. - * - xhtml-trans: XHTML1 Transitional. - * - xhtml-frame: XHTML1 Frameset. - * - xhtml11: XHTML1.1. - * - * @param string $type Doctype to use. - * @return string|null Doctype string - * @link https://book.cakephp.org/3.0/en/views/helpers/html.html#creating-doctype-tags - */ - public function docType($type = 'html5') - { - if (isset($this->_docTypes[$type])) { - return $this->_docTypes[$type]; - } - - return null; - } + protected array $_scriptBlockOptions = []; /** * Creates a link to an external resource and handles basic meta tags @@ -222,18 +128,19 @@ public function docType($type = 'html5') * - `block` - Set to true to append output to view block "meta" or provide * custom block name. * - * @param string|array $type The title of the external resource, Or an array of attributes for a + * @param array|string $type The title of the external resource, Or an array of attributes for a * custom meta tag. - * @param string|array|null $content The address of the external resource or string for content attribute - * @param array $options Other attributes for the generated tag. If the type attribute is html, + * @param array|string|null $content The address of the external resource or string for content attribute + * @param array $options Other attributes for the generated tag. If the type attribute is html, * rss, atom, or icon, the mime-type is returned. - * @return string|null A completed `` element, or null if the element was sent to a block. - * @link https://book.cakephp.org/3.0/en/views/helpers/html.html#creating-meta-tags + * @return string|null A completed `` or `` element, or null if the element was sent to a block. + * @link https://book.cakephp.org/5/en/views/helpers/html.html#creating-meta-tags */ - public function meta($type, $content = null, array $options = []) + public function meta(array|string $type, array|string|null $content = null, array $options = []): ?string { - if (!is_array($type)) { + if (is_string($type)) { $types = [ + 'csrf-token' => ['name' => 'csrf-token'], 'rss' => ['type' => 'application/rss+xml', 'rel' => 'alternate', 'title' => $type, 'link' => $content], 'atom' => ['type' => 'application/atom+xml', 'title' => $type, 'link' => $content], 'icon' => ['type' => 'image/x-icon', 'rel' => 'icon', 'link' => $content], @@ -245,13 +152,17 @@ public function meta($type, $content = null, array $options = []) 'next' => ['rel' => 'next', 'link' => $content], 'prev' => ['rel' => 'prev', 'link' => $content], 'first' => ['rel' => 'first', 'link' => $content], - 'last' => ['rel' => 'last', 'link' => $content] + 'last' => ['rel' => 'last', 'link' => $content], ]; if ($type === 'icon' && $content === null) { $types['icon']['link'] = 'favicon.ico'; } + if ($type === 'csrf-token') { + $types['csrf-token']['content'] = $this->_View->getRequest()->getAttribute('csrfToken'); + } + if (isset($types[$type])) { $type = $types[$type]; } elseif (!isset($options['type']) && $content !== null) { @@ -268,25 +179,22 @@ public function meta($type, $content = null, array $options = []) } } - $options += $type + ['block' => null]; - $out = null; + $options += $type + ['block' => $this->getConfig('defaultMetaBlock')]; + $out = ''; if (isset($options['link'])) { - $options['link'] = $this->Url->assetUrl($options['link']); - if (isset($options['rel']) && $options['rel'] === 'icon') { - $out = $this->formatTemplate('metalink', [ - 'url' => $options['link'], - 'attrs' => $this->templater()->formatAttributes($options, ['block', 'link']) - ]); - $options['rel'] = 'shortcut icon'; + if (is_array($options['link'])) { + $options['link'] = $this->Url->build($options['link']); + } else { + $options['link'] = $this->Url->assetUrl($options['link']); } $out .= $this->formatTemplate('metalink', [ 'url' => $options['link'], - 'attrs' => $this->templater()->formatAttributes($options, ['block', 'link']) + 'attrs' => $this->templater()->formatAttributes($options, ['block', 'link']), ]); } else { $out = $this->formatTemplate('meta', [ - 'attrs' => $this->templater()->formatAttributes($options, ['block', 'type']) + 'attrs' => $this->templater()->formatAttributes($options, ['block', 'type']), ]); } @@ -297,6 +205,8 @@ public function meta($type, $content = null, array $options = []) $options['block'] = __FUNCTION__; } $this->_View->append($options['block'], $out); + + return null; } /** @@ -305,16 +215,16 @@ public function meta($type, $content = null, array $options = []) * @param string|null $charset The character set to be used in the meta tag. If empty, * The App.encoding value will be used. Example: "utf-8". * @return string A meta tag containing the specified character set. - * @link https://book.cakephp.org/3.0/en/views/helpers/html.html#creating-charset-tags + * @link https://book.cakephp.org/5/en/views/helpers/html.html#creating-charset-tags */ - public function charset($charset = null) + public function charset(?string $charset = null): string { - if (empty($charset)) { - $charset = strtolower(Configure::read('App.encoding')); + if (!$charset) { + $charset = strtolower((string)Configure::read('App.encoding')); } return $this->formatTemplate('charset', [ - 'charset' => !empty($charset) ? $charset : 'utf-8' + 'charset' => $charset ?: 'utf-8', ]); } @@ -331,18 +241,18 @@ public function charset($charset = null) * * - `escape` Set to false to disable escaping of title and attributes. * - `escapeTitle` Set to false to disable escaping of title. Takes precedence - * over value of `escape`) + * over value of `escape`. * - `confirm` JavaScript confirmation message. * - * @param string|array $title The content to be wrapped by `` tags. + * @param array|string $title The content to be wrapped by `` tags. * Can be an array if $url is null. If $url is null, $title will be used as both the URL and title. - * @param string|array|null $url Cake-relative URL or array of URL parameters, or + * @param array|string|null $url Cake-relative URL or array of URL parameters, or * external URL (starts with http://) - * @param array $options Array of options and HTML attributes. - * @return string An `` element. - * @link https://book.cakephp.org/3.0/en/views/helpers/html.html#creating-links + * @param array $options Array of options and HTML attributes. + * @return string An `` element. + * @link https://book.cakephp.org/5/en/views/helpers/html.html#creating-links */ - public function link($title, $url = null, array $options = []) + public function link(array|string $title, array|string|null $url = null, array $options = []): string { $escapeTitle = true; if ($url !== null) { @@ -368,24 +278,52 @@ public function link($title, $url = null, array $options = []) $title = htmlentities($title, ENT_QUOTES, $escapeTitle); } + $templater = $this->templater(); $confirmMessage = null; if (isset($options['confirm'])) { $confirmMessage = $options['confirm']; unset($options['confirm']); } if ($confirmMessage) { - $options['onclick'] = $this->_confirm($confirmMessage, 'return true;', 'return false;', $options); + $confirm = $this->_confirm('return true;', 'return false;'); + $options['data-confirm-message'] = $confirmMessage; + $options['onclick'] = $templater->format('confirmJs', [ + 'confirmMessage' => h($confirmMessage), + 'confirm' => $confirm, + ]); } - $templater = $this->templater(); - return $templater->format('link', [ 'url' => $url, 'attrs' => $templater->formatAttributes($options), - 'content' => $title + 'content' => $title, ]); } + /** + * Creates an HTML link from route path string. + * + * ### Options + * + * - `escape` Set to false to disable escaping of title and attributes. + * - `escapeTitle` Set to false to disable escaping of title. Takes precedence + * over value of `escape`. + * - `confirm` JavaScript confirmation message. + * + * @param string $title The content to be wrapped by `` tags. + * @param string $path Cake-relative route path. + * @param array $params An array specifying any additional parameters. + * Can be also any special parameters supported by `Router::url()`. + * @param array $options Array of options and HTML attributes. + * @return string An `` element. + * @see \Cake\Routing\Router::pathUrl() + * @link https://book.cakephp.org/5/en/views/helpers/html.html#creating-links-from-route-paths + */ + public function linkFromPath(string $title, string $path, array $params = [], array $options = []): string + { + return $this->link($title, ['_path' => $path] + $params, $options); + } + /** * Creates a link element for CSS stylesheets. * @@ -419,23 +357,32 @@ public function link($title, $url = null, array $options = []) * * - `block` Set to true to append output to view block "css" or provide * custom block name. - * - `once` Whether or not the css file should be checked for uniqueness. If true css + * - `once` Whether the css file should be checked for uniqueness. If true css * files will only be included once, use false to allow the same * css to be included more than once per request. * - `plugin` False value will prevent parsing path as a plugin * - `rel` Defaults to 'stylesheet'. If equal to 'import' the stylesheet will be imported. * - `fullBase` If true the URL will get a full address for the css file. * - * @param string|array $path The name of a CSS style sheet or an array containing names of + * All other options will be treated as HTML attributes. If the request contains a + * `cspStyleNonce` attribute, that value will be applied as the `nonce` attribute on the + * generated HTML. + * + * @param array|string $path The name of a CSS style sheet or an array containing names of * CSS stylesheets. If `$path` is prefixed with '/', the path will be relative to the webroot * of your application. Otherwise, the path will be relative to your CSS path, usually webroot/css. - * @param array $options Array of options and HTML arguments. - * @return string|null CSS `` or ` + + + +
    + fetch('title'))); + $errorTitle = array_shift($title); + $errorDescription = implode("\n", $title); + ?> +

    + + 📋 +

    + + + + +
    +
    + fetch('subheading')): ?> +

    + fetch('subheading') ?> +

    + + + fetch('file')): ?> +
    + fetch('file') ?> +
    + + + element('dev_error_stacktrace'); ?> + + fetch('templateName')): ?> +

    + If you want to customize this error message, create + fetch('templateName') ?> +

    + +
    + + + + diff --git a/tests/Fixture/AliasedArticlesFixture.php b/tests/Fixture/AliasedArticlesFixture.php new file mode 100644 index 00000000000..0b26b1e8713 --- /dev/null +++ b/tests/Fixture/AliasedArticlesFixture.php @@ -0,0 +1,25 @@ + ['type' => 'integer'], - 'author_id' => ['type' => 'integer', 'null' => true], - 'title' => ['type' => 'string', 'null' => true], - 'body' => 'text', - 'published' => ['type' => 'string', 'length' => 1, 'default' => 'N'], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['author_id' => 1, 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y'], ['author_id' => 3, 'title' => 'Second Article', 'body' => 'Second Article Body', 'published' => 'Y'], - ['author_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', 'published' => 'Y'] + ['author_id' => 1, 'title' => 'Third Article', 'body' => 'Third Article Body', 'published' => 'Y'], ]; } diff --git a/tests/Fixture/ArticlesMoreTranslationsFixture.php b/tests/Fixture/ArticlesMoreTranslationsFixture.php new file mode 100644 index 00000000000..0a64fb78a67 --- /dev/null +++ b/tests/Fixture/ArticlesMoreTranslationsFixture.php @@ -0,0 +1,40 @@ + 'eng', 'id' => 1, 'title' => 'Title #1', 'subtitle' => 'SubTitle #1', 'body' => 'Content #1'], + ['locale' => 'deu', 'id' => 1, 'title' => 'Titel #1', 'subtitle' => 'SubTitel #1', 'body' => 'Inhalt #1'], + ['locale' => 'cze', 'id' => 1, 'title' => 'Titulek #1', 'subtitle' => 'SubTitulek #1', 'body' => 'Obsah #1'], + ['locale' => 'eng', 'id' => 2, 'title' => 'Title #2', 'subtitle' => 'SubTitle #2', 'body' => 'Content #2'], + ['locale' => 'deu', 'id' => 2, 'title' => 'Titel #2', 'subtitle' => 'SubTitel #2', 'body' => 'Inhalt #2'], + ['locale' => 'cze', 'id' => 2, 'title' => 'Titulek #2', 'subtitle' => 'SubTitulek #2', 'body' => 'Obsah #2'], + ['locale' => 'eng', 'id' => 3, 'title' => 'Title #3', 'subtitle' => 'SubTitle #3', 'body' => 'Content #3'], + ['locale' => 'deu', 'id' => 3, 'title' => 'Titel #3', 'subtitle' => 'SubTitel #3', 'body' => 'Inhalt #3'], + ['locale' => 'cze', 'id' => 3, 'title' => 'Titulek #3', 'subtitle' => 'SubTitulek #3', 'body' => 'Obsah #3'], + ]; +} diff --git a/tests/Fixture/ArticlesTagsBindingKeysFixture.php b/tests/Fixture/ArticlesTagsBindingKeysFixture.php new file mode 100644 index 00000000000..3e0010f2536 --- /dev/null +++ b/tests/Fixture/ArticlesTagsBindingKeysFixture.php @@ -0,0 +1,35 @@ + 1, 'tagname' => 'tag1'], + ['article_id' => 1, 'tagname' => 'tag2'], + ['article_id' => 2, 'tagname' => 'tag1'], + ['article_id' => 2, 'tagname' => 'tag3'], + ]; +} diff --git a/tests/Fixture/ArticlesTagsFixture.php b/tests/Fixture/ArticlesTagsFixture.php index 7d1e9b2f92b..76bf031236b 100644 --- a/tests/Fixture/ArticlesTagsFixture.php +++ b/tests/Fixture/ArticlesTagsFixture.php @@ -21,36 +21,15 @@ */ class ArticlesTagsFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'article_id' => ['type' => 'integer', 'null' => false], - 'tag_id' => ['type' => 'integer', 'null' => false], - '_constraints' => [ - 'unique_tag' => ['type' => 'primary', 'columns' => ['article_id', 'tag_id']], - 'tag_id_fk' => [ - 'type' => 'foreign', - 'columns' => ['tag_id'], - 'references' => ['tags', 'id'], - 'update' => 'cascade', - 'delete' => 'cascade', - ] - ] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['article_id' => 1, 'tag_id' => 1], ['article_id' => 1, 'tag_id' => 2], ['article_id' => 2, 'tag_id' => 1], - ['article_id' => 2, 'tag_id' => 3] + ['article_id' => 2, 'tag_id' => 3], ]; } diff --git a/tests/Fixture/ArticlesTranslationsFixture.php b/tests/Fixture/ArticlesTranslationsFixture.php new file mode 100644 index 00000000000..13e129e9038 --- /dev/null +++ b/tests/Fixture/ArticlesTranslationsFixture.php @@ -0,0 +1,42 @@ + 'eng', 'id' => 1, 'title' => 'Title #1', 'body' => 'Content #1'], + ['locale' => 'deu', 'id' => 1, 'title' => 'Titel #1', 'body' => 'Inhalt #1'], + ['locale' => 'cze', 'id' => 1, 'title' => 'Titulek #1', 'body' => 'Obsah #1'], + ['locale' => 'spa', 'id' => 1, 'title' => 'First Article', 'body' => 'Contenido #1'], + ['locale' => 'zzz', 'id' => 1, 'title' => '', 'body' => ''], + ['locale' => 'eng', 'id' => 2, 'title' => 'Title #2', 'body' => 'Content #2'], + ['locale' => 'deu', 'id' => 2, 'title' => 'Titel #2', 'body' => 'Inhalt #2'], + ['locale' => 'cze', 'id' => 2, 'title' => 'Titulek #2', 'body' => 'Obsah #2'], + ['locale' => 'eng', 'id' => 3, 'title' => 'Title #3', 'body' => 'Content #3'], + ['locale' => 'deu', 'id' => 3, 'title' => 'Titel #3', 'body' => 'Inhalt #3'], + ['locale' => 'cze', 'id' => 3, 'title' => 'Titulek #3', 'body' => 'Obsah #3'], + ]; +} diff --git a/tests/Fixture/AssertIntegrationTestCase.php b/tests/Fixture/AssertIntegrationTestCase.php deleted file mode 100644 index 87413add516..00000000000 --- a/tests/Fixture/AssertIntegrationTestCase.php +++ /dev/null @@ -1,25 +0,0 @@ -_response = new Response(); - $this->_response->header('Location', 'http://localhost/tasks/index'); - - $this->assertNoRedirect(); - } -} diff --git a/tests/Fixture/AttachmentsFixture.php b/tests/Fixture/AttachmentsFixture.php index efbe46d0dc5..dad30b95cca 100644 --- a/tests/Fixture/AttachmentsFixture.php +++ b/tests/Fixture/AttachmentsFixture.php @@ -21,27 +21,12 @@ */ class AttachmentsFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'comment_id' => ['type' => 'integer', 'null' => false], - 'attachment' => ['type' => 'string', 'null' => false], - 'created' => 'datetime', - 'updated' => 'datetime', - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * records property * * @var array */ - public $records = [ - ['comment_id' => 5, 'attachment' => 'attachment.zip', 'created' => '2007-03-18 10:51:23', 'updated' => '2007-03-18 10:53:31'] + public array $records = [ + ['comment_id' => 5, 'attachment' => 'attachment.zip', 'created' => '2007-03-18 10:51:23', 'updated' => '2007-03-18 10:53:31'], ]; } diff --git a/tests/Fixture/AuthUsersFixture.php b/tests/Fixture/AuthUsersFixture.php index 7951109c971..41944af32c4 100644 --- a/tests/Fixture/AuthUsersFixture.php +++ b/tests/Fixture/AuthUsersFixture.php @@ -21,27 +21,12 @@ */ class AuthUsersFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'username' => ['type' => 'string', 'null' => false], - 'password' => ['type' => 'string', 'null' => false], - 'created' => 'datetime', - 'updated' => 'datetime', - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['username' => 'mariano', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31'], ['username' => 'larry', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', 'created' => '2007-03-17 01:20:23', 'updated' => '2007-03-17 01:22:31'], ['username' => 'chartjes', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', 'created' => '2007-03-17 01:22:23', 'updated' => '2007-03-17 01:24:31'], diff --git a/tests/Fixture/AuthorsFixture.php b/tests/Fixture/AuthorsFixture.php index b6844fe5d26..a3697b3c47d 100644 --- a/tests/Fixture/AuthorsFixture.php +++ b/tests/Fixture/AuthorsFixture.php @@ -21,24 +21,12 @@ */ class AuthorsFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'name' => ['type' => 'string', 'default' => null], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['name' => 'mariano'], ['name' => 'nate'], ['name' => 'larry'], diff --git a/tests/Fixture/AuthorsTagsFixture.php b/tests/Fixture/AuthorsTagsFixture.php index 246d9219c7b..7985bd9eb09 100644 --- a/tests/Fixture/AuthorsTagsFixture.php +++ b/tests/Fixture/AuthorsTagsFixture.php @@ -20,29 +20,15 @@ */ class AuthorsTagsFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'author_id' => ['type' => 'integer', 'null' => false], - 'tag_id' => ['type' => 'integer', 'null' => false], - '_constraints' => [ - 'unique_tag' => ['type' => 'primary', 'columns' => ['author_id', 'tag_id']], - ] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['author_id' => 3, 'tag_id' => 1], ['author_id' => 3, 'tag_id' => 2], ['author_id' => 2, 'tag_id' => 1], - ['author_id' => 2, 'tag_id' => 3] + ['author_id' => 2, 'tag_id' => 3], ]; } diff --git a/tests/Fixture/AuthorsTranslationsFixture.php b/tests/Fixture/AuthorsTranslationsFixture.php new file mode 100644 index 00000000000..a430ecadecf --- /dev/null +++ b/tests/Fixture/AuthorsTranslationsFixture.php @@ -0,0 +1,32 @@ + 'eng', 'id' => 1, 'name' => 'May-rianoh'], + ]; +} diff --git a/tests/Fixture/BinaryUuidItemsBinaryUuidTagsFixture.php b/tests/Fixture/BinaryUuidItemsBinaryUuidTagsFixture.php new file mode 100644 index 00000000000..7cd9288ee20 --- /dev/null +++ b/tests/Fixture/BinaryUuidItemsBinaryUuidTagsFixture.php @@ -0,0 +1,30 @@ + '481fc6d0-b920-43e0-a40d-6d1740cf8569', 'published' => true, 'name' => 'Item 1'], + ['id' => '48298a29-81c0-4c26-a7fb-413140cf8569', 'published' => false, 'name' => 'Item 2'], + ['id' => '482b7756-8da0-419a-b21f-27da40cf8569', 'published' => true, 'name' => 'Item 3'], + ]; +} diff --git a/tests/Fixture/BinaryUuidTagsFixture.php b/tests/Fixture/BinaryUuidTagsFixture.php new file mode 100644 index 00000000000..3292fccea34 --- /dev/null +++ b/tests/Fixture/BinaryUuidTagsFixture.php @@ -0,0 +1,33 @@ + '481fc6d0-b920-43e0-a40d-111111111111', 'name' => 'Defect'], + ['id' => '48298a29-81c0-4c26-a7fb-222222222222', 'name' => 'Enhancement'], + ]; +} diff --git a/tests/Fixture/CakeSessionsFixture.php b/tests/Fixture/CakeSessionsFixture.php index bf1f7228d58..f0842860ec4 100644 --- a/tests/Fixture/CakeSessionsFixture.php +++ b/tests/Fixture/CakeSessionsFixture.php @@ -21,23 +21,10 @@ */ class CakeSessionsFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'string', 'length' => 128], - 'data' => ['type' => 'text', 'null' => true], - 'expires' => ['type' => 'integer', 'length' => 11, 'null' => true], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * records property * * @var array */ - public $records = []; + public array $records = []; } diff --git a/tests/Fixture/CategoriesFixture.php b/tests/Fixture/CategoriesFixture.php index 03edfb3eb7a..8a6b63d408b 100644 --- a/tests/Fixture/CategoriesFixture.php +++ b/tests/Fixture/CategoriesFixture.php @@ -21,27 +21,12 @@ */ class CategoriesFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'parent_id' => ['type' => 'integer', 'null' => false], - 'name' => ['type' => 'string', 'null' => false], - 'created' => 'datetime', - 'updated' => 'datetime', - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['parent_id' => 0, 'name' => 'Category 1', 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31'], ['parent_id' => 1, 'name' => 'Category 1.1', 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31'], ['parent_id' => 1, 'name' => 'Category 1.2', 'created' => '2007-03-18 15:30:23', 'updated' => '2007-03-18 15:32:31'], diff --git a/tests/Fixture/ColumnSchemaAwareTypeValuesFixture.php b/tests/Fixture/ColumnSchemaAwareTypeValuesFixture.php new file mode 100644 index 00000000000..75b9f68159e --- /dev/null +++ b/tests/Fixture/ColumnSchemaAwareTypeValuesFixture.php @@ -0,0 +1,24 @@ +records = [ + [ + 'val' => new ColumnSchemaAwareTypeValueObject('THIS TEXT SHOULD BE PROCESSED VIA A CUSTOM TYPE'), + ], + [ + 'val' => 'THIS TEXT ALSO SHOULD BE PROCESSED VIA A CUSTOM TYPE', + ], + ]; + } +} diff --git a/tests/Fixture/CommentsFixture.php b/tests/Fixture/CommentsFixture.php index 07d8e2ac766..c0c21eafd34 100644 --- a/tests/Fixture/CommentsFixture.php +++ b/tests/Fixture/CommentsFixture.php @@ -21,34 +21,17 @@ */ class CommentsFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'article_id' => ['type' => 'integer', 'null' => false], - 'user_id' => ['type' => 'integer', 'null' => false], - 'comment' => ['type' => 'text'], - 'published' => ['type' => 'string', 'length' => 1, 'default' => 'N'], - 'created' => ['type' => 'datetime'], - 'updated' => ['type' => 'datetime'], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['article_id' => 1, 'user_id' => 2, 'comment' => 'First Comment for First Article', 'published' => 'Y', 'created' => '2007-03-18 10:45:23', 'updated' => '2007-03-18 10:47:31'], ['article_id' => 1, 'user_id' => 4, 'comment' => 'Second Comment for First Article', 'published' => 'Y', 'created' => '2007-03-18 10:47:23', 'updated' => '2007-03-18 10:49:31'], ['article_id' => 1, 'user_id' => 1, 'comment' => 'Third Comment for First Article', 'published' => 'Y', 'created' => '2007-03-18 10:49:23', 'updated' => '2007-03-18 10:51:31'], ['article_id' => 1, 'user_id' => 1, 'comment' => 'Fourth Comment for First Article', 'published' => 'N', 'created' => '2007-03-18 10:51:23', 'updated' => '2007-03-18 10:53:31'], ['article_id' => 2, 'user_id' => 1, 'comment' => 'First Comment for Second Article', 'published' => 'Y', 'created' => '2007-03-18 10:53:23', 'updated' => '2007-03-18 10:55:31'], - ['article_id' => 2, 'user_id' => 2, 'comment' => 'Second Comment for Second Article', 'published' => 'Y', 'created' => '2007-03-18 10:55:23', 'updated' => '2007-03-18 10:57:31'] + ['article_id' => 2, 'user_id' => 2, 'comment' => 'Second Comment for Second Article', 'published' => 'Y', 'created' => '2007-03-18 10:55:23', 'updated' => '2007-03-18 10:57:31'], ]; } diff --git a/tests/Fixture/CommentsTranslationsFixture.php b/tests/Fixture/CommentsTranslationsFixture.php new file mode 100644 index 00000000000..d86315f16cc --- /dev/null +++ b/tests/Fixture/CommentsTranslationsFixture.php @@ -0,0 +1,36 @@ + 'eng', 'id' => 1, 'comment' => 'Comment #1'], + ['locale' => 'eng', 'id' => 2, 'comment' => 'Comment #2'], + ['locale' => 'eng', 'id' => 3, 'comment' => 'Comment #3'], + ['locale' => 'eng', 'id' => 4, 'comment' => 'Comment #4'], + ['locale' => 'spa', 'id' => 4, 'comment' => 'Comentario #4'], + ]; +} diff --git a/tests/Fixture/CompositeIncrementsFixture.php b/tests/Fixture/CompositeIncrementsFixture.php index 67545188329..5a3c204a07f 100644 --- a/tests/Fixture/CompositeIncrementsFixture.php +++ b/tests/Fixture/CompositeIncrementsFixture.php @@ -18,24 +18,11 @@ class CompositeIncrementsFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer', 'null' => false, 'autoIncrement' => true], - 'account_id' => ['type' => 'integer', 'null' => false], - 'name' => ['type' => 'string', 'default' => null], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id', 'account_id']]] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ]; } diff --git a/tests/Fixture/CompositeKeyArticlesFixture.php b/tests/Fixture/CompositeKeyArticlesFixture.php new file mode 100644 index 00000000000..1e8cb6f2d20 --- /dev/null +++ b/tests/Fixture/CompositeKeyArticlesFixture.php @@ -0,0 +1,22 @@ + ['type' => 'integer'], - 'name' => ['type' => 'string', 'length' => 255, 'null' => false], - 'post_count' => ['type' => 'integer', 'null' => true], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - - public $records = [ + public array $records = [ ['name' => 'Sport', 'post_count' => 1], ['name' => 'Music', 'post_count' => 2], ]; diff --git a/tests/Fixture/CounterCacheCommentsFixture.php b/tests/Fixture/CounterCacheCommentsFixture.php index 1e169453cdc..fb8561816c5 100644 --- a/tests/Fixture/CounterCacheCommentsFixture.php +++ b/tests/Fixture/CounterCacheCommentsFixture.php @@ -21,15 +21,7 @@ */ class CounterCacheCommentsFixture extends TestFixture { - - public $fields = [ - 'id' => ['type' => 'integer'], - 'title' => ['type' => 'string', 'length' => 255], - 'user_id' => ['type' => 'integer', 'null' => true], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - - public $records = [ + public array $records = [ ['title' => 'First Comment', 'user_id' => 1], ['title' => 'Second Comment', 'user_id' => 1], ['title' => 'Third Comment', 'user_id' => 2], diff --git a/tests/Fixture/CounterCachePostsFixture.php b/tests/Fixture/CounterCachePostsFixture.php index f65a01d7ad3..be8eefa2050 100644 --- a/tests/Fixture/CounterCachePostsFixture.php +++ b/tests/Fixture/CounterCachePostsFixture.php @@ -21,17 +21,7 @@ */ class CounterCachePostsFixture extends TestFixture { - - public $fields = [ - 'id' => ['type' => 'integer'], - 'title' => ['type' => 'string', 'length' => 255], - 'user_id' => ['type' => 'integer', 'null' => true], - 'category_id' => ['type' => 'integer', 'null' => true], - 'published' => ['type' => 'boolean', 'null' => false, 'default' => false], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - - public $records = [ + public array $records = [ ['title' => 'Rock and Roll', 'user_id' => 1, 'category_id' => 1, 'published' => 0], ['title' => 'Music', 'user_id' => 1, 'category_id' => 2, 'published' => 1], ['title' => 'Food', 'user_id' => 2, 'category_id' => 2, 'published' => 1], diff --git a/tests/Fixture/CounterCacheUserCategoryPostsFixture.php b/tests/Fixture/CounterCacheUserCategoryPostsFixture.php index e70054446fb..bac057be465 100644 --- a/tests/Fixture/CounterCacheUserCategoryPostsFixture.php +++ b/tests/Fixture/CounterCacheUserCategoryPostsFixture.php @@ -21,18 +21,9 @@ */ class CounterCacheUserCategoryPostsFixture extends TestFixture { - - public $fields = [ - 'id' => ['type' => 'integer'], - 'category_id' => ['type' => 'integer'], - 'user_id' => ['type' => 'integer'], - 'post_count' => ['type' => 'integer', 'null' => true], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - - public $records = [ + public array $records = [ ['category_id' => 1, 'user_id' => 1, 'post_count' => 1], ['category_id' => 2, 'user_id' => 1, 'post_count' => 1], - ['category_id' => 2, 'user_id' => 2, 'post_count' => 1] + ['category_id' => 2, 'user_id' => 2, 'post_count' => 1], ]; } diff --git a/tests/Fixture/CounterCacheUsersFixture.php b/tests/Fixture/CounterCacheUsersFixture.php index f7289e4d2b5..d1310d5479d 100644 --- a/tests/Fixture/CounterCacheUsersFixture.php +++ b/tests/Fixture/CounterCacheUsersFixture.php @@ -21,17 +21,7 @@ */ class CounterCacheUsersFixture extends TestFixture { - - public $fields = [ - 'id' => ['type' => 'integer'], - 'name' => ['type' => 'string', 'length' => 255, 'null' => false], - 'post_count' => ['type' => 'integer', 'null' => true], - 'comment_count' => ['type' => 'integer', 'null' => true], - 'posts_published' => ['type' => 'integer', 'null' => true], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - - public $records = [ + public array $records = [ ['name' => 'Alexander', 'post_count' => 2, 'comment_count' => 2, 'posts_published' => 1], ['name' => 'Steven', 'post_count' => 1, 'comment_count' => 1, 'posts_published' => 1], ]; diff --git a/tests/Fixture/DatatypesFixture.php b/tests/Fixture/DatatypesFixture.php index 4c6bcf48b81..ffa69426845 100644 --- a/tests/Fixture/DatatypesFixture.php +++ b/tests/Fixture/DatatypesFixture.php @@ -21,21 +21,8 @@ */ class DatatypesFixture extends TestFixture { - - /** - * @var array - */ - public $fields = [ - 'id' => ['type' => 'biginteger'], - 'cost' => ['type' => 'decimal', 'length' => 20, 'precision' => 0, 'null' => true], - 'floaty' => ['type' => 'float', 'null' => true], - 'small' => ['type' => 'smallinteger', 'null' => true], - 'tiny' => ['type' => 'tinyinteger', 'null' => true], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * @var array */ - public $records = []; + public array $records = []; } diff --git a/tests/Fixture/DateKeysFixture.php b/tests/Fixture/DateKeysFixture.php new file mode 100644 index 00000000000..88546f8de32 --- /dev/null +++ b/tests/Fixture/DateKeysFixture.php @@ -0,0 +1,30 @@ + ['type' => 'integer', 'null' => false], - 'priority' => ['type' => 'integer', 'null' => false], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['tag_id']]] - ]; - /** * records property * * @var array */ - public $records = [ - ['priority' => 1], - ['priority' => 2], - ['priority' => 3] + public array $records = [ + ['tag_id' => 1, 'priority' => 1], + ['tag_id' => 2, 'priority' => 2], + ['tag_id' => 3, 'priority' => 3], ]; } diff --git a/tests/Fixture/FixturizedTestCase.php b/tests/Fixture/FixturizedTestCase.php index 8b836fd967d..6b8555252ec 100644 --- a/tests/Fixture/FixturizedTestCase.php +++ b/tests/Fixture/FixturizedTestCase.php @@ -1,7 +1,20 @@ */ - public $fixtures = ['core.categories', 'core.articles']; + protected array $fixtures = ['core.Categories', 'core.Articles']; /** * test that the shared fixture is correctly set - * - * @return void */ - public function testFixturePresent() + public function testFixturePresent(): void { - $this->assertInstanceOf('Cake\TestSuite\Fixture\FixtureManager', $this->fixtureManager); + $this->assertInstanceOf(FixtureManager::class, $this->fixtureManager); } /** * test that it is possible to load fixtures on demand - * - * @return void */ - public function testFixtureLoadOnDemand() + public function testFixtureLoadOnDemand(): void { $this->loadFixtures('Categories'); } /** * test that calling loadFixtures without args loads all fixtures - * - * @return void */ - public function testLoadAllFixtures() + public function testLoadAllFixtures(): void { $this->loadFixtures(); - $article = TableRegistry::get('Articles')->get(1); + $article = $this->getTableLocator()->get('Articles')->get(1); $this->assertSame(1, $article->id); - $category = TableRegistry::get('Categories')->get(1); + $category = $this->getTableLocator()->get('Categories')->get(1); $this->assertSame(1, $category->id); } /** * test that a test is marked as skipped using skipIf and its first parameter evaluates to true - * - * @return void */ - public function testSkipIfTrue() + public function testSkipIfTrue(): void { $this->skipIf(true); } /** * test that a test is not marked as skipped using skipIf and its first parameter evaluates to false - * - * @return void */ - public function testSkipIfFalse() + public function testSkipIfFalse(): void { $this->skipIf(false); + $this->assertTrue(true, 'Avoid phpunit warnings'); } /** * test that a fixtures are unloaded even if the test throws exceptions * - * @return void * @throws \Exception */ - public function testThrowException() + public function testThrowException(): void { throw new Exception(); } diff --git a/tests/Fixture/GroupsFixture.php b/tests/Fixture/GroupsFixture.php deleted file mode 100644 index e7c08cca1d9..00000000000 --- a/tests/Fixture/GroupsFixture.php +++ /dev/null @@ -1,45 +0,0 @@ - ['type' => 'integer'], - 'title' => ['type' => 'string'], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - - /** - * records property - * - * @var array - */ - public $records = [ - ['title' => 'foo'], - ['title' => 'bar'], - ]; -} diff --git a/tests/Fixture/GroupsMembersFixture.php b/tests/Fixture/GroupsMembersFixture.php deleted file mode 100644 index 3b007f2a5b1..00000000000 --- a/tests/Fixture/GroupsMembersFixture.php +++ /dev/null @@ -1,46 +0,0 @@ - ['type' => 'integer'], - 'group_id' => ['type' => 'integer'], - 'member_id' => ['type' => 'integer'], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - - /** - * records property - * - * @var array - */ - public $records = [ - ['group_id' => 1, 'member_id' => 1], - ['group_id' => 2, 'member_id' => 1], - ]; -} diff --git a/tests/Fixture/MembersFixture.php b/tests/Fixture/MembersFixture.php index 63fe105808d..4f79365bb03 100644 --- a/tests/Fixture/MembersFixture.php +++ b/tests/Fixture/MembersFixture.php @@ -21,24 +21,12 @@ */ class MembersFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'group_count' => ['type' => 'integer'], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * records property * * @var array */ - public $records = [ - ['group_count' => 2], + public array $records = [ + ['section_count' => 2], ]; } diff --git a/tests/Fixture/MenuLinkTreesFixture.php b/tests/Fixture/MenuLinkTreesFixture.php index f034d578f92..3b8d56a1a2e 100644 --- a/tests/Fixture/MenuLinkTreesFixture.php +++ b/tests/Fixture/MenuLinkTreesFixture.php @@ -23,23 +23,6 @@ */ class MenuLinkTreesFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'menu' => ['type' => 'string', 'null' => false], - 'lft' => ['type' => 'integer'], - 'rght' => ['type' => 'integer'], - 'parent_id' => 'integer', - 'url' => ['type' => 'string', 'null' => false], - 'title' => ['type' => 'string', 'null' => false], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * Records * @@ -71,7 +54,7 @@ class MenuLinkTreesFixture extends TestFixture * * **Note:** title:id */ - public $records = [ + public array $records = [ [ 'menu' => 'main-menu', 'lft' => '1', diff --git a/tests/Fixture/NullableAuthorsFixture.php b/tests/Fixture/NullableAuthorsFixture.php new file mode 100644 index 00000000000..124890a3b8b --- /dev/null +++ b/tests/Fixture/NullableAuthorsFixture.php @@ -0,0 +1,41 @@ + ['type' => 'integer'], + 'author_id' => ['type' => 'integer', 'null' => true], + '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]], + ]; + + /** + * records property + * + * @var array + */ + public array $records = [ + ['author_id' => 3], + ['author_id' => null], + ]; +} diff --git a/tests/Fixture/NumberTreesArticlesFixture.php b/tests/Fixture/NumberTreesArticlesFixture.php new file mode 100644 index 00000000000..528ad26c401 --- /dev/null +++ b/tests/Fixture/NumberTreesArticlesFixture.php @@ -0,0 +1,34 @@ + 1, 'title' => 'First Article', 'body' => 'First Article Body', 'published' => 'Y'], + ['number_tree_id' => 1, 'title' => 'Second Article', 'body' => 'Second Article Body', 'published' => 'Y'], + ['number_tree_id' => 11, 'title' => 'Third Article', 'body' => 'Third Article Body', 'published' => 'Y'], + ]; +} diff --git a/tests/Fixture/NumberTreesFixture.php b/tests/Fixture/NumberTreesFixture.php index 14fdcfb4eb4..9b5a98a5abf 100644 --- a/tests/Fixture/NumberTreesFixture.php +++ b/tests/Fixture/NumberTreesFixture.php @@ -23,22 +23,6 @@ */ class NumberTreesFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'name' => ['type' => 'string', 'null' => false], - 'parent_id' => 'integer', - 'lft' => ['type' => 'integer'], - 'rght' => ['type' => 'integer'], - 'depth' => ['type' => 'integer'], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * Records * @@ -56,83 +40,83 @@ class NumberTreesFixture extends TestFixture * * @var array */ - public $records = [ + public array $records = [ [ 'name' => 'electronics', 'parent_id' => null, 'lft' => '1', 'rght' => '20', - 'depth' => 0 + 'depth' => 0, ], [ 'name' => 'televisions', 'parent_id' => '1', 'lft' => '2', 'rght' => '9', - 'depth' => 1 + 'depth' => 1, ], [ 'name' => 'tube', 'parent_id' => '2', 'lft' => '3', 'rght' => '4', - 'depth' => 2 + 'depth' => 2, ], [ 'name' => 'lcd', 'parent_id' => '2', 'lft' => '5', 'rght' => '6', - 'depth' => 2 + 'depth' => 2, ], [ 'name' => 'plasma', 'parent_id' => '2', 'lft' => '7', 'rght' => '8', - 'depth' => 2 + 'depth' => 2, ], [ 'name' => 'portable', 'parent_id' => '1', 'lft' => '10', 'rght' => '19', - 'depth' => 1 + 'depth' => 1, ], [ 'name' => 'mp3', 'parent_id' => '6', 'lft' => '11', 'rght' => '14', - 'depth' => 2 + 'depth' => 2, ], [ 'name' => 'flash', 'parent_id' => '7', 'lft' => '12', 'rght' => '13', - 'depth' => 3 + 'depth' => 3, ], [ 'name' => 'cd', 'parent_id' => '6', 'lft' => '15', 'rght' => '16', - 'depth' => 2 + 'depth' => 2, ], [ 'name' => 'radios', 'parent_id' => '6', 'lft' => '17', 'rght' => '18', - 'depth' => 2 + 'depth' => 2, ], [ 'name' => 'alien hardware', 'parent_id' => null, 'lft' => '21', 'rght' => '22', - 'depth' => 0 - ] + 'depth' => 0, + ], ]; } diff --git a/tests/Fixture/OrderedUuidItemsFixture.php b/tests/Fixture/OrderedUuidItemsFixture.php index c30d147704a..cbbc440a44d 100644 --- a/tests/Fixture/OrderedUuidItemsFixture.php +++ b/tests/Fixture/OrderedUuidItemsFixture.php @@ -18,28 +18,14 @@ /** * Class OrderedUuiditemFixture - * */ class OrderedUuidItemsFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'string', 'length' => 32], - 'published' => ['type' => 'boolean', 'null' => false], - 'name' => ['type' => 'string', 'null' => false], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ]; } diff --git a/tests/Fixture/OrdersFixture.php b/tests/Fixture/OrdersFixture.php index 55706040b4c..cd163f40692 100644 --- a/tests/Fixture/OrdersFixture.php +++ b/tests/Fixture/OrdersFixture.php @@ -21,47 +21,17 @@ */ class OrdersFixture extends TestFixture { - /** - * {@inheritDoc} + * @inheritDoc */ - public $table = 'orders'; - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'product_category' => ['type' => 'integer', 'null' => false], - 'product_id' => ['type' => 'integer', 'null' => false], - '_indexes' => [ - 'product_category' => [ - 'type' => 'index', - 'columns' => ['product_category', 'product_id'] - ] - ], - '_constraints' => [ - 'primary' => [ - 'type' => 'primary', 'columns' => ['id'] - ], - 'product_category_fk' => [ - 'type' => 'foreign', - 'columns' => ['product_category', 'product_id'], - 'references' => ['products', ['category', 'id']], - 'update' => 'cascade', - 'delete' => 'cascade', - ] - ] - ]; + public string $table = 'orders'; /** * records property * * @var array */ - public $records = [ - ['product_category' => 1, 'product_id' => 1] + public array $records = [ + ['product_category' => 1, 'product_id' => 1], ]; } diff --git a/tests/Fixture/OtherArticlesFixture.php b/tests/Fixture/OtherArticlesFixture.php index a5dc0bdc3ed..54c0ab6937d 100644 --- a/tests/Fixture/OtherArticlesFixture.php +++ b/tests/Fixture/OtherArticlesFixture.php @@ -24,38 +24,44 @@ */ class OtherArticlesFixture implements FixtureInterface { - public $table = 'other_articles'; + public string $table = 'other_articles'; - public function create(ConnectionInterface $db) + public function create(ConnectionInterface $connection): bool { + return true; } - public function drop(ConnectionInterface $db) + public function drop(ConnectionInterface $connection): bool { + return true; } - public function insert(ConnectionInterface $db) + public function insert(ConnectionInterface $connection): bool { + return true; } - public function createConstraints(ConnectionInterface $db) + public function createConstraints(ConnectionInterface $connection): bool { + return true; } - public function dropConstraints(ConnectionInterface $db) + public function dropConstraints(ConnectionInterface $connection): bool { + return true; } - public function truncate(ConnectionInterface $db) + public function truncate(ConnectionInterface $connection): bool { + return true; } - public function connection() + public function connection(): string { return 'other'; } - public function sourceName() + public function sourceName(): string { return 'other_articles'; } diff --git a/tests/Fixture/PolymorphicTaggedFixture.php b/tests/Fixture/PolymorphicTaggedFixture.php index a64f6a340dd..f7508a0fdb4 100644 --- a/tests/Fixture/PolymorphicTaggedFixture.php +++ b/tests/Fixture/PolymorphicTaggedFixture.php @@ -18,34 +18,19 @@ class PolymorphicTaggedFixture extends TestFixture { - /** * table property * * @var string */ - public $table = 'polymorphic_tagged'; - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'tag_id' => ['type' => 'integer'], - 'foreign_key' => ['type' => 'integer'], - 'foreign_model' => ['type' => 'string'], - 'position' => ['type' => 'integer', 'null' => true], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; + public string $table = 'polymorphic_tagged'; /** * records property * * @var array */ - public $records = [ + public array $records = [ ['tag_id' => 1, 'foreign_key' => 1, 'foreign_model' => 'Posts', 'position' => 1], ['tag_id' => 1, 'foreign_key' => 1, 'foreign_model' => 'Articles', 'position' => 1], ]; diff --git a/tests/Fixture/PostsFixture.php b/tests/Fixture/PostsFixture.php index d39ab19b1a5..f81515bf546 100644 --- a/tests/Fixture/PostsFixture.php +++ b/tests/Fixture/PostsFixture.php @@ -21,29 +21,14 @@ */ class PostsFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'author_id' => ['type' => 'integer', 'null' => false], - 'title' => ['type' => 'string', 'null' => false], - 'body' => 'text', - 'published' => ['type' => 'string', 'length' => 1, 'default' => 'N'], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['author_id' => 1, 'title' => 'First Post', 'body' => 'First Post Body', 'published' => 'Y'], ['author_id' => 3, 'title' => 'Second Post', 'body' => 'Second Post Body', 'published' => 'Y'], - ['author_id' => 1, 'title' => 'Third Post', 'body' => 'Third Post Body', 'published' => 'Y'] + ['author_id' => 1, 'title' => 'Third Post', 'body' => 'Third Post Body', 'published' => 'Y'], ]; } diff --git a/tests/Fixture/ProductsFixture.php b/tests/Fixture/ProductsFixture.php index e801d2b5363..34ffc41906b 100644 --- a/tests/Fixture/ProductsFixture.php +++ b/tests/Fixture/ProductsFixture.php @@ -22,31 +22,18 @@ class ProductsFixture extends TestFixture { /** - * {@inheritDoc} + * @inheritDoc */ - public $table = 'products'; - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'category' => ['type' => 'integer', 'null' => false], - 'name' => ['type' => 'string', 'null' => false], - 'price' => ['type' => 'integer'], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['category', 'id']]] - ]; + public string $table = 'products'; /** * records property * * @var array */ - public $records = [ + public array $records = [ ['id' => 1, 'category' => 1, 'name' => 'First product', 'price' => 10], ['id' => 2, 'category' => 2, 'name' => 'Second product', 'price' => 20], - ['id' => 3, 'category' => 3, 'name' => 'Third product', 'price' => 30] + ['id' => 3, 'category' => 3, 'name' => 'Third product', 'price' => 30], ]; } diff --git a/tests/Fixture/ProfilesFixture.php b/tests/Fixture/ProfilesFixture.php index b566b5e5f12..992b87ba1c4 100644 --- a/tests/Fixture/ProfilesFixture.php +++ b/tests/Fixture/ProfilesFixture.php @@ -1,16 +1,16 @@ ['type' => 'integer'], - 'user_id' => ['type' => 'integer', 'null' => false], - 'first_name' => ['type' => 'string', 'null' => true], - 'last_name' => ['type' => 'string', 'null' => true], - 'is_active' => ['type' => 'boolean', 'null' => false, 'default' => true], - '_constraints' => [ - 'primary' => ['type' => 'primary', 'columns' => ['id']], - ] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['user_id' => 1, 'first_name' => 'mariano', 'last_name' => 'iglesias', 'is_active' => false], ['user_id' => 2, 'first_name' => 'nate', 'last_name' => 'abele', 'is_active' => false], ['user_id' => 3, 'first_name' => 'larry', 'last_name' => 'masters', 'is_active' => true], diff --git a/tests/Fixture/SectionsFixture.php b/tests/Fixture/SectionsFixture.php new file mode 100644 index 00000000000..b38b140fdd8 --- /dev/null +++ b/tests/Fixture/SectionsFixture.php @@ -0,0 +1,33 @@ + 'foo'], + ['title' => 'bar'], + ]; +} diff --git a/tests/Fixture/SectionsMembersFixture.php b/tests/Fixture/SectionsMembersFixture.php new file mode 100644 index 00000000000..9fe0320ddf9 --- /dev/null +++ b/tests/Fixture/SectionsMembersFixture.php @@ -0,0 +1,33 @@ + 1, 'member_id' => 1], + ['section_id' => 2, 'member_id' => 1], + ]; +} diff --git a/tests/Fixture/SectionsTranslationsFixture.php b/tests/Fixture/SectionsTranslationsFixture.php new file mode 100644 index 00000000000..dff8d1fc00b --- /dev/null +++ b/tests/Fixture/SectionsTranslationsFixture.php @@ -0,0 +1,30 @@ + ['type' => 'string', 'length' => 128], - 'data' => ['type' => 'binary', 'length' => TableSchema::LENGTH_MEDIUM, 'null' => true], - 'expires' => ['type' => 'integer', 'length' => 11, 'null' => true], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * records property * * @var array */ - public $records = []; + public array $records = []; } diff --git a/tests/Fixture/SiteArticlesFixture.php b/tests/Fixture/SiteArticlesFixture.php index a8f31b4fdbf..f8d91eb2574 100644 --- a/tests/Fixture/SiteArticlesFixture.php +++ b/tests/Fixture/SiteArticlesFixture.php @@ -18,27 +18,12 @@ class SiteArticlesFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'author_id' => ['type' => 'integer', 'null' => true], - 'site_id' => ['type' => 'integer', 'null' => false], - 'title' => ['type' => 'string', 'null' => true], - 'body' => 'text', - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id', 'site_id']]] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ [ 'id' => 1, 'author_id' => 1, @@ -66,6 +51,6 @@ class SiteArticlesFixture extends TestFixture 'site_id' => 1, 'title' => 'Fourth Article', 'body' => 'Fourth Article Body', - ] + ], ]; } diff --git a/tests/Fixture/SiteArticlesTagsFixture.php b/tests/Fixture/SiteArticlesTagsFixture.php index d348660aab9..efa5b4335c7 100644 --- a/tests/Fixture/SiteArticlesTagsFixture.php +++ b/tests/Fixture/SiteArticlesTagsFixture.php @@ -18,31 +18,16 @@ class SiteArticlesTagsFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'article_id' => ['type' => 'integer', 'null' => false], - 'tag_id' => ['type' => 'integer', 'null' => false], - 'site_id' => ['type' => 'integer', 'null' => false], - '_constraints' => [ - 'UNIQUE_TAG2' => ['type' => 'primary', 'columns' => ['article_id', 'tag_id', 'site_id']] - ] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['article_id' => 1, 'tag_id' => 1, 'site_id' => 1], ['article_id' => 1, 'tag_id' => 2, 'site_id' => 2], ['article_id' => 2, 'tag_id' => 4, 'site_id' => 2], ['article_id' => 4, 'tag_id' => 1, 'site_id' => 1], - ['article_id' => 1, 'tag_id' => 3, 'site_id' => 1] + ['article_id' => 1, 'tag_id' => 3, 'site_id' => 1], ]; } diff --git a/tests/Fixture/SiteAuthorsFixture.php b/tests/Fixture/SiteAuthorsFixture.php index c70a94e3466..55283c042ff 100644 --- a/tests/Fixture/SiteAuthorsFixture.php +++ b/tests/Fixture/SiteAuthorsFixture.php @@ -18,28 +18,15 @@ class SiteAuthorsFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'name' => ['type' => 'string', 'default' => null], - 'site_id' => ['type' => 'integer', 'null' => false], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id', 'site_id']]] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['id' => 1, 'name' => 'mark', 'site_id' => 1], ['id' => 2, 'name' => 'juan', 'site_id' => 2], ['id' => 3, 'name' => 'jose', 'site_id' => 2], - ['id' => 4, 'name' => 'andy', 'site_id' => 1] + ['id' => 4, 'name' => 'andy', 'site_id' => 1], ]; } diff --git a/tests/Fixture/SiteTagsFixture.php b/tests/Fixture/SiteTagsFixture.php index 42a836593da..a6735b27ba6 100644 --- a/tests/Fixture/SiteTagsFixture.php +++ b/tests/Fixture/SiteTagsFixture.php @@ -18,28 +18,15 @@ class SiteTagsFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'site_id' => ['type' => 'integer'], - 'name' => ['type' => 'string', 'null' => false], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id', 'site_id']]] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['id' => 1, 'site_id' => 1, 'name' => 'tag1'], ['id' => 2, 'site_id' => 2, 'name' => 'tag2'], ['id' => 3, 'site_id' => 1, 'name' => 'tag3'], - ['id' => 4, 'site_id' => 2, 'name' => 'tag4'] + ['id' => 4, 'site_id' => 2, 'name' => 'tag4'], ]; } diff --git a/tests/Fixture/SpecialPkFixture.php b/tests/Fixture/SpecialPkFixture.php new file mode 100644 index 00000000000..25ba0afc500 --- /dev/null +++ b/tests/Fixture/SpecialPkFixture.php @@ -0,0 +1,25 @@ + ['type' => 'integer'], - 'article_id' => ['type' => 'integer', 'null' => false], - 'tag_id' => ['type' => 'integer', 'null' => false], - 'highlighted' => ['type' => 'boolean', 'null' => true], - 'highlighted_time' => ['type' => 'timestamp', 'null' => true], - 'extra_info' => ['type' => 'string'], - 'author_id' => ['type' => 'integer', 'null' => true], - '_constraints' => [ - 'primary' => ['type' => 'primary', 'columns' => ['id']], - 'UNIQUE_TAG2' => ['type' => 'unique', 'columns' => ['article_id', 'tag_id']] - ] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['article_id' => 1, 'tag_id' => 3, 'highlighted' => false, 'highlighted_time' => null, 'extra_info' => 'Foo', 'author_id' => 1], ['article_id' => 2, 'tag_id' => 1, 'highlighted' => true, 'highlighted_time' => '2014-06-01 10:10:00', 'extra_info' => 'Bar', 'author_id' => 2], - ['article_id' => 10, 'tag_id' => 10, 'highlighted' => true, 'highlighted_time' => '2014-06-01 10:10:00', 'extra_info' => 'Baz', 'author_id' => null] + ['article_id' => 10, 'tag_id' => 10, 'highlighted' => true, 'highlighted_time' => '2014-06-01 10:10:00', 'extra_info' => 'Baz', 'author_id' => null], ]; } diff --git a/tests/Fixture/SpecialTagsTranslationsFixture.php b/tests/Fixture/SpecialTagsTranslationsFixture.php new file mode 100644 index 00000000000..d9a8dd3edb8 --- /dev/null +++ b/tests/Fixture/SpecialTagsTranslationsFixture.php @@ -0,0 +1,32 @@ + 2, 'locale' => 'eng', 'extra_info' => 'Translated Info'], + ]; +} diff --git a/tests/Fixture/TagsFixture.php b/tests/Fixture/TagsFixture.php index 4620122f327..e194f76e89a 100644 --- a/tests/Fixture/TagsFixture.php +++ b/tests/Fixture/TagsFixture.php @@ -14,7 +14,6 @@ */ namespace Cake\Test\Fixture; -use Cake\Database\Schema\TableSchema; use Cake\TestSuite\Fixture\TestFixture; /** @@ -22,28 +21,14 @@ */ class TagsFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer', 'null' => false], - 'name' => ['type' => 'string', 'null' => false], - 'description' => ['type' => 'text', 'length' => TableSchema::LENGTH_MEDIUM], - 'created' => ['type' => 'datetime', 'null' => true, 'default' => null], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['name' => 'tag1', 'description' => 'A big description', 'created' => '2016-01-01 00:00'], ['name' => 'tag2', 'description' => 'Another big description', 'created' => '2016-01-01 00:00'], - ['name' => 'tag3', 'description' => 'Yet another one', 'created' => '2016-01-01 00:00'] + ['name' => 'tag3', 'description' => 'Yet another one', 'created' => '2016-01-01 00:00'], ]; } diff --git a/tests/Fixture/TagsShadowTranslationsFixture.php b/tests/Fixture/TagsShadowTranslationsFixture.php new file mode 100644 index 00000000000..3aaf496d1eb --- /dev/null +++ b/tests/Fixture/TagsShadowTranslationsFixture.php @@ -0,0 +1,40 @@ + 'eng', 'id' => 1, 'name' => 'tag1 in eng'], + ['locale' => 'deu', 'id' => 1, 'name' => 'tag1 in deu'], + ['locale' => 'cze', 'id' => 1, 'name' => 'tag1 in cze'], + ['locale' => 'eng', 'id' => 2, 'name' => 'tag2 in eng'], + ['locale' => 'deu', 'id' => 2, 'name' => 'tag2 in deu'], + ['locale' => 'cze', 'id' => 2, 'name' => 'tag2 in cze'], + ['locale' => 'eng', 'id' => 3, 'name' => 'tag3 in eng'], + ['locale' => 'deu', 'id' => 3, 'name' => 'tag3 in deu'], + ['locale' => 'cze', 'id' => 3, 'name' => 'tag3 in cze'], + ]; +} diff --git a/tests/Fixture/TagsTranslationsFixture.php b/tests/Fixture/TagsTranslationsFixture.php index 658a268e418..b3e403f46cd 100644 --- a/tests/Fixture/TagsTranslationsFixture.php +++ b/tests/Fixture/TagsTranslationsFixture.php @@ -21,27 +21,14 @@ */ class TagsTranslationsFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer', 'null' => false, 'autoIncrement' => true], - 'locale' => ['type' => 'string', 'null' => false], - 'name' => ['type' => 'string', 'null' => false], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['locale' => 'en_us', 'name' => 'tag 1 translated into en_us'], ['locale' => 'en_us', 'name' => 'tag 2 translated into en_us'], - ['locale' => 'en_us', 'name' => 'tag 3 translated into en_us'] + ['locale' => 'en_us', 'name' => 'tag 3 translated into en_us'], ]; } diff --git a/tests/Fixture/TestPluginCommentsFixture.php b/tests/Fixture/TestPluginCommentsFixture.php index 90ff6ffda11..f806f919c4a 100644 --- a/tests/Fixture/TestPluginCommentsFixture.php +++ b/tests/Fixture/TestPluginCommentsFixture.php @@ -21,34 +21,17 @@ */ class TestPluginCommentsFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'article_id' => ['type' => 'integer', 'null' => false], - 'user_id' => ['type' => 'integer', 'null' => false], - 'comment' => 'text', - 'published' => ['type' => 'string', 'length' => 1, 'default' => 'N'], - 'created' => 'datetime', - 'updated' => 'datetime', - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['article_id' => 1, 'user_id' => 2, 'comment' => 'First Comment for First Plugin Article', 'published' => 'Y', 'created' => '2008-09-24 10:45:23', 'updated' => '2008-09-24 10:47:31'], ['article_id' => 1, 'user_id' => 4, 'comment' => 'Second Comment for First Plugin Article', 'published' => 'Y', 'created' => '2008-09-24 10:47:23', 'updated' => '2008-09-24 10:49:31'], ['article_id' => 1, 'user_id' => 1, 'comment' => 'Third Comment for First Plugin Article', 'published' => 'Y', 'created' => '2008-09-24 10:49:23', 'updated' => '2008-09-24 10:51:31'], ['article_id' => 1, 'user_id' => 1, 'comment' => 'Fourth Comment for First Plugin Article', 'published' => 'N', 'created' => '2008-09-24 10:51:23', 'updated' => '2008-09-24 10:53:31'], ['article_id' => 2, 'user_id' => 1, 'comment' => 'First Comment for Second Plugin Article', 'published' => 'Y', 'created' => '2008-09-24 10:53:23', 'updated' => '2008-09-24 10:55:31'], - ['article_id' => 2, 'user_id' => 2, 'comment' => 'Second Comment for Second Plugin Article', 'published' => 'Y', 'created' => '2008-09-24 10:55:23', 'updated' => '2008-09-24 10:57:31'] + ['article_id' => 2, 'user_id' => 2, 'comment' => 'Second Comment for Second Plugin Article', 'published' => 'Y', 'created' => '2008-09-24 10:55:23', 'updated' => '2008-09-24 10:57:31'], ]; } diff --git a/tests/Fixture/ThingsFixture.php b/tests/Fixture/ThingsFixture.php index af75a7fec02..1bae5d7e54a 100644 --- a/tests/Fixture/ThingsFixture.php +++ b/tests/Fixture/ThingsFixture.php @@ -18,25 +18,13 @@ class ThingsFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'title' => ['type' => 'string', 'length' => 20], - 'body' => ['type' => 'string', 'length' => 50] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['id' => 1, 'title' => 'a title', 'body' => 'a body'], - ['id' => 2, 'title' => 'another title', 'body' => 'another body'] + ['id' => 2, 'title' => 'another title', 'body' => 'another body'], ]; } diff --git a/tests/Fixture/TranslatesFixture.php b/tests/Fixture/TranslatesFixture.php index f75a531a694..3e067017132 100644 --- a/tests/Fixture/TranslatesFixture.php +++ b/tests/Fixture/TranslatesFixture.php @@ -21,35 +21,19 @@ */ class TranslatesFixture extends TestFixture { - /** * table property * * @var string */ - public $table = 'i18n'; - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'locale' => ['type' => 'string', 'length' => 6, 'null' => false], - 'model' => ['type' => 'string', 'null' => false], - 'foreign_key' => ['type' => 'integer', 'null' => false], - 'field' => ['type' => 'string', 'null' => false], - 'content' => ['type' => 'text'], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; + public string $table = 'i18n'; /** * records property * * @var array */ - public $records = [ + public array $records = [ ['locale' => 'eng', 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'title', 'content' => 'Title #1'], ['locale' => 'eng', 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'body', 'content' => 'Content #1'], ['locale' => 'eng', 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'description', 'content' => 'Description #1'], diff --git a/tests/Fixture/UniqueAuthorsFixture.php b/tests/Fixture/UniqueAuthorsFixture.php new file mode 100644 index 00000000000..c540c7b856f --- /dev/null +++ b/tests/Fixture/UniqueAuthorsFixture.php @@ -0,0 +1,32 @@ + null, 'second_author_id' => 1], + ]; +} diff --git a/tests/Fixture/UsersFixture.php b/tests/Fixture/UsersFixture.php index 668b5a6cc1b..0a34ddb5c68 100644 --- a/tests/Fixture/UsersFixture.php +++ b/tests/Fixture/UsersFixture.php @@ -21,27 +21,12 @@ */ class UsersFixture extends TestFixture { - - /** - * fields property - * - * @var array - */ - public $fields = [ - 'id' => ['type' => 'integer'], - 'username' => ['type' => 'string', 'null' => true], - 'password' => ['type' => 'string', 'null' => true], - 'created' => ['type' => 'timestamp', 'null' => true], - 'updated' => ['type' => 'timestamp', 'null' => true], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - /** * records property * * @var array */ - public $records = [ + public array $records = [ ['username' => 'mariano', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', 'created' => '2007-03-17 01:16:23', 'updated' => '2007-03-17 01:18:31'], ['username' => 'nate', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', 'created' => '2008-03-17 01:18:23', 'updated' => '2008-03-17 01:20:31'], ['username' => 'larry', 'password' => '$2a$10$u05j8FjsvLBNdfhBhc21LOuVMpzpabVXQ9OpC2wO3pSO0q6t7HHMO', 'created' => '2010-05-10 01:20:23', 'updated' => '2010-05-10 01:22:31'], diff --git a/tests/Fixture/UuidItemsFixture.php b/tests/Fixture/UuidItemsFixture.php new file mode 100644 index 00000000000..5359c99c1b0 --- /dev/null +++ b/tests/Fixture/UuidItemsFixture.php @@ -0,0 +1,37 @@ + '481fc6d0-b920-43e0-a40d-6d1740cf8569', 'published' => 0, 'name' => 'Item 1'], + ['id' => '48298a29-81c0-4c26-a7fb-413140cf8569', 'published' => 0, 'name' => 'Item 2'], + ['id' => '482b7756-8da0-419a-b21f-27da40cf8569', 'published' => 0, 'name' => 'Item 3'], + ['id' => '482cfd4b-0e7c-4ea3-9582-4cec40cf8569', 'published' => 0, 'name' => 'Item 4'], + ['id' => '4831181b-4020-4983-a29b-131440cf8569', 'published' => 0, 'name' => 'Item 5'], + ['id' => '483798c8-c7cc-430e-8cf9-4fcc40cf8569', 'published' => 0, 'name' => 'Item 6'], + ]; +} diff --git a/tests/Fixture/UuiditemsFixture.php b/tests/Fixture/UuiditemsFixture.php deleted file mode 100644 index 851f3fd96c6..00000000000 --- a/tests/Fixture/UuiditemsFixture.php +++ /dev/null @@ -1,50 +0,0 @@ - ['type' => 'uuid'], - 'published' => ['type' => 'boolean', 'null' => false], - 'name' => ['type' => 'string', 'null' => false], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - - /** - * records property - * - * @var array - */ - public $records = [ - ['id' => '481fc6d0-b920-43e0-a40d-6d1740cf8569', 'published' => 0, 'name' => 'Item 1'], - ['id' => '48298a29-81c0-4c26-a7fb-413140cf8569', 'published' => 0, 'name' => 'Item 2'], - ['id' => '482b7756-8da0-419a-b21f-27da40cf8569', 'published' => 0, 'name' => 'Item 3'], - ['id' => '482cfd4b-0e7c-4ea3-9582-4cec40cf8569', 'published' => 0, 'name' => 'Item 4'], - ['id' => '4831181b-4020-4983-a29b-131440cf8569', 'published' => 0, 'name' => 'Item 5'], - ['id' => '483798c8-c7cc-430e-8cf9-4fcc40cf8569', 'published' => 0, 'name' => 'Item 6'] - ]; -} diff --git a/tests/Fixture/UuidportfoliosFixture.php b/tests/Fixture/UuidportfoliosFixture.php deleted file mode 100644 index b9661fa4d6f..00000000000 --- a/tests/Fixture/UuidportfoliosFixture.php +++ /dev/null @@ -1,45 +0,0 @@ - ['type' => 'uuid'], - 'name' => ['type' => 'string', 'null' => false], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - - /** - * records property - * - * @var array - */ - public $records = [ - ['id' => '4806e091-6940-4d2b-b227-303740cf8569', 'name' => 'Portfolio 1'], - ['id' => '480af662-eb8c-47d3-886b-230540cf8569', 'name' => 'Portfolio 2'], - ]; -} diff --git a/tests/Fixture/sample.a68 b/tests/Fixture/sample.a68 new file mode 100644 index 00000000000..0fd3c0b375c --- /dev/null +++ b/tests/Fixture/sample.a68 @@ -0,0 +1,19 @@ +MODE ELEMENT = STRING; +MODE NODE = + STRUCT ( ELEMENT value, REF NODE next ); +MODE LIST = REF NODE; +LIST empty = NIL; +PROC append = ( REF LIST list, ELEMENT val ) VOID: +BEGIN + IF list IS empty + THEN + list := HEAP NODE := ( val, empty ) + ELSE + REF LIST tail := list; + WHILE next OF tail ISNT empty + DO + tail := next OF tail + OD; + next OF tail := HEAP NODE := ( val, empty ) + FI +END; \ No newline at end of file diff --git a/tests/Fixture/sample.html b/tests/Fixture/sample.html new file mode 100644 index 00000000000..4beed9c3e37 --- /dev/null +++ b/tests/Fixture/sample.html @@ -0,0 +1,14 @@ + + + +

    Browsers usually indent blockquote elements.

    + +
    +For 50 years, WWF has been protecting the future of nature. +The world's leading conservation organization, +WWF works in 100 countries and is supported by +1.2 million members in the United States and +close to 5 million globally. +
    + + diff --git a/tests/PHPStan/AssociationTableMixinClassReflectionExtension.php b/tests/PHPStan/AssociationTableMixinClassReflectionExtension.php index 627f292b94e..f2927bfa081 100644 --- a/tests/PHPStan/AssociationTableMixinClassReflectionExtension.php +++ b/tests/PHPStan/AssociationTableMixinClassReflectionExtension.php @@ -1,48 +1,46 @@ -broker = $broker; + public function __construct( + ReflectionProvider $reflectionProvider, + ) { + $this->reflectionProvider = $reflectionProvider; } - /** - * @return ClassReflection - */ protected function getTableReflection(): ClassReflection { - return $this->broker->getClass(Table::class); + return $this->reflectionProvider->getClass(Table::class); } /** * @param ClassReflection $classReflection Class reflection * @param string $methodName Method name - * @return bool */ public function hasMethod(ClassReflection $classReflection, string $methodName): bool { + // magic findBy* method + if ($classReflection->isSubclassOf(Table::class) && preg_match('/^find(?:\w+)?By/', $methodName) > 0) { + return true; + } + if (!$classReflection->isSubclassOf(Association::class)) { return false; } @@ -53,17 +51,20 @@ public function hasMethod(ClassReflection $classReflection, string $methodName): /** * @param ClassReflection $classReflection Class reflection * @param string $methodName Method name - * @return MethodReflection */ public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection { + // magic findBy* method + if ($classReflection->isSubclassOf(Table::class) && preg_match('/^find(?:\w+)?By/', $methodName) > 0) { + return new TableFindByPropertyMethodReflection($methodName, $classReflection); + } + return $this->getTableReflection()->getNativeMethod($methodName); } /** * @param ClassReflection $classReflection Class reflection * @param string $propertyName Method name - * @return bool */ public function hasProperty(ClassReflection $classReflection, string $propertyName): bool { @@ -77,7 +78,6 @@ public function hasProperty(ClassReflection $classReflection, string $propertyNa /** * @param ClassReflection $classReflection Class reflection * @param string $propertyName Method name - * @return PropertyReflection */ public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection { diff --git a/tests/PHPStan/TableFindByPropertyMethodReflection.php b/tests/PHPStan/TableFindByPropertyMethodReflection.php new file mode 100644 index 00000000000..dc60a78e299 --- /dev/null +++ b/tests/PHPStan/TableFindByPropertyMethodReflection.php @@ -0,0 +1,130 @@ +name = $name; + $this->declaringClass = $declaringClass; + } + + public function getDeclaringClass(): ClassReflection + { + return $this->declaringClass; + } + + public function getPrototype(): MethodReflection + { + return $this; + } + + public function isStatic(): bool + { + return false; + } + + /** + * @return \PHPStan\Reflection\ParameterReflection[] + */ + public function getParameters(): array + { + return []; + } + + public function isVariadic(): bool + { + return true; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function getName(): string + { + return $this->name; + } + + public function getReturnType(): Type + { + return new ObjectType(SelectQuery::class); + } + + public function getDocComment(): ?string + { + return null; + } + + public function getVariants(): array + { + return [ + new FunctionVariantWithPhpDocs( + TemplateTypeMap::createEmpty(), + TemplateTypeMap::createEmpty(), + [], + true, + $this->getReturnType(), + $this->getReturnType(), + $this->getReturnType(), + ), + ]; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getThrowType(): ?Type + { + return null; + } + + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } +} diff --git a/tests/TestCase/Auth/BasicAuthenticateTest.php b/tests/TestCase/Auth/BasicAuthenticateTest.php deleted file mode 100644 index e1d44c12906..00000000000 --- a/tests/TestCase/Auth/BasicAuthenticateTest.php +++ /dev/null @@ -1,235 +0,0 @@ -Collection = $this->getMockBuilder(ComponentRegistry::class)->getMock(); - $this->auth = new BasicAuthenticate($this->Collection, [ - 'userModel' => 'Users', - 'realm' => 'localhost' - ]); - - $password = password_hash('password', PASSWORD_BCRYPT); - $User = TableRegistry::get('Users'); - $User->updateAll(['password' => $password], []); - $this->response = $this->getMockBuilder(Response::class)->getMock(); - } - - /** - * test applying settings in the constructor - * - * @return void - */ - public function testConstructor() - { - $object = new BasicAuthenticate($this->Collection, [ - 'userModel' => 'AuthUser', - 'fields' => ['username' => 'user', 'password' => 'password'] - ]); - $this->assertEquals('AuthUser', $object->config('userModel')); - $this->assertEquals(['username' => 'user', 'password' => 'password'], $object->config('fields')); - } - - /** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateNoData() - { - $request = new ServerRequest('posts/index'); - - $this->response->expects($this->never()) - ->method('header'); - - $this->assertFalse($this->auth->getUser($request)); - } - - /** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateNoUsername() - { - $request = new ServerRequest([ - 'url' => 'posts/index', - 'environment' => ['PHP_AUTH_PW' => 'foobar'] - ]); - - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - - /** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateNoPassword() - { - $request = new ServerRequest([ - 'url' => 'posts/index', - 'environment' => ['PHP_AUTH_USER' => 'mariano'] - ]); - - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - - /** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateInjection() - { - $request = new ServerRequest([ - 'url' => 'posts/index', - 'environment' => [ - 'PHP_AUTH_USER' => '> 1', - 'PHP_AUTH_PW' => "' OR 1 = 1" - ] - ]); - $request->addParams(['pass' => []]); - - $this->assertFalse($this->auth->getUser($request)); - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - - /** - * Test that username of 0 works. - * - * @return void - */ - public function testAuthenticateUsernameZero() - { - $User = TableRegistry::get('Users'); - $User->updateAll(['username' => '0'], ['username' => 'mariano']); - - $request = new ServerRequest('posts/index'); - $request->data = ['User' => [ - 'user' => '0', - 'password' => 'password' - ]]; - $_SERVER['PHP_AUTH_USER'] = '0'; - $_SERVER['PHP_AUTH_PW'] = 'password'; - - $expected = [ - 'id' => 1, - 'username' => '0', - 'created' => new Time('2007-03-17 01:16:23'), - 'updated' => new Time('2007-03-17 01:18:31'), - ]; - $this->assertEquals($expected, $this->auth->authenticate($request, $this->response)); - } - - /** - * test that challenge headers are sent when no credentials are found. - * - * @return void - */ - public function testAuthenticateChallenge() - { - $request = new ServerRequest('posts/index'); - $request->addParams(['pass' => []]); - - try { - $this->auth->unauthenticated($request, $this->response); - } catch (UnauthorizedException $e) { - } - - $this->assertNotEmpty($e); - - $expected = ['WWW-Authenticate: Basic realm="localhost"']; - $this->assertEquals($expected, $e->responseHeader()); - } - - /** - * test authenticate success - * - * @return void - */ - public function testAuthenticateSuccess() - { - $request = new ServerRequest([ - 'url' => 'posts/index', - 'environment' => [ - 'PHP_AUTH_USER' => 'mariano', - 'PHP_AUTH_PW' => 'password' - ] - ]); - $request->addParams(['pass' => []]); - - $result = $this->auth->authenticate($request, $this->response); - $expected = [ - 'id' => 1, - 'username' => 'mariano', - 'created' => new Time('2007-03-17 01:16:23'), - 'updated' => new Time('2007-03-17 01:18:31') - ]; - $this->assertEquals($expected, $result); - } - - /** - * test scope failure. - * - * @return void - */ - public function testAuthenticateFailReChallenge() - { - $this->expectException(\Cake\Network\Exception\UnauthorizedException::class); - $this->expectExceptionCode(401); - $this->auth->config('scope.username', 'nate'); - $request = new ServerRequest([ - 'url' => 'posts/index', - 'environment' => [ - 'PHP_AUTH_USER' => 'mariano', - 'PHP_AUTH_PW' => 'password' - ] - ]); - $request->addParams(['pass' => []]); - - $this->auth->unauthenticated($request, $this->response); - } -} diff --git a/tests/TestCase/Auth/ControllerAuthorizeTest.php b/tests/TestCase/Auth/ControllerAuthorizeTest.php deleted file mode 100644 index 8fa1d7747c9..00000000000 --- a/tests/TestCase/Auth/ControllerAuthorizeTest.php +++ /dev/null @@ -1,89 +0,0 @@ -controller = $this->getMockBuilder(Controller::class) - ->setMethods(['isAuthorized']) - ->disableOriginalConstructor() - ->getMock(); - $this->components = $this->getMockBuilder(ComponentRegistry::class)->getMock(); - $this->components->expects($this->any()) - ->method('getController') - ->will($this->returnValue($this->controller)); - - $this->auth = new ControllerAuthorize($this->components); - } - - /** - * @return void - */ - public function testControllerErrorOnMissingMethod() - { - $this->expectException(\Cake\Core\Exception\Exception::class); - $this->auth->controller(new Controller()); - } - - /** - * test failure - * - * @return void - */ - public function testAuthorizeFailure() - { - $user = []; - $request = new ServerRequest('/posts/index'); - $this->assertFalse($this->auth->authorize($user, $request)); - } - - /** - * test isAuthorized working. - * - * @return void - */ - public function testAuthorizeSuccess() - { - $user = ['User' => ['username' => 'mark']]; - $request = new ServerRequest('/posts/index'); - - $this->controller->expects($this->once()) - ->method('isAuthorized') - ->with($user) - ->will($this->returnValue(true)); - - $this->assertTrue($this->auth->authorize($user, $request)); - } -} diff --git a/tests/TestCase/Auth/DefaultPasswordHasherTest.php b/tests/TestCase/Auth/DefaultPasswordHasherTest.php deleted file mode 100644 index 0f2978a1cba..00000000000 --- a/tests/TestCase/Auth/DefaultPasswordHasherTest.php +++ /dev/null @@ -1,54 +0,0 @@ -assertTrue($hasher->needsRehash(md5('foo'))); - $password = $hasher->hash('foo'); - $this->assertFalse($hasher->needsRehash($password)); - } - - /** - * Tests that when the hash options change, the password needs - * to be rehashed - * - * @return void - */ - public function testNeedsRehashWithDifferentOptions() - { - $defaultHasher = new DefaultPasswordHasher(['hashType' => PASSWORD_BCRYPT, 'hashOptions' => ['cost' => 10]]); - $updatedHasher = new DefaultPasswordHasher(['hashType' => PASSWORD_BCRYPT, 'hashOptions' => ['cost' => 12]]); - $password = $defaultHasher->hash('foo'); - - $this->assertTrue($updatedHasher->needsRehash($password)); - } -} diff --git a/tests/TestCase/Auth/DigestAuthenticateTest.php b/tests/TestCase/Auth/DigestAuthenticateTest.php deleted file mode 100644 index a9d3bcfbd3d..00000000000 --- a/tests/TestCase/Auth/DigestAuthenticateTest.php +++ /dev/null @@ -1,551 +0,0 @@ -Collection = $this->getMockBuilder(ComponentRegistry::class)->getMock(); - $this->auth = new DigestAuthenticate($this->Collection, [ - 'realm' => 'localhost', - 'nonce' => 123, - 'opaque' => '123abc' - ]); - - $password = DigestAuthenticate::password('mariano', 'cake', 'localhost'); - $User = TableRegistry::get('Users'); - $User->updateAll(['password' => $password], []); - - $this->response = $this->getMockBuilder(Response::class)->getMock(); - } - - /** - * test applying settings in the constructor - * - * @return void - */ - public function testConstructor() - { - $object = new DigestAuthenticate($this->Collection, [ - 'userModel' => 'AuthUser', - 'fields' => ['username' => 'user', 'password' => 'pass'], - 'nonce' => 123456 - ]); - $this->assertEquals('AuthUser', $object->config('userModel')); - $this->assertEquals(['username' => 'user', 'password' => 'pass'], $object->config('fields')); - $this->assertEquals(123456, $object->config('nonce')); - $this->assertEquals(env('SERVER_NAME'), $object->config('realm')); - } - - /** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateNoData() - { - $request = new ServerRequest('posts/index'); - - $this->response->expects($this->never()) - ->method('header'); - - $this->assertFalse($this->auth->getUser($request, $this->response)); - } - - /** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateWrongUsername() - { - $this->expectException(\Cake\Network\Exception\UnauthorizedException::class); - $this->expectExceptionCode(401); - $request = new ServerRequest('posts/index'); - $request->addParams(['pass' => []]); - - $data = [ - 'username' => 'incorrect_user', - 'realm' => 'localhost', - 'nonce' => $this->generateNonce(), - 'uri' => '/dir/index.html', - 'qop' => 'auth', - 'nc' => 0000001, - 'cnonce' => '0a4f113b' - ]; - $data['response'] = $this->auth->generateResponseHash($data, '09faa9931501bf30f0d4253fa7763022', 'GET'); - $request->env('PHP_AUTH_DIGEST', $this->digestHeader($data)); - - $this->auth->unauthenticated($request, $this->response); - } - - /** - * test that challenge headers are sent when no credentials are found. - * - * @return void - */ - public function testAuthenticateChallenge() - { - $request = new ServerRequest([ - 'url' => 'posts/index', - 'environment' => ['REQUEST_METHOD' => 'GET'] - ]); - $request->addParams(['pass' => []]); - - try { - $this->auth->unauthenticated($request, $this->response); - } catch (UnauthorizedException $e) { - } - - $this->assertNotEmpty($e); - - $header = $e->responseHeader()[0]; - $this->assertRegexp( - '/^WWW\-Authenticate: Digest realm="localhost",qop="auth",nonce="[a-zA-Z0-9=]+",opaque="123abc"$/', - $e->responseHeader()[0] - ); - } - - /** - * test that challenge headers include stale when the nonce is stale - * - * @return void - */ - public function testAuthenticateChallengeIncludesStaleAttributeOnStaleNonce() - { - $request = new ServerRequest([ - 'url' => 'posts/index', - 'environment' => ['REQUEST_METHOD' => 'GET'] - ]); - $request->addParams(['pass' => []]); - $data = [ - 'uri' => '/dir/index.html', - 'nonce' => $this->generateNonce(null, 5, strtotime('-10 minutes')), - 'nc' => 1, - 'cnonce' => '123', - 'qop' => 'auth', - ]; - $data['response'] = $this->auth->generateResponseHash($data, '09faa9931501bf30f0d4253fa7763022', 'GET'); - $request->env('PHP_AUTH_DIGEST', $this->digestHeader($data)); - - try { - $this->auth->unauthenticated($request, $this->response); - } catch (UnauthorizedException $e) { - } - $this->assertNotEmpty($e); - - $header = $e->responseHeader()[0]; - $this->assertContains('stale=true', $header); - } - - /** - * Test that authentication fails when a nonce is stale - * - * @return void - */ - public function testAuthenticateFailsOnStaleNonce() - { - $request = new ServerRequest([ - 'url' => 'posts/index', - 'environment' => ['REQUEST_METHOD' => 'GET'] - ]); - $request->addParams(['pass' => []]); - - $data = [ - 'uri' => '/dir/index.html', - 'nonce' => $this->generateNonce(null, 5, strtotime('-10 minutes')), - 'nc' => 1, - 'cnonce' => '123', - 'qop' => 'auth', - ]; - $data['response'] = $this->auth->generateResponseHash($data, '09faa9931501bf30f0d4253fa7763022', 'GET'); - $request->env('PHP_AUTH_DIGEST', $this->digestHeader($data)); - $result = $this->auth->authenticate($request, $this->response); - $this->assertFalse($result, 'Stale nonce should fail'); - } - - /** - * Test that nonces are required. - * - * @return void - */ - public function testAuthenticateValidUsernamePasswordNoNonce() - { - $request = new ServerRequest([ - 'url' => 'posts/index', - 'environment' => ['REQUEST_METHOD' => 'GET'] - ]); - $request->addParams(['pass' => []]); - - $data = [ - 'username' => 'mariano', - 'realm' => 'localhos', - 'uri' => '/dir/index.html', - 'nonce' => '', - 'nc' => 1, - 'cnonce' => '123', - 'qop' => 'auth', - ]; - $data['response'] = $this->auth->generateResponseHash($data, '09faa9931501bf30f0d4253fa7763022', 'GET'); - $request->env('PHP_AUTH_DIGEST', $this->digestHeader($data)); - $result = $this->auth->authenticate($request, $this->response); - $this->assertFalse($result, 'Empty nonce should fail'); - } - - /** - * test authenticate success - * - * @return void - */ - public function testAuthenticateSuccess() - { - $request = new ServerRequest([ - 'url' => 'posts/index', - 'environment' => ['REQUEST_METHOD' => 'GET'] - ]); - $request->addParams(['pass' => []]); - - $data = [ - 'uri' => '/dir/index.html', - 'nonce' => $this->generateNonce(), - 'nc' => 1, - 'cnonce' => '123', - 'qop' => 'auth', - ]; - $data['response'] = $this->auth->generateResponseHash($data, '09faa9931501bf30f0d4253fa7763022', 'GET'); - $request->env('PHP_AUTH_DIGEST', $this->digestHeader($data)); - - $result = $this->auth->authenticate($request, $this->response); - $expected = [ - 'id' => 1, - 'username' => 'mariano', - 'created' => new Time('2007-03-17 01:16:23'), - 'updated' => new Time('2007-03-17 01:18:31') - ]; - $this->assertEquals($expected, $result); - } - - /** - * test authenticate success even when digest 'password' is a hidden field. - * - * @return void - */ - public function testAuthenticateSuccessHiddenPasswordField() - { - $User = TableRegistry::get('Users'); - $User->setEntityClass(ProtectedUser::class); - - $request = new ServerRequest([ - 'url' => 'posts/index', - 'environment' => ['REQUEST_METHOD' => 'GET'] - ]); - $request->addParams(['pass' => []]); - - $data = [ - 'uri' => '/dir/index.html', - 'nonce' => $this->generateNonce(), - 'nc' => 1, - 'cnonce' => '123', - 'qop' => 'auth', - ]; - $data['response'] = $this->auth->generateResponseHash($data, '09faa9931501bf30f0d4253fa7763022', 'GET'); - $request->env('PHP_AUTH_DIGEST', $this->digestHeader($data)); - - $result = $this->auth->authenticate($request, $this->response); - $expected = [ - 'id' => 1, - 'username' => 'mariano', - 'created' => new Time('2007-03-17 01:16:23'), - 'updated' => new Time('2007-03-17 01:18:31') - ]; - $this->assertEquals($expected, $result); - } - - /** - * test authenticate success - * - * @return void - */ - public function testAuthenticateSuccessSimulatedRequestMethod() - { - $request = new ServerRequest([ - 'url' => 'posts/index', - 'post' => ['_method' => 'PUT'], - 'environment' => ['REQUEST_METHOD' => 'GET'] - ]); - $request->addParams(['pass' => []]); - - $data = [ - 'username' => 'mariano', - 'uri' => '/dir/index.html', - 'nonce' => $this->generateNonce(), - 'nc' => 1, - 'cnonce' => '123', - 'qop' => 'auth', - ]; - $data['response'] = $this->auth->generateResponseHash($data, '09faa9931501bf30f0d4253fa7763022', 'GET'); - $request->env('PHP_AUTH_DIGEST', $this->digestHeader($data)); - - $result = $this->auth->authenticate($request, $this->response); - $expected = [ - 'id' => 1, - 'username' => 'mariano', - 'created' => new Time('2007-03-17 01:16:23'), - 'updated' => new Time('2007-03-17 01:18:31') - ]; - $this->assertEquals($expected, $result); - } - - /** - * test scope failure. - * - * @return void - */ - public function testAuthenticateFailReChallenge() - { - $this->expectException(\Cake\Network\Exception\UnauthorizedException::class); - $this->expectExceptionCode(401); - $this->auth->config('scope.username', 'nate'); - $request = new ServerRequest([ - 'url' => 'posts/index', - 'environment' => ['REQUEST_METHOD' => 'GET'] - ]); - $request->addParams(['pass' => []]); - - $data = [ - 'username' => 'invalid', - 'uri' => '/dir/index.html', - 'nonce' => $this->generateNonce(), - 'nc' => 1, - 'cnonce' => '123', - 'qop' => 'auth', - ]; - $data['response'] = $this->auth->generateResponseHash($data, '09faa9931501bf30f0d4253fa7763022', 'GET'); - $request->env('PHP_AUTH_DIGEST', $this->digestHeader($data)); - $this->auth->unauthenticated($request, $this->response); - } - - /** - * testLoginHeaders method - * - * @return void - */ - public function testLoginHeaders() - { - $request = new ServerRequest([ - 'environment' => ['SERVER_NAME' => 'localhost'] - ]); - $this->auth = new DigestAuthenticate($this->Collection, [ - 'realm' => 'localhost', - ]); - $result = $this->auth->loginHeaders($request); - - $this->assertRegexp( - '/^WWW\-Authenticate: Digest realm="localhost",qop="auth",nonce="[a-zA-Z0-9=]+",opaque="[a-f0-9]+"$/', - $result - ); - } - - /** - * testParseDigestAuthData method - * - * @return void - */ - public function testParseAuthData() - { - $digest = << 'Mufasa', - 'realm' => 'testrealm@host.com', - 'nonce' => 'dcd98b7102dd2f0e8b11d0f600bfb0c093', - 'uri' => '/dir/index.html?query=string&value=some%20value', - 'qop' => 'auth', - 'nc' => '00000001', - 'cnonce' => '0a4f113b', - 'response' => '6629fae49393a05397450978507c4ef1', - 'opaque' => '5ccc069c403ebaf9f0171e9517f40e41' - ]; - $result = $this->auth->parseAuthData($digest); - $this->assertSame($expected, $result); - - $result = $this->auth->parseAuthData(''); - $this->assertNull($result); - } - - /** - * Test parsing a full URI. While not part of the spec some mobile clients will do it wrong. - * - * @return void - */ - public function testParseAuthDataFullUri() - { - $digest = <<auth->parseAuthData($digest); - $this->assertSame($expected, $result['uri']); - } - - /** - * test parsing digest information with email addresses - * - * @return void - */ - public function testParseAuthEmailAddress() - { - $digest = << 'mark@example.com', - 'realm' => 'testrealm@host.com', - 'nonce' => 'dcd98b7102dd2f0e8b11d0f600bfb0c093', - 'uri' => '/dir/index.html', - 'qop' => 'auth', - 'nc' => '00000001', - 'cnonce' => '0a4f113b', - 'response' => '6629fae49393a05397450978507c4ef1', - 'opaque' => '5ccc069c403ebaf9f0171e9517f40e41' - ]; - $result = $this->auth->parseAuthData($digest); - $this->assertSame($expected, $result); - } - - /** - * test password hashing - * - * @return void - */ - public function testPassword() - { - $result = DigestAuthenticate::password('mark', 'password', 'localhost'); - $expected = md5('mark:localhost:password'); - $this->assertEquals($expected, $result); - } - - /** - * Generate a nonce for testing. - * - * @param string $secret The secret to use. - * @param int $expires Time to live - * @return string - */ - protected function generateNonce($secret = null, $expires = 300, $time = null) - { - $secret = $secret ?: Configure::read('Security.salt'); - $time = $time ?: microtime(true); - $expiryTime = $time + $expires; - $signatureValue = hash_hmac('sha256', $expiryTime . ':' . $secret, $secret); - $nonceValue = $expiryTime . ':' . $signatureValue; - - return base64_encode($nonceValue); - } - - /** - * Create a digest header string from an array of data. - * - * @param array $data the data to convert into a header. - * @return string - */ - protected function digestHeader($data) - { - $data += [ - 'username' => 'mariano', - 'realm' => 'localhost', - 'opaque' => '123abc' - ]; - $digest = << ['Weak', 'Default']]); - $weak = new WeakPasswordHasher(); - $this->assertSame($weak->hash('foo'), $hasher->hash('foo')); - - $simple = new DefaultPasswordHasher(); - $hasher = new FallbackPasswordHasher(['hashers' => ['Weak', 'Default']]); - $this->assertSame($weak->hash('foo'), $hasher->hash('foo')); - } - - /** - * Tests that the check method will check with configured hashers until a match - * is found - * - * @return void - */ - public function testCheck() - { - $hasher = new FallbackPasswordHasher(['hashers' => ['Weak', 'Default']]); - $weak = new WeakPasswordHasher(); - $simple = new DefaultPasswordHasher(); - - $hash = $simple->hash('foo'); - $otherHash = $weak->hash('foo'); - $this->assertTrue($hasher->check('foo', $hash)); - $this->assertTrue($hasher->check('foo', $otherHash)); - } - - /** - * Tests that the check method will work with configured hashers including different - * configs per hasher. - * - * @return void - */ - public function testCheckWithConfigs() - { - $hasher = new FallbackPasswordHasher(['hashers' => ['Default', 'Weak' => ['hashType' => 'md5']]]); - $legacy = new WeakPasswordHasher(['hashType' => 'md5']); - $simple = new DefaultPasswordHasher(); - - $hash = $simple->hash('foo'); - $legacyHash = $legacy->hash('foo'); - $this->assertTrue($hash !== $legacyHash); - $this->assertTrue($hasher->check('foo', $hash)); - $this->assertTrue($hasher->check('foo', $legacyHash)); - } - - /** - * Tests that the password only needs to be re-built according to the first hasher - * - * @return void - */ - public function testNeedsRehash() - { - $hasher = new FallbackPasswordHasher(['hashers' => ['Default', 'Weak']]); - $weak = new WeakPasswordHasher(); - $otherHash = $weak->hash('foo'); - $this->assertTrue($hasher->needsRehash($otherHash)); - - $simple = new DefaultPasswordHasher(); - $hash = $simple->hash('foo'); - $this->assertFalse($hasher->needsRehash($hash)); - } -} diff --git a/tests/TestCase/Auth/FormAuthenticateTest.php b/tests/TestCase/Auth/FormAuthenticateTest.php deleted file mode 100644 index 0b02267ebc0..00000000000 --- a/tests/TestCase/Auth/FormAuthenticateTest.php +++ /dev/null @@ -1,451 +0,0 @@ -Collection = $this->getMockBuilder(ComponentRegistry::class)->getMock(); - $this->auth = new FormAuthenticate($this->Collection, [ - 'userModel' => 'Users' - ]); - $password = password_hash('password', PASSWORD_DEFAULT); - - TableRegistry::clear(); - $Users = TableRegistry::get('Users'); - $Users->updateAll(['password' => $password], []); - - $AuthUsers = TableRegistry::get('AuthUsers', [ - 'className' => 'TestApp\Model\Table\AuthUsersTable' - ]); - $AuthUsers->updateAll(['password' => $password], []); - - $this->response = $this->getMockBuilder(Response::class)->getMock(); - } - - /** - * test applying settings in the constructor - * - * @return void - */ - public function testConstructor() - { - $object = new FormAuthenticate($this->Collection, [ - 'userModel' => 'AuthUsers', - 'fields' => ['username' => 'user', 'password' => 'password'] - ]); - $this->assertEquals('AuthUsers', $object->config('userModel')); - $this->assertEquals(['username' => 'user', 'password' => 'password'], $object->config('fields')); - } - - /** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateNoData() - { - $request = new ServerRequest('posts/index'); - $request->data = []; - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - - /** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateNoUsername() - { - $request = new ServerRequest('posts/index'); - $request->data = ['password' => 'foobar']; - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - - /** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateNoPassword() - { - $request = new ServerRequest('posts/index'); - $request->data = ['username' => 'mariano']; - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - - /** - * test authenticate password is false method - * - * @return void - */ - public function testAuthenticatePasswordIsFalse() - { - $request = new ServerRequest('posts/index', false); - $request->data = [ - 'username' => 'mariano', - 'password' => null - ]; - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - - /** - * Test for password as empty string with _checkFields() call skipped - * Refs https://github.com/cakephp/cakephp/pull/2441 - * - * @return void - */ - public function testAuthenticatePasswordIsEmptyString() - { - $request = new ServerRequest('posts/index', false); - $request->data = [ - 'username' => 'mariano', - 'password' => '' - ]; - - $this->auth = $this->getMockBuilder(FormAuthenticate::class) - ->setMethods(['_checkFields']) - ->setConstructorArgs([ - $this->Collection, - ['userModel' => 'Users'] - ]) - ->getMock(); - - // Simulate that check for ensuring password is not empty is missing. - $this->auth->expects($this->once()) - ->method('_checkFields') - ->will($this->returnValue(true)); - - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - - /** - * test authenticate field is not string - * - * @return void - */ - public function testAuthenticateFieldsAreNotString() - { - $request = new ServerRequest('posts/index', false); - $request->data = [ - 'username' => ['mariano', 'phpnut'], - 'password' => 'my password' - ]; - $this->assertFalse($this->auth->authenticate($request, $this->response)); - - $request->data = [ - 'username' => 'mariano', - 'password' => ['password1', 'password2'] - ]; - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - - /** - * test the authenticate method - * - * @return void - */ - public function testAuthenticateInjection() - { - $request = new ServerRequest('posts/index'); - $request->data = [ - 'username' => '> 1', - 'password' => "' OR 1 = 1" - ]; - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - - /** - * test authenticate success - * - * @return void - */ - public function testAuthenticateSuccess() - { - $request = new ServerRequest('posts/index'); - $request->data = [ - 'username' => 'mariano', - 'password' => 'password' - ]; - $result = $this->auth->authenticate($request, $this->response); - $expected = [ - 'id' => 1, - 'username' => 'mariano', - 'created' => new Time('2007-03-17 01:16:23'), - 'updated' => new Time('2007-03-17 01:18:31') - ]; - $this->assertEquals($expected, $result); - } - - /** - * Test that authenticate() includes virtual fields. - * - * @return void - */ - public function testAuthenticateIncludesVirtualFields() - { - $users = TableRegistry::get('Users'); - $users->entityClass('TestApp\Model\Entity\VirtualUser'); - - $request = new ServerRequest('posts/index'); - $request->data = [ - 'username' => 'mariano', - 'password' => 'password' - ]; - $result = $this->auth->authenticate($request, $this->response); - $expected = [ - 'id' => 1, - 'username' => 'mariano', - 'bonus' => 'bonus', - 'created' => new Time('2007-03-17 01:16:23'), - 'updated' => new Time('2007-03-17 01:18:31') - ]; - $this->assertEquals($expected, $result); - } - - /** - * test a model in a plugin. - * - * @return void - */ - public function testPluginModel() - { - Plugin::load('TestPlugin'); - - $PluginModel = TableRegistry::get('TestPlugin.AuthUsers'); - $user['id'] = 1; - $user['username'] = 'gwoo'; - $user['password'] = password_hash(Security::salt() . 'cake', PASSWORD_BCRYPT); - $PluginModel->save(new Entity($user)); - - $this->auth->config('userModel', 'TestPlugin.AuthUsers'); - - $request = new ServerRequest('posts/index'); - $request->data = [ - 'username' => 'gwoo', - 'password' => 'cake' - ]; - - $result = $this->auth->authenticate($request, $this->response); - $expected = [ - 'id' => 1, - 'username' => 'gwoo', - 'created' => new Time('2007-03-17 01:16:23'), - 'updated' => new Time('2007-03-17 01:18:31') - ]; - $this->assertEquals($expected, $result); - Plugin::unload(); - } - - /** - * Test using custom finder - * - * @return void - */ - public function testFinder() - { - $request = new ServerRequest('posts/index'); - $request->data = [ - 'username' => 'mariano', - 'password' => 'password' - ]; - - $this->auth->config([ - 'userModel' => 'AuthUsers', - 'finder' => 'auth' - ]); - - $result = $this->auth->authenticate($request, $this->response); - $expected = [ - 'id' => 1, - 'username' => 'mariano', - ]; - $this->assertEquals($expected, $result, 'Result should not contain "created" and "modified" fields'); - - $this->auth->config([ - 'finder' => ['auth' => ['return_created' => true]] - ]); - - $result = $this->auth->authenticate($request, $this->response); - $expected = [ - 'id' => 1, - 'username' => 'mariano', - 'created' => new Time('2007-03-17 01:16:23'), - ]; - $this->assertEquals($expected, $result); - } - - /** - * Test using custom finder - * - * @return void - */ - public function testFinderOptions() - { - $request = new ServerRequest('posts/index'); - $request->data = [ - 'username' => 'mariano', - 'password' => 'password' - ]; - - $this->auth->config([ - 'userModel' => 'AuthUsers', - 'finder' => 'username' - ]); - - $result = $this->auth->authenticate($request, $this->response); - $expected = [ - 'id' => 1, - 'username' => 'mariano', - ]; - $this->assertEquals($expected, $result); - - $this->auth->config([ - 'finder' => ['username' => ['username' => 'nate']] - ]); - - $result = $this->auth->authenticate($request, $this->response); - $expected = [ - 'id' => 5, - 'username' => 'nate', - ]; - $this->assertEquals($expected, $result); - } - - /** - * test password hasher settings - * - * @return void - */ - public function testPasswordHasherSettings() - { - $this->auth->config('passwordHasher', [ - 'className' => 'Default', - 'hashType' => PASSWORD_BCRYPT - ]); - - $passwordHasher = $this->auth->passwordHasher(); - $result = $passwordHasher->config(); - $this->assertEquals(PASSWORD_BCRYPT, $result['hashType']); - - $hash = password_hash('mypass', PASSWORD_BCRYPT); - $User = TableRegistry::get('Users'); - $User->updateAll( - ['password' => $hash], - ['username' => 'mariano'] - ); - - $request = new ServerRequest('posts/index'); - $request->data = [ - 'username' => 'mariano', - 'password' => 'mypass' - ]; - - $result = $this->auth->authenticate($request, $this->response); - $expected = [ - 'id' => 1, - 'username' => 'mariano', - 'created' => new Time('2007-03-17 01:16:23'), - 'updated' => new Time('2007-03-17 01:18:31') - ]; - $this->assertEquals($expected, $result); - - $this->auth = new FormAuthenticate($this->Collection, [ - 'fields' => ['username' => 'username', 'password' => 'password'], - 'userModel' => 'Users' - ]); - $this->auth->config('passwordHasher', [ - 'className' => 'Default' - ]); - $this->assertEquals($expected, $this->auth->authenticate($request, $this->response)); - - $User->updateAll( - ['password' => '$2y$10$/G9GBQDZhWUM4w/WLes3b.XBZSK1hGohs5dMi0vh/oen0l0a7DUyK'], - ['username' => 'mariano'] - ); - $this->assertFalse($this->auth->authenticate($request, $this->response)); - } - - /** - * Tests that using default means password don't need to be rehashed - * - * @return void - */ - public function testAuthenticateNoRehash() - { - $request = new ServerRequest('posts/index'); - $request->data = [ - 'username' => 'mariano', - 'password' => 'password' - ]; - $result = $this->auth->authenticate($request, $this->response); - $this->assertNotEmpty($result); - $this->assertFalse($this->auth->needsPasswordRehash()); - } - - /** - * Tests that not using the Default password hasher means that the password - * needs to be rehashed - * - * @return void - */ - public function testAuthenticateRehash() - { - $this->auth = new FormAuthenticate($this->Collection, [ - 'userModel' => 'Users', - 'passwordHasher' => 'Weak' - ]); - $password = $this->auth->passwordHasher()->hash('password'); - TableRegistry::get('Users')->updateAll(['password' => $password], []); - - $request = new ServerRequest('posts/index'); - $request->data = [ - 'username' => 'mariano', - 'password' => 'password' - ]; - $result = $this->auth->authenticate($request, $this->response); - $this->assertNotEmpty($result); - $this->assertTrue($this->auth->needsPasswordRehash()); - } -} diff --git a/tests/TestCase/Auth/PasswordHasherFactoryTest.php b/tests/TestCase/Auth/PasswordHasherFactoryTest.php deleted file mode 100644 index 62e51619857..00000000000 --- a/tests/TestCase/Auth/PasswordHasherFactoryTest.php +++ /dev/null @@ -1,60 +0,0 @@ -assertInstanceof('Cake\Auth\DefaultPasswordHasher', $hasher); - - $hasher = PasswordHasherFactory::build([ - 'className' => 'Default', - 'hashOptions' => ['foo' => 'bar'] - ]); - $this->assertInstanceof('Cake\Auth\DefaultPasswordHasher', $hasher); - $this->assertEquals(['foo' => 'bar'], $hasher->config('hashOptions')); - - Plugin::load('TestPlugin'); - $hasher = PasswordHasherFactory::build('TestPlugin.Legacy'); - $this->assertInstanceof('TestPlugin\Auth\LegacyPasswordHasher', $hasher); - } - - /** - * test build() throws exception for non existent hasher - * - * @return void - */ - public function testBuildException() - { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Password hasher class "FooBar" was not found.'); - $hasher = PasswordHasherFactory::build('FooBar'); - } -} diff --git a/tests/TestCase/Auth/Storage/MemoryStorageTest.php b/tests/TestCase/Auth/Storage/MemoryStorageTest.php deleted file mode 100644 index b4b885d1faa..00000000000 --- a/tests/TestCase/Auth/Storage/MemoryStorageTest.php +++ /dev/null @@ -1,87 +0,0 @@ -storage = new MemoryStorage; - $this->user = ['username' => 'giantGummyLizard']; - } - - /** - * Test write. - * - * @return void - */ - public function testWrite() - { - $this->storage->write($this->user); - $this->assertSame($this->user, $this->storage->read()); - } - - /** - * Test read. - * - * @return void - */ - public function testRead() - { - $this->assertNull($this->storage->read()); - } - - /** - * Test delete. - * - * @return void - */ - public function testDelete() - { - $this->storage->write($this->user); - $this->storage->delete(); - - $this->assertNull($this->storage->read()); - } - - /** - * Test redirectUrl. - * - * @return void - */ - public function testRedirectUrl() - { - $this->assertNull($this->storage->redirectUrl()); - - $this->storage->redirectUrl('/posts/the-gummy-lizards'); - $this->assertSame('/posts/the-gummy-lizards', $this->storage->redirectUrl()); - - $this->assertNull($this->storage->redirectUrl(false)); - $this->assertNull($this->storage->redirectUrl()); - } -} diff --git a/tests/TestCase/Auth/Storage/SessionStorageTest.php b/tests/TestCase/Auth/Storage/SessionStorageTest.php deleted file mode 100644 index c7a359f8bba..00000000000 --- a/tests/TestCase/Auth/Storage/SessionStorageTest.php +++ /dev/null @@ -1,135 +0,0 @@ -session = $this->getMockBuilder(Session::class)->getMock(); - $this->request = new ServerRequest(['session' => $this->session]); - $this->response = new Response(); - $this->storage = new SessionStorage($this->request, $this->response, ['key' => 'Auth.AuthUser']); - $this->user = ['id' => 1]; - } - - /** - * Test write - * - * @return void - */ - public function testWrite() - { - $this->session->expects($this->once()) - ->method('write') - ->with('Auth.AuthUser', $this->user) - ->will($this->returnValue(true)); - - $this->storage->write($this->user); - } - - /** - * Test read - * - * @return void - */ - public function testRead() - { - $this->session->expects($this->once()) - ->method('read') - ->with('Auth.AuthUser') - ->will($this->returnValue($this->user)); - - $result = $this->storage->read(); - $this->assertSame($this->user, $result); - } - - /** - * Test read from local var - * - * @return void - */ - public function testGetFromLocalVar() - { - $this->storage->write($this->user); - - $this->session->expects($this->never()) - ->method('read'); - - $result = $this->storage->read(); - $this->assertSame($this->user, $result); - } - - /** - * Test delete - * - * @return void - */ - public function testDelete() - { - $this->session->expects($this->once()) - ->method('delete') - ->with('Auth.AuthUser'); - - $this->storage->delete(); - } - - /** - * Test redirectUrl() - * - * @return void - */ - public function redirectUrl() - { - $url = '/url'; - - $this->session->expects($this->once()) - ->method('write') - ->with('Auth.redirectUrl', $url); - - $this->storage->redirectUrl($url); - - $this->session->expects($this->once()) - ->method('read') - ->with('Auth.redirectUrl') - ->will($this->returnValue($url)); - - $result = $this->storage->redirectUrl(); - $this->assertEquals($url, $result); - - $this->session->expects($this->once()) - ->method('delete') - ->with('Auth.redirectUrl'); - - $this->storage->redirectUrl(false); - } -} diff --git a/tests/TestCase/Auth/WeakPasswordHasherTest.php b/tests/TestCase/Auth/WeakPasswordHasherTest.php deleted file mode 100644 index 01e3f8b2151..00000000000 --- a/tests/TestCase/Auth/WeakPasswordHasherTest.php +++ /dev/null @@ -1,69 +0,0 @@ -assertTrue($hasher->needsRehash(md5('foo'))); - $this->assertTrue($hasher->needsRehash('bar')); - $this->assertFalse($hasher->needsRehash('$2y$10$juOA0XVFpvZa0KTxRxEYVuX5kIS7U1fKDRcxyYhhUQECN1oHYnBMy')); - } - - /** - * Tests hash() and check() - * - * @return void - */ - public function testHashAndCheck() - { - $hasher = new WeakPasswordHasher(); - $hasher->config('hashType', 'md5'); - $password = $hasher->hash('foo'); - $this->assertTrue($hasher->check('foo', $password)); - $this->assertFalse($hasher->check('bar', $password)); - - $hasher->config('hashType', 'sha1'); - $this->assertFalse($hasher->check('foo', $password)); - } -} diff --git a/tests/TestCase/BasicsTest.php b/tests/TestCase/BasicsTest.php deleted file mode 100644 index 54667aac295..00000000000 --- a/tests/TestCase/BasicsTest.php +++ /dev/null @@ -1,595 +0,0 @@ - 1, 'two' => 2, 'three' => 3]; - $two = ['one' => 'one', 'two' => 'two']; - $result = array_diff_key($one, $two); - $expected = ['three' => 3]; - $this->assertEquals($expected, $result); - - $one = ['one' => ['value', 'value-two'], 'two' => 2, 'three' => 3]; - $two = ['two' => 'two']; - $result = array_diff_key($one, $two); - $expected = ['one' => ['value', 'value-two'], 'three' => 3]; - $this->assertEquals($expected, $result); - - $one = ['one' => null, 'two' => 2, 'three' => '', 'four' => 0]; - $two = ['two' => 'two']; - $result = array_diff_key($one, $two); - $expected = ['one' => null, 'three' => '', 'four' => 0]; - $this->assertEquals($expected, $result); - - $one = ['minYear' => null, 'maxYear' => null, 'separator' => '-', 'interval' => 1, 'monthNames' => true]; - $two = ['minYear' => null, 'maxYear' => null, 'separator' => '-', 'interval' => 1, 'monthNames' => true]; - $result = array_diff_key($one, $two); - $this->assertSame([], $result); - - $one = ['minYear' => null, 'maxYear' => null, 'separator' => '-', 'interval' => 1, 'monthNames' => true]; - $two = []; - $result = array_diff_key($one, $two); - $this->assertSame($one, $result); - } - - /** - * testHttpBase method - * - * @return void - */ - public function testEnv() - { - $this->skipIf(!function_exists('ini_get') || ini_get('safe_mode') === '1', 'Safe mode is on.'); - - $server = $_SERVER; - $env = $_ENV; - $_SERVER = $_ENV = []; - - $_SERVER['SCRIPT_NAME'] = '/a/test/test.php'; - $this->assertEquals(env('SCRIPT_NAME'), '/a/test/test.php'); - - $_SERVER = $_ENV = []; - - $_ENV['CGI_MODE'] = 'BINARY'; - $_ENV['SCRIPT_URL'] = '/a/test/test.php'; - $this->assertEquals(env('SCRIPT_NAME'), '/a/test/test.php'); - - $_SERVER = $_ENV = []; - - $this->assertFalse(env('HTTPS')); - - $_SERVER['HTTPS'] = 'on'; - $this->assertTrue(env('HTTPS')); - - $_SERVER['HTTPS'] = '1'; - $this->assertTrue(env('HTTPS')); - - $_SERVER['HTTPS'] = 'I am not empty'; - $this->assertTrue(env('HTTPS')); - - $_SERVER['HTTPS'] = 1; - $this->assertTrue(env('HTTPS')); - - $_SERVER['HTTPS'] = 'off'; - $this->assertFalse(env('HTTPS')); - - $_SERVER['HTTPS'] = false; - $this->assertFalse(env('HTTPS')); - - $_SERVER['HTTPS'] = ''; - $this->assertFalse(env('HTTPS')); - - $_SERVER = []; - - $_ENV['SCRIPT_URI'] = 'https://domain.test/a/test.php'; - $this->assertTrue(env('HTTPS')); - - $_ENV['SCRIPT_URI'] = 'http://domain.test/a/test.php'; - $this->assertFalse(env('HTTPS')); - - $_SERVER = $_ENV = []; - - $this->assertNull(env('TEST_ME')); - - $_ENV['TEST_ME'] = 'a'; - $this->assertEquals(env('TEST_ME'), 'a'); - - $_SERVER['TEST_ME'] = 'b'; - $this->assertEquals(env('TEST_ME'), 'b'); - - unset($_ENV['TEST_ME']); - $this->assertEquals(env('TEST_ME'), 'b'); - - $_SERVER = $server; - $_ENV = $env; - } - - /** - * Test h() - * - * @return void - */ - public function testH() - { - $string = ''; - $result = h($string); - $this->assertEquals('<foo>', $result); - - $in = ['this & that', '

    Which one

    ']; - $result = h($in); - $expected = ['this & that', '<p>Which one</p>']; - $this->assertEquals($expected, $result); - - $string = ' &  '; - $result = h($string); - $this->assertEquals('<foo> & &nbsp;', $result); - - $string = ' &  '; - $result = h($string, false); - $this->assertEquals('<foo> &  ', $result); - - $string = ' &  '; - $result = h($string, 'UTF-8'); - $this->assertEquals('<foo> & &nbsp;', $result); - - $string = "An invalid\x80string"; - $result = h($string); - $this->assertContains('string', $result); - - $arr = ['', ' ']; - $result = h($arr); - $expected = [ - '<foo>', - '&nbsp;' - ]; - $this->assertEquals($expected, $result); - - $arr = ['', ' ']; - $result = h($arr, false); - $expected = [ - '<foo>', - ' ' - ]; - $this->assertEquals($expected, $result); - - $arr = ['f' => '', 'n' => ' ']; - $result = h($arr, false); - $expected = [ - 'f' => '<foo>', - 'n' => ' ' - ]; - $this->assertEquals($expected, $result); - - $arr = ['invalid' => "\x99An invalid\x80string", 'good' => 'Good string']; - $result = h($arr); - $this->assertContains('An invalid', $result['invalid']); - $this->assertEquals('Good string', $result['good']); - - // Test that boolean values are not converted to strings - $result = h(false); - $this->assertFalse($result); - - $arr = ['foo' => false, 'bar' => true]; - $result = h($arr); - $this->assertFalse($result['foo']); - $this->assertTrue($result['bar']); - - $obj = new \stdClass(); - $result = h($obj); - $this->assertEquals('(object)stdClass', $result); - - $obj = new Response(['body' => 'Body content']); - $result = h($obj); - $this->assertEquals('Body content', $result); - } - - /** - * test debug() - * - * @return void - */ - public function testDebug() - { - ob_start(); - $this->assertEquals('this-is-a-test', debug('this-is-a-test', false)); - $result = ob_get_clean(); - $expectedText = <<assertEquals($expected, $result); - - ob_start(); - $value = '
    this-is-a-test
    '; - $this->assertSame($value, debug($value, true)); - $result = ob_get_clean(); - $expectedHtml = << -%s (line %d) -
    -'<div>this-is-a-test</div>'
    -
    - -EXPECTED; - $expected = sprintf($expectedHtml, str_replace(CAKE_CORE_INCLUDE_PATH, '', __FILE__), __LINE__ - 10); - $this->assertEquals($expected, $result); - - ob_start(); - debug('
    this-is-a-test
    ', true, true); - $result = ob_get_clean(); - $expected = << -%s (line %d) -
    -'<div>this-is-a-test</div>'
    -
    - -EXPECTED; - $expected = sprintf($expected, str_replace(CAKE_CORE_INCLUDE_PATH, '', __FILE__), __LINE__ - 10); - $this->assertEquals($expected, $result); - - ob_start(); - debug('
    this-is-a-test
    ', true, false); - $result = ob_get_clean(); - $expected = << - -
    -'<div>this-is-a-test</div>'
    -
    - -EXPECTED; - $expected = sprintf($expected, str_replace(CAKE_CORE_INCLUDE_PATH, '', __FILE__), __LINE__ - 10); - $this->assertEquals($expected, $result); - - ob_start(); - debug('
    this-is-a-test
    ', null); - $result = ob_get_clean(); - $expectedHtml = << -%s (line %d) -
    -'<div>this-is-a-test</div>'
    -
    - -EXPECTED; - $expectedText = <<this-is-a-test' -########################### - -EXPECTED; - if ((PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg')) { - $expected = sprintf($expectedText, str_replace(CAKE_CORE_INCLUDE_PATH, '', __FILE__), __LINE__ - 18); - } else { - $expected = sprintf($expectedHtml, str_replace(CAKE_CORE_INCLUDE_PATH, '', __FILE__), __LINE__ - 19); - } - $this->assertEquals($expected, $result); - - ob_start(); - debug('
    this-is-a-test
    ', null, false); - $result = ob_get_clean(); - $expectedHtml = << - -
    -'<div>this-is-a-test</div>'
    -
    - -EXPECTED; - $expectedText = <<this-is-a-test' -########################### - -EXPECTED; - if ((PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg')) { - $expected = sprintf($expectedText, str_replace(CAKE_CORE_INCLUDE_PATH, '', __FILE__), __LINE__ - 18); - } else { - $expected = sprintf($expectedHtml, str_replace(CAKE_CORE_INCLUDE_PATH, '', __FILE__), __LINE__ - 19); - } - $this->assertEquals($expected, $result); - - ob_start(); - debug('
    this-is-a-test
    ', false); - $result = ob_get_clean(); - $expected = <<this-is-a-test' -########################### - -EXPECTED; - $expected = sprintf($expected, str_replace(CAKE_CORE_INCLUDE_PATH, '', __FILE__), __LINE__ - 9); - $this->assertEquals($expected, $result); - - ob_start(); - debug('
    this-is-a-test
    ', false, true); - $result = ob_get_clean(); - $expected = <<this-is-a-test' -########################### - -EXPECTED; - $expected = sprintf($expected, str_replace(CAKE_CORE_INCLUDE_PATH, '', __FILE__), __LINE__ - 9); - $this->assertEquals($expected, $result); - - ob_start(); - debug('
    this-is-a-test
    ', false, false); - $result = ob_get_clean(); - $expected = <<this-is-a-test' -########################### - -EXPECTED; - $expected = sprintf($expected, str_replace(CAKE_CORE_INCLUDE_PATH, '', __FILE__), __LINE__ - 9); - $this->assertEquals($expected, $result); - - ob_start(); - $this->assertFalse(debug(false, false, false)); - $result = ob_get_clean(); - $expected = <<assertEquals($expected, $result); - } - - /** - * test pr() - * - * @return void - */ - public function testPr() - { - ob_start(); - $this->assertTrue(pr(true)); - $result = ob_get_clean(); - $expected = "\n1\n\n"; - $this->assertEquals($expected, $result); - - ob_start(); - $this->assertFalse(pr(false)); - $result = ob_get_clean(); - $expected = "\n\n\n"; - $this->assertEquals($expected, $result); - - ob_start(); - $this->assertNull(pr(null)); - $result = ob_get_clean(); - $expected = "\n\n\n"; - $this->assertEquals($expected, $result); - - ob_start(); - $this->assertSame(123, pr(123)); - $result = ob_get_clean(); - $expected = "\n123\n\n"; - $this->assertEquals($expected, $result); - - ob_start(); - pr('123'); - $result = ob_get_clean(); - $expected = "\n123\n\n"; - $this->assertEquals($expected, $result); - - ob_start(); - pr('this is a test'); - $result = ob_get_clean(); - $expected = "\nthis is a test\n\n"; - $this->assertEquals($expected, $result); - - ob_start(); - pr(['this' => 'is', 'a' => 'test', 123 => 456]); - $result = ob_get_clean(); - $expected = "\nArray\n(\n [this] => is\n [a] => test\n [123] => 456\n)\n\n"; - $this->assertEquals($expected, $result); - } - - /** - * test pj() - * - * @return void - */ - public function testPj() - { - ob_start(); - $this->assertTrue(pj(true)); - $result = ob_get_clean(); - $expected = "\ntrue\n\n"; - $this->assertEquals($expected, $result); - - ob_start(); - $this->assertFalse(pj(false)); - $result = ob_get_clean(); - $expected = "\nfalse\n\n"; - $this->assertEquals($expected, $result); - - ob_start(); - $this->assertNull(pj(null)); - $result = ob_get_clean(); - $expected = "\nnull\n\n"; - $this->assertEquals($expected, $result); - - ob_start(); - $this->assertSame(123, pj(123)); - $result = ob_get_clean(); - $expected = "\n123\n\n"; - $this->assertEquals($expected, $result); - - ob_start(); - pj('123'); - $result = ob_get_clean(); - $expected = "\n\"123\"\n\n"; - $this->assertEquals($expected, $result); - - ob_start(); - pj('this is a test'); - $result = ob_get_clean(); - $expected = "\n\"this is a test\"\n\n"; - $this->assertEquals($expected, $result); - - ob_start(); - $value = ['this' => 'is', 'a' => 'test', 123 => 456]; - $this->assertSame($value, pj($value)); - $result = ob_get_clean(); - $expected = "\n{\n \"this\": \"is\",\n \"a\": \"test\",\n \"123\": 456\n}\n\n"; - $this->assertEquals($expected, $result); - } - - /** - * Test splitting plugin names. - * - * @return void - */ - public function testPluginSplit() - { - $result = pluginSplit('Something.else'); - $this->assertEquals(['Something', 'else'], $result); - - $result = pluginSplit('Something.else.more.dots'); - $this->assertEquals(['Something', 'else.more.dots'], $result); - - $result = pluginSplit('Somethingelse'); - $this->assertEquals([null, 'Somethingelse'], $result); - - $result = pluginSplit('Something.else', true); - $this->assertEquals(['Something.', 'else'], $result); - - $result = pluginSplit('Something.else.more.dots', true); - $this->assertEquals(['Something.', 'else.more.dots'], $result); - - $result = pluginSplit('Post', false, 'Blog'); - $this->assertEquals(['Blog', 'Post'], $result); - - $result = pluginSplit('Blog.Post', false, 'Ultimate'); - $this->assertEquals(['Blog', 'Post'], $result); - } - - /** - * test namespaceSplit - * - * @return void - */ - public function testNamespaceSplit() - { - $result = namespaceSplit('Something'); - $this->assertEquals(['', 'Something'], $result); - - $result = namespaceSplit('\Something'); - $this->assertEquals(['', 'Something'], $result); - - $result = namespaceSplit('Cake\Something'); - $this->assertEquals(['Cake', 'Something'], $result); - - $result = namespaceSplit('Cake\Test\Something'); - $this->assertEquals(['Cake\Test', 'Something'], $result); - } - - /** - * Tests that the stackTrace() method is a shortcut for Debugger::trace() - * - * @return void - */ - public function testStackTrace() - { - ob_start(); - list($r, $expected) = [stackTrace(), \Cake\Error\Debugger::trace()]; - $result = ob_get_clean(); - $this->assertEquals($expected, $result); - - $opts = ['args' => true]; - ob_start(); - list($r, $expected) = [stackTrace($opts), \Cake\Error\Debugger::trace($opts)]; - $result = ob_get_clean(); - $this->assertEquals($expected, $result); - } - - /** - * Tests that the collection() method is a shortcut for new Collection - * - * @return void - */ - public function testCollection() - { - $items = [1, 2, 3]; - $collection = collection($items); - $this->assertInstanceOf('Cake\Collection\Collection', $collection); - $this->assertSame($items, $collection->toArray()); - } - - /** - * Test that works in tandem with testEventManagerReset2 to - * test the EventManager reset. - * - * The return value is passed to testEventManagerReset2 as - * an arguments. - * - * @return \Cake\Event\EventManager - */ - public function testEventManagerReset1() - { - $eventManager = EventManager::instance(); - $this->assertInstanceOf('Cake\Event\EventManager', $eventManager); - - return $eventManager; - } - - /** - * Test if the EventManager is reset between tests. - * - * @depends testEventManagerReset1 - * @return void - */ - public function testEventManagerReset2($prevEventManager) - { - $this->assertInstanceOf('Cake\Event\EventManager', $prevEventManager); - $this->assertNotSame($prevEventManager, EventManager::instance()); - } -} diff --git a/tests/TestCase/Cache/CacheTest.php b/tests/TestCase/Cache/CacheTest.php index ff2de1b6f0a..8c60614e995 100644 --- a/tests/TestCase/Cache/CacheTest.php +++ b/tests/TestCase/Cache/CacheTest.php @@ -1,4 +1,6 @@ 'File', - 'path' => TMP, - 'prefix' => 'test_' + 'path' => CACHE, + 'prefix' => 'test_', ]); } /** - * tests Cache::engine() fallback - * - * @return void + * tests Cache::pool() fallback */ - public function testCacheEngineFallback() + public function testCachePoolFallback(): void { - $filename = tempnam(TMP, 'tmp_'); + $filename = tempnam(CACHE, 'tmp_'); Cache::setConfig('tests', [ 'engine' => 'File', 'path' => $filename, 'prefix' => 'test_', - 'fallback' => 'tests_fallback' + 'fallback' => 'tests_fallback', ]); Cache::setConfig('tests_fallback', [ 'engine' => 'File', - 'path' => TMP, + 'path' => CACHE, 'prefix' => 'test_', ]); - $engine = Cache::engine('tests'); + $this->expectWarningMessageMatches('/^.* is not writable/', function () use (&$engine): void { + $engine = Cache::pool('tests'); + }); $path = $engine->getConfig('path'); - $this->assertSame(TMP, $path); + $this->assertSame(CACHE, $path); - Cache::drop('tests'); - Cache::drop('tests_fallback'); unlink($filename); } /** - * tests handling misconfiguration of fallback - * - * @return void + * tests you can disable Cache::pool() fallback */ - public function testCacheEngineFallbackToSelf() + public function testCachePoolFallbackDisabled(): void { - $filename = tempnam(TMP, 'tmp_'); + $engine = new class extends TestAppCacheEngine { + public function init(array $config = []): bool + { + return false; + } + }; - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('cannot fallback to itself'); + Cache::setConfig('tests', [ + 'engine' => $engine, + 'fallback' => false, + ]); + + $this->expectErrorMessageMatches('/^Cache engine `.*TestAppCacheEngine.*` is not properly configured/', function (): void { + Cache::pool('tests'); + }); + } + + /** + * tests handling misconfiguration of fallback + */ + public function testCacheEngineFallbackToSelf(): void + { + $filename = tempnam(CACHE, 'tmp_'); Cache::setConfig('tests', [ 'engine' => 'File', 'path' => $filename, 'prefix' => 'test_', - 'fallback' => 'tests' + 'fallback' => 'tests', ]); - Cache::engine('tests'); + $e = null; + try { + $this->expectWarningMessageMatches('/^.* is not writable/', function (): void { + Cache::pool('tests'); + }); + } catch (InvalidArgumentException $e) { + } Cache::drop('tests'); unlink($filename); + + $this->assertNotNull($e); + $this->assertStringEndsWith('cannot fallback to itself.', $e->getMessage()); + $this->assertInstanceOf('RunTimeException', $e->getPrevious()); } /** - * tests Cache::engine() fallback when using groups - * - * @return void + * tests Cache::pool() fallback when using groups */ - public function testCacheFallbackWithGroups() + public function testCacheFallbackWithGroups(): void { - $filename = tempnam(TMP, 'tmp_'); + $filename = tempnam(CACHE, 'tmp_'); Cache::setConfig('tests', [ 'engine' => 'File', @@ -139,36 +167,34 @@ public function testCacheFallbackWithGroups() ]); Cache::setConfig('tests_fallback', [ 'engine' => 'File', - 'path' => TMP, + 'path' => CACHE, 'prefix' => 'test_', 'groups' => ['group3', 'group1'], ]); - $result = Cache::groupConfigs('group1'); + $this->expectWarningMessageMatches('/^.* is not writable/', function () use (&$result): void { + $result = Cache::groupConfigs('group1'); + }); $this->assertSame(['group1' => ['tests', 'tests_fallback']], $result); $result = Cache::groupConfigs('group2'); $this->assertSame(['group2' => ['tests']], $result); - Cache::drop('tests'); - Cache::drop('tests_fallback'); unlink($filename); } /** * tests cache fallback - * - * @return void */ - public function testCacheFallbackIntegration() + public function testCacheFallbackIntegration(): void { - $filename = tempnam(TMP, 'tmp_'); + $filename = tempnam(CACHE, 'tmp_'); Cache::setConfig('tests', [ 'engine' => 'File', 'path' => $filename, 'fallback' => 'tests_fallback', - 'groups' => ['integration_group', 'integration_group_2'] + 'groups' => ['integration_group', 'integration_group_2'], ]); Cache::setConfig('tests_fallback', [ 'engine' => 'File', @@ -178,149 +204,161 @@ public function testCacheFallbackIntegration() ]); Cache::setConfig('tests_fallback_final', [ 'engine' => 'File', - 'path' => TMP . 'cake_test' . DS, + 'path' => CACHE . 'cake_test' . DS, 'groups' => ['integration_group_3'], ]); - $this->assertTrue(Cache::write('grouped', 'worked', 'tests')); + $this->expectWarningMessageMatches('/^.* is not writable/', function (): void { + $this->assertTrue(Cache::write('grouped', 'worked', 'tests')); + }); $this->assertTrue(Cache::write('grouped_2', 'worked', 'tests_fallback')); $this->assertTrue(Cache::write('grouped_3', 'worked', 'tests_fallback_final')); $this->assertTrue(Cache::clearGroup('integration_group', 'tests')); - $this->assertFalse(Cache::read('grouped', 'tests')); - $this->assertFalse(Cache::read('grouped_2', 'tests_fallback')); + $this->assertNull(Cache::read('grouped', 'tests')); + $this->assertNull(Cache::read('grouped_2', 'tests_fallback')); $this->assertSame('worked', Cache::read('grouped_3', 'tests_fallback_final')); - Cache::drop('tests'); - Cache::drop('tests_fallback'); - Cache::drop('tests_fallback_final'); unlink($filename); } /** * Check that no fatal errors are issued doing normal things when Cache.disable is true. - * - * @return void */ - public function testNonFatalErrorsWithCacheDisable() + public function testNonFatalErrorsWithCacheDisable(): void { Cache::disable(); $this->_configCache(); - $this->assertNull(Cache::write('no_save', 'Noooo!', 'tests')); - $this->assertFalse(Cache::read('no_save', 'tests')); - $this->assertNull(Cache::delete('no_save', 'tests')); + $this->assertTrue(Cache::write('no_save', 'Noooo!', 'tests')); + $this->assertNull(Cache::read('no_save', 'tests')); + $this->assertTrue(Cache::delete('no_save', 'tests')); } /** * Check that a null instance is returned from engine() when caching is disabled. - * - * @return void */ - public function testNullEngineWhenCacheDisable() + public function testNullEngineWhenCacheDisable(): void { $this->_configCache(); Cache::disable(); - $result = Cache::engine('tests'); + $result = Cache::pool('tests'); $this->assertInstanceOf(NullEngine::class, $result); } /** * Test configuring an invalid class fails - * - * @return void */ - public function testConfigInvalidClassType() + public function testConfigInvalidClassType(): void { - $this->expectException(Error::class); - $this->expectExceptionMessage('Cache engines must use Cake\Cache\CacheEngine'); - Cache::setConfig('tests', [ - 'className' => '\StdClass' + 'className' => '\stdClass', ]); - $result = Cache::engine('tests'); - $this->assertInstanceOf(NullEngine::class, $result); + + $this->expectException(AssertionError::class); + $this->expectExceptionMessage('Cache engines must extend `' . CacheEngine::class . '`'); + + Cache::pool('tests'); } /** * Test engine init failing triggers an error but falls back to NullEngine - * - * @return void */ - public function testConfigFailedInit() + public function testConfigFailedInit(): void { - $this->expectException(Error::class); - $this->expectExceptionMessage('is not properly configured'); - - $mock = $this->getMockForAbstractClass('Cake\Cache\CacheEngine', [], '', true, true, true, ['init']); - $mock->method('init')->will($this->returnValue(false)); + $engine = new class extends TestAppCacheEngine { + public function init(array $config = []): bool + { + return false; + } + }; Cache::setConfig('tests', [ - 'engine' => $mock + 'engine' => $engine, ]); - $result = Cache::engine('tests'); - $this->assertInstanceOf(NullEngine::class, $result); + + $regex = '/^Cache engine `.*TestAppCacheEngine.*/'; + $this->expectWarningMessageMatches($regex, function () use (&$engine): void { + $engine = Cache::pool('tests'); + }); + + $this->assertInstanceOf(NullEngine::class, $engine); } /** * test configuring CacheEngines in App/libs - * - * @return void */ - public function testConfigWithLibAndPluginEngines() + public function testConfigWithLibAndPluginEngines(): void { static::setAppNamespace(); - Plugin::load('TestPlugin'); + $this->loadPlugins(['TestPlugin']); - $config = ['engine' => 'TestAppCache', 'path' => TMP, 'prefix' => 'cake_test_']; + $config = ['engine' => 'TestAppCache', 'path' => CACHE, 'prefix' => 'cake_test_']; Cache::setConfig('libEngine', $config); - $engine = Cache::engine('libEngine'); - $this->assertInstanceOf('TestApp\Cache\Engine\TestAppCacheEngine', $engine); + $engine = Cache::pool('libEngine'); + $this->assertInstanceOf(TestAppCacheEngine::class, $engine); - $config = ['engine' => 'TestPlugin.TestPluginCache', 'path' => TMP, 'prefix' => 'cake_test_']; - $result = Cache::setConfig('pluginLibEngine', $config); - $engine = Cache::engine('pluginLibEngine'); - $this->assertInstanceOf('TestPlugin\Cache\Engine\TestPluginCacheEngine', $engine); + $config = ['engine' => 'TestPlugin.TestPluginCache', 'path' => CACHE, 'prefix' => 'cake_test_']; + Cache::setConfig('pluginLibEngine', $config); + $engine = Cache::pool('pluginLibEngine'); + $this->assertInstanceOf(TestPluginCacheEngine::class, $engine); Cache::drop('libEngine'); Cache::drop('pluginLibEngine'); - Plugin::unload(); + $this->clearPlugins(); } /** * Test write from a config that is undefined. - * - * @return void */ - public function testWriteNonExistingConfig() + public function testWriteNonExistentConfig(): void { - $this->expectException(\InvalidArgumentException::class); - $this->assertFalse(Cache::write('key', 'value', 'totally fake')); + $this->expectException(InvalidArgumentException::class); + + Cache::write('key', 'value', 'totally fake'); } /** * Test write from a config that is undefined. - * - * @return void */ - public function testIncrementNonExistingConfig() + public function testIncrementNonExistentConfig(): void { - $this->expectException(\InvalidArgumentException::class); - $this->assertFalse(Cache::increment('key', 1, 'totally fake')); + $this->expectException(InvalidArgumentException::class); + + Cache::increment('key', 1, 'totally fake'); + } + + /** + * Test increment with value < 0 + */ + public function testIncrementSubZero(): void + { + $this->expectException(InvalidArgumentException::class); + + Cache::increment('key', -1); } /** * Test write from a config that is undefined. - * - * @return void */ - public function testDecrementNonExistingConfig() + public function testDecrementNonExistentConfig(): void + { + $this->expectException(InvalidArgumentException::class); + + Cache::decrement('key', 1, 'totally fake'); + } + + /** + * Test decrement value < 0 + */ + public function testDecrementSubZero(): void { - $this->expectException(\InvalidArgumentException::class); - $this->assertFalse(Cache::decrement('key', 1, 'totally fake')); + $this->expectException(InvalidArgumentException::class); + + Cache::decrement('key', -1); } /** @@ -328,18 +366,18 @@ public function testDecrementNonExistingConfig() * * @return array */ - public static function configProvider() + public static function configProvider(): array { return [ 'Array of data using engine key.' => [[ 'engine' => 'File', - 'path' => TMP . 'tests', - 'prefix' => 'cake_test_' + 'path' => CACHE . 'tests', + 'prefix' => 'cake_test_', ]], 'Array of data using classname key.' => [[ 'className' => 'File', - 'path' => TMP . 'tests', - 'prefix' => 'cake_test_' + 'path' => CACHE . 'tests', + 'prefix' => 'cake_test_', ]], 'Direct instance' => [new FileEngine()], ]; @@ -348,107 +386,116 @@ public static function configProvider() /** * testConfig method * - * @dataProvider configProvider - * @return void + * @param \Cake\Cache\CacheEngine|array $config */ - public function testConfigVariants($config) + #[DataProvider('configProvider')] + public function testConfigVariants($config): void { $this->assertNotContains('test', Cache::configured(), 'test config should not exist.'); Cache::setConfig('tests', $config); - $engine = Cache::engine('tests'); - $this->assertInstanceOf('Cake\Cache\Engine\FileEngine', $engine); + $engine = Cache::pool('tests'); + $this->assertInstanceOf(FileEngine::class, $engine); $this->assertContains('tests', Cache::configured()); } /** * testConfigInvalidEngine method - * - * @return void */ - public function testConfigInvalidEngine() + public function testConfigInvalidEngine(): void { - $this->expectException(\BadMethodCallException::class); $config = ['engine' => 'Imaginary']; Cache::setConfig('test', $config); - Cache::engine('test'); + + $this->expectException(BadMethodCallException::class); + + Cache::pool('test'); } /** * test that trying to configure classes that don't extend CacheEngine fail. - * - * @return void */ - public function testConfigInvalidObject() + public function testConfigInvalidObject(): void { - $this->expectException(\BadMethodCallException::class); - $this->getMockBuilder(\StdClass::class) - ->setMockClassName('RubbishEngine') - ->getMock(); + $object = new stdClass(); + $this->expectException(BadMethodCallException::class); + Cache::setConfig('test', [ - 'engine' => '\RubbishEngine' + 'engine' => $object, ]); - Cache::engine('tests'); } /** * Ensure you cannot reconfigure a cache adapter. - * - * @return void */ - public function testConfigErrorOnReconfigure() + public function testConfigErrorOnReconfigure(): void { - $this->expectException(\BadMethodCallException::class); - Cache::setConfig('tests', ['engine' => 'File', 'path' => TMP]); + Cache::setConfig('tests', ['engine' => 'File', 'path' => CACHE]); + + $this->expectException(BadMethodCallException::class); + Cache::setConfig('tests', ['engine' => 'Apc']); } /** * Test reading configuration. - * - * @return void */ - public function testConfigRead() + public function testConfigRead(): void { $config = [ 'engine' => 'File', - 'path' => TMP, - 'prefix' => 'cake_' + 'path' => CACHE, + 'prefix' => 'cake_', ]; Cache::setConfig('tests', $config); $expected = $config; $expected['className'] = $config['engine']; unset($expected['engine']); - $this->assertEquals($expected, Cache::config('tests')); + $this->assertEquals($expected, Cache::getConfig('tests')); + } + + /** + * Test reading configuration with numeric string. + */ + public function testConfigReadNumeric(): void + { + $config = [ + 'engine' => 'File', + 'path' => CACHE, + 'prefix' => 'cake_', + ]; + Cache::setConfig('123', $config); + $expected = $config; + $expected['className'] = $config['engine']; + unset($expected['engine']); + $this->assertEquals($expected, Cache::getConfig('123')); } /** * test config() with dotted name - * - * @return void */ - public function testConfigDottedAlias() + public function testConfigDottedAlias(): void { Cache::setConfig('cache.dotted', [ 'className' => 'File', - 'path' => TMP, - 'prefix' => 'cache_value_' + 'path' => CACHE, + 'prefix' => 'cache_value_', ]); - $engine = Cache::engine('cache.dotted'); + $engine = Cache::pool('cache.dotted'); $this->assertContains('cache.dotted', Cache::configured()); $this->assertNotContains('dotted', Cache::configured()); - $this->assertInstanceOf('Cake\Cache\Engine\FileEngine', $engine); + $this->assertInstanceOf(FileEngine::class, $engine); Cache::drop('cache.dotted'); } /** * testGroupConfigs method */ - public function testGroupConfigs() + public function testGroupConfigs(): void { Cache::drop('test'); - Cache::config('latest', [ + Cache::setConfig('latest', [ 'duration' => 300, 'engine' => 'File', 'groups' => ['posts', 'comments'], @@ -465,7 +512,7 @@ public function testGroupConfigs() $result = Cache::groupConfigs('posts'); $this->assertEquals(['posts' => ['latest']], $result); - Cache::config('page', [ + Cache::setConfig('page', [ 'duration' => 86400, 'engine' => 'File', 'groups' => ['posts', 'archive'], @@ -501,7 +548,7 @@ public function testGroupConfigs() /** * testGroupConfigsWithCacheInstance method */ - public function testGroupConfigsWithCacheInstance() + public function testGroupConfigsWithCacheInstance(): void { Cache::drop('test'); $cache = new FileEngine(); @@ -519,33 +566,38 @@ public function testGroupConfigsWithCacheInstance() /** * testGroupConfigsThrowsException method */ - public function testGroupConfigsThrowsException() + public function testGroupConfigsThrowsException(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); + Cache::groupConfigs('bogus'); + } + + /** + * testGroupConfigsThrowsOldException method + */ + public function testGroupConfigsThrowsOldException(): void + { + $this->expectException(InvalidArgumentException::class); Cache::groupConfigs('bogus'); } /** * test that configured returns an array of the currently configured cache * config - * - * @return void */ - public function testConfigured() + public function testConfigured(): void { Cache::drop('default'); $result = Cache::configured(); - $this->assertContains('_cake_core_', $result); + $this->assertContains('_cake_translations_', $result); $this->assertNotContains('default', $result, 'Unconnected engines should not display.'); } /** * test that drop removes cache configs, and that further attempts to use that config * do not work. - * - * @return void */ - public function testDrop() + public function testDrop(): void { static::setAppNamespace(); @@ -553,31 +605,29 @@ public function testDrop() $this->assertFalse($result, 'Drop should not succeed when config is missing.'); Cache::setConfig('unconfigTest', [ - 'engine' => 'TestAppCache' + 'engine' => 'TestAppCache', ]); $this->assertInstanceOf( - 'TestApp\Cache\Engine\TestAppCacheEngine', - Cache::engine('unconfigTest') + TestAppCacheEngine::class, + Cache::pool('unconfigTest'), ); $this->assertTrue(Cache::drop('unconfigTest')); } /** * testWriteEmptyValues method - * - * @return void */ - public function testWriteEmptyValues() + public function testWriteEmptyValues(): void { $this->_configCache(); Cache::write('App.falseTest', false, 'tests'); - $this->assertSame(Cache::read('App.falseTest', 'tests'), false); + $this->assertFalse(Cache::read('App.falseTest', 'tests')); Cache::write('App.trueTest', true, 'tests'); - $this->assertSame(Cache::read('App.trueTest', 'tests'), true); + $this->assertTrue(Cache::read('App.trueTest', 'tests')); Cache::write('App.nullTest', null, 'tests'); - $this->assertSame(Cache::read('App.nullTest', 'tests'), null); + $this->assertNull(Cache::read('App.nullTest', 'tests')); Cache::write('App.zeroTest', 0, 'tests'); $this->assertSame(Cache::read('App.zeroTest', 'tests'), 0); @@ -588,23 +638,21 @@ public function testWriteEmptyValues() /** * testWriteEmptyValues method - * - * @return void */ - public function testWriteEmptyKey() + public function testWriteEmptyKey(): void { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('An empty value is not valid as a cache key'); $this->_configCache(); - Cache::write(null, 'not null', 'tests'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A cache key must be a non-empty string'); + + Cache::write('', 'not null', 'tests'); } /** * testReadWriteMany method - * - * @return void */ - public function testReadWriteMany() + public function testReadWriteMany(): void { $this->_configCache(); $data = [ @@ -612,25 +660,23 @@ public function testReadWriteMany() 'App.trueTest' => true, 'App.nullTest' => null, 'App.zeroTest' => 0, - 'App.zeroTest2' => '0' + 'App.zeroTest2' => '0', ]; Cache::writeMany($data, 'tests'); $read = Cache::readMany(array_keys($data), 'tests'); - $this->assertSame($read['App.falseTest'], false); - $this->assertSame($read['App.trueTest'], true); - $this->assertSame($read['App.nullTest'], null); + $this->assertFalse($read['App.falseTest']); + $this->assertTrue($read['App.trueTest']); + $this->assertNull($read['App.nullTest']); $this->assertSame($read['App.zeroTest'], 0); $this->assertSame($read['App.zeroTest2'], '0'); } /** * testDeleteMany method - * - * @return void */ - public function testDeleteMany() + public function testDeleteMany(): void { $this->_configCache(); $data = [ @@ -638,35 +684,54 @@ public function testDeleteMany() 'App.trueTest' => true, 'App.nullTest' => null, 'App.zeroTest' => 0, - 'App.zeroTest2' => '0' + 'App.zeroTest2' => '0', ]; Cache::writeMany(array_merge($data, ['App.keepTest' => 'keepMe']), 'tests'); Cache::deleteMany(array_keys($data), 'tests'); $read = Cache::readMany(array_merge(array_keys($data), ['App.keepTest']), 'tests'); - $this->assertSame($read['App.falseTest'], false); - $this->assertSame($read['App.trueTest'], false); - $this->assertSame($read['App.nullTest'], false); - $this->assertSame($read['App.zeroTest'], false); - $this->assertSame($read['App.zeroTest2'], false); + $this->assertNull($read['App.falseTest']); + $this->assertNull($read['App.trueTest']); + $this->assertNull($read['App.nullTest']); + $this->assertNull($read['App.zeroTest']); + $this->assertNull($read['App.zeroTest2']); $this->assertSame($read['App.keepTest'], 'keepMe'); } /** - * Test that failed writes cause errors to be triggered. - * - * @return void + * testDeleteMany partial failure */ - public function testWriteTriggerError() + public function testDeleteManyPartialFailure(): void + { + $this->_configCache(); + $data = [ + 'App.exists' => 'yes', + 'App.exists2' => 'yes', + ]; + Cache::writeMany($data, 'tests'); + + $result = Cache::deleteMany(['App.exists', 'App.noExists', 'App.exists2'], 'tests'); + $this->assertFalse($result); + + $this->assertNull(Cache::read('App.exists', 'tests')); + $this->assertNull(Cache::read('App.exists2', 'tests')); + } + + /** + * Test that failed writes causes an Exception to be triggered. + */ + public function testWriteTriggerCacheWriteException(): void { - $this->expectException(\PHPUnit\Framework\Error\Error::class); static::setAppNamespace(); Cache::setConfig('test_trigger', [ 'engine' => 'TestAppCache', - 'prefix' => '' + 'prefix' => '', ]); + $this->expectException(CacheWriteException::class); + $this->expectExceptionMessage('test_trigger cache was unable to write \'fail\' to TestApp\Cache\Engine\TestAppCacheEngine cache'); + Cache::write('fail', 'value', 'test_trigger'); } @@ -675,15 +740,13 @@ public function testWriteTriggerError() * * Check that the "Cache.disable" configuration and a change to it * (even after a cache config has been setup) is taken into account. - * - * @return void */ - public function testCacheDisable() + public function testCacheDisable(): void { Cache::enable(); Cache::setConfig('test_cache_disable_1', [ 'engine' => 'File', - 'path' => TMP . 'tests' + 'path' => CACHE . 'tests', ]); $this->assertTrue(Cache::write('key_1', 'hello', 'test_cache_disable_1')); @@ -691,23 +754,23 @@ public function testCacheDisable() Cache::disable(); - $this->assertNull(Cache::write('key_2', 'hello', 'test_cache_disable_1')); - $this->assertFalse(Cache::read('key_2', 'test_cache_disable_1')); + $this->assertTrue(Cache::write('key_2', 'hello', 'test_cache_disable_1')); + $this->assertNull(Cache::read('key_2', 'test_cache_disable_1')); Cache::enable(); $this->assertTrue(Cache::write('key_3', 'hello', 'test_cache_disable_1')); $this->assertSame('hello', Cache::read('key_3', 'test_cache_disable_1')); - Cache::clear(false, 'test_cache_disable_1'); + Cache::clear('test_cache_disable_1'); Cache::disable(); - Cache::config('test_cache_disable_2', [ + Cache::setConfig('test_cache_disable_2', [ 'engine' => 'File', - 'path' => TMP . 'tests' + 'path' => CACHE . 'tests', ]); - $this->assertNull(Cache::write('key_4', 'hello', 'test_cache_disable_2')); - $this->assertFalse(Cache::read('key_4', 'test_cache_disable_2')); + $this->assertTrue(Cache::write('key_4', 'hello', 'test_cache_disable_2')); + $this->assertNull(Cache::read('key_4', 'test_cache_disable_2')); Cache::enable(); @@ -715,27 +778,25 @@ public function testCacheDisable() $this->assertSame(Cache::read('key_5', 'test_cache_disable_2'), 'hello'); Cache::disable(); - $this->assertNull(Cache::write('key_6', 'hello', 'test_cache_disable_2')); - $this->assertFalse(Cache::read('key_6', 'test_cache_disable_2')); + $this->assertTrue(Cache::write('key_6', 'hello', 'test_cache_disable_2')); + $this->assertNull(Cache::read('key_6', 'test_cache_disable_2')); Cache::enable(); - Cache::clear(false, 'test_cache_disable_2'); + Cache::clear('test_cache_disable_2'); } /** * test clearAll() method - * - * @return void */ - public function testClearAll() + public function testClearAll(): void { - Cache::config('configTest', [ + Cache::setConfig('configTest', [ 'engine' => 'File', - 'path' => TMP . 'tests' + 'path' => CACHE . 'tests', ]); - Cache::config('anotherConfigTest', [ + Cache::setConfig('anotherConfigTest', [ 'engine' => 'File', - 'path' => TMP . 'tests' + 'path' => CACHE . 'tests', ]); Cache::write('key_1', 'hello', 'configTest'); @@ -747,31 +808,27 @@ public function testClearAll() $result = Cache::clearAll(); $this->assertTrue($result['configTest']); $this->assertTrue($result['anotherConfigTest']); - $this->assertFalse(Cache::read('key_1', 'configTest')); - $this->assertFalse(Cache::read('key_2', 'anotherConfigTest')); + $this->assertNull(Cache::read('key_1', 'configTest')); + $this->assertNull(Cache::read('key_2', 'anotherConfigTest')); Cache::drop('configTest'); Cache::drop('anotherConfigTest'); } /** * Test toggling enabled state of cache. - * - * @return void */ - public function testEnableDisableEnabled() + public function testEnableDisableEnabled(): void { - $this->assertNull(Cache::enable()); + Cache::enable(); $this->assertTrue(Cache::enabled(), 'Should be on'); - $this->assertNull(Cache::disable()); + Cache::disable(); $this->assertFalse(Cache::enabled(), 'Should be off'); } /** * test remember method. - * - * @return void */ - public function testRemember() + public function testRemember(): void { $this->_configCache(); $counter = 0; @@ -781,19 +838,16 @@ public function testRemember() $expected = 'This is some data 0'; $result = Cache::remember('test_key', $cacher, 'tests'); - $this->assertEquals($expected, $result); + $this->assertSame($expected, $result); - $counter = 1; $result = Cache::remember('test_key', $cacher, 'tests'); - $this->assertEquals($expected, $result); + $this->assertSame($expected, $result); } /** * Test add method. - * - * @return void */ - public function testAdd() + public function testAdd(): void { $this->_configCache(); Cache::delete('test_add_key', 'tests'); @@ -803,55 +857,107 @@ public function testAdd() $expected = 'test data'; $result = Cache::read('test_add_key', 'tests'); - $this->assertEquals($expected, $result); + $this->assertSame($expected, $result); $result = Cache::add('test_add_key', 'test data 2', 'tests'); $this->assertFalse($result); } /** - * test registry method - * - * @return void + * Test getting the registry */ - public function testRegistry() + public function testGetRegistry(): void { - $this->assertInstanceOf(CacheRegistry::class, Cache::registry()); + $this->assertInstanceOf(CacheRegistry::class, Cache::getRegistry()); } /** - * test registry method setting - * - * @return void + * Test setting the registry */ - public function testRegistrySet() + public function testSetAndGetRegistry(): void { $registry = new CacheRegistry(); - Cache::registry($registry); + Cache::setRegistry($registry); - $this->assertSame($registry, Cache::registry()); + $this->assertSame($registry, Cache::getRegistry()); } /** - * Test getting the registry - * - * @return void + * Test getting instances with pool */ - public function testGetRegistry() + public function testPool(): void { - $this->assertInstanceOf(CacheRegistry::class, Cache::getRegistry()); + $this->_configCache(); + + $pool = Cache::pool('tests'); + $this->assertInstanceOf(SimpleCacheInterface::class, $pool); } /** - * Test setting the registry + * Test getting instances with pool + */ + public function testPoolCacheDisabled(): void + { + Cache::disable(); + $pool = Cache::pool('tests'); + $this->assertInstanceOf(SimpleCacheInterface::class, $pool); + } + + /** + * A pool asked for again while it is still being constructed must not recurse. * - * @return void + * An engine that cannot reach its backend may log that failure, and the logger may in turn + * ask for a cache pool - a database schema metadata cache, for instance. Nothing is + * registered for the pool at that point, so the two would otherwise call each other until + * the process runs out of memory. */ - public function testSetAndGetRegistry() + public function testPoolReentrantBuildDoesNotRecurse(): void { - $registry = new CacheRegistry(); - Cache::setRegistry($registry); + ReentrantCacheEngine::reset(); + Cache::setConfig('reentrant', [ + 'className' => ReentrantCacheEngine::class, + 'reentrantTarget' => 'reentrant', + ]); - $this->assertSame($registry, Cache::getRegistry()); + $pool = Cache::pool('reentrant'); + + $this->assertSame(1, ReentrantCacheEngine::$initCount, 'The engine should be built once.'); + $this->assertInstanceOf(ReentrantCacheEngine::class, $pool); + $this->assertInstanceOf( + NullEngine::class, + ReentrantCacheEngine::$reentrantPool, + 'The reentrant call should degrade to a null engine.', + ); + + ReentrantCacheEngine::reset(); + Cache::drop('reentrant'); + } + + /** + * The in-flight marker must be cleared once construction finishes, so a later call to the + * same pool still builds normally rather than being treated as reentrant forever. + */ + public function testPoolIsBuildableAgainAfterReentrantBuild(): void + { + ReentrantCacheEngine::reset(); + Cache::setConfig('reentrant', [ + 'className' => ReentrantCacheEngine::class, + 'reentrantTarget' => 'reentrant', + ]); + + Cache::pool('reentrant'); + Cache::drop('reentrant'); + + Cache::setConfig('reentrant', [ + 'className' => ReentrantCacheEngine::class, + 'reentrantTarget' => 'reentrant', + ]); + $pool = Cache::pool('reentrant'); + + $this->assertInstanceOf(ReentrantCacheEngine::class, $pool); + $this->assertSame(2, ReentrantCacheEngine::$initCount); + + ReentrantCacheEngine::reset(); + Cache::drop('reentrant'); } } diff --git a/tests/TestCase/Cache/Engine/ApcEngineTest.php b/tests/TestCase/Cache/Engine/ApcEngineTest.php deleted file mode 100644 index 9e80a85b90e..00000000000 --- a/tests/TestCase/Cache/Engine/ApcEngineTest.php +++ /dev/null @@ -1,303 +0,0 @@ -skipIf(!function_exists('apcu_store'), 'APCu is not installed or configured properly.'); - - if ((PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg')) { - $this->skipIf(!ini_get('apc.enable_cli'), 'APC is not enabled for the CLI.'); - } - - Cache::enable(); - $this->_configCache(); - } - - /** - * tearDown method - * - * @return void - */ - public function tearDown() - { - parent::tearDown(); - Cache::drop('apc'); - Cache::drop('apc_groups'); - } - - /** - * Helper method for testing. - * - * @param array $config - * @return void - */ - protected function _configCache($config = []) - { - $defaults = [ - 'className' => 'Apc', - 'prefix' => 'cake_', - ]; - Cache::drop('apc'); - Cache::config('apc', array_merge($defaults, $config)); - } - - /** - * testReadAndWriteCache method - * - * @return void - */ - public function testReadAndWriteCache() - { - $this->_configCache(['duration' => 1]); - - $result = Cache::read('test', 'apc'); - $expecting = ''; - $this->assertEquals($expecting, $result); - - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('test', $data, 'apc'); - $this->assertTrue($result); - - $result = Cache::read('test', 'apc'); - $expecting = $data; - $this->assertEquals($expecting, $result); - - Cache::delete('test', 'apc'); - } - - /** - * Writing cache entries with duration = 0 (forever) should work. - * - * @return void - */ - public function testReadWriteDurationZero() - { - Cache::drop('apc'); - Cache::config('apc', ['engine' => 'Apc', 'duration' => 0, 'prefix' => 'cake_']); - Cache::write('zero', 'Should save', 'apc'); - sleep(1); - - $result = Cache::read('zero', 'apc'); - $this->assertEquals('Should save', $result); - } - - /** - * testExpiry method - * - * @return void - */ - public function testExpiry() - { - $this->_configCache(['duration' => 1]); - - $result = Cache::read('test', 'apc'); - $this->assertFalse($result); - - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('other_test', $data, 'apc'); - $this->assertTrue($result); - - sleep(2); - $result = Cache::read('other_test', 'apc'); - $this->assertFalse($result); - } - - /** - * testDeleteCache method - * - * @return void - */ - public function testDeleteCache() - { - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('delete_test', $data, 'apc'); - $this->assertTrue($result); - - $result = Cache::delete('delete_test', 'apc'); - $this->assertTrue($result); - } - - /** - * testDecrement method - * - * @return void - */ - public function testDecrement() - { - $result = Cache::write('test_decrement', 5, 'apc'); - $this->assertTrue($result); - - $result = Cache::decrement('test_decrement', 1, 'apc'); - $this->assertEquals(4, $result); - - $result = Cache::read('test_decrement', 'apc'); - $this->assertEquals(4, $result); - - $result = Cache::decrement('test_decrement', 2, 'apc'); - $this->assertEquals(2, $result); - - $result = Cache::read('test_decrement', 'apc'); - $this->assertEquals(2, $result); - } - - /** - * testIncrement method - * - * @return void - */ - public function testIncrement() - { - $result = Cache::write('test_increment', 5, 'apc'); - $this->assertTrue($result); - - $result = Cache::increment('test_increment', 1, 'apc'); - $this->assertEquals(6, $result); - - $result = Cache::read('test_increment', 'apc'); - $this->assertEquals(6, $result); - - $result = Cache::increment('test_increment', 2, 'apc'); - $this->assertEquals(8, $result); - - $result = Cache::read('test_increment', 'apc'); - $this->assertEquals(8, $result); - } - - /** - * test the clearing of cache keys - * - * @return void - */ - public function testClear() - { - apcu_store('not_cake', 'survive'); - Cache::write('some_value', 'value', 'apc'); - - $result = Cache::clear(false, 'apc'); - $this->assertTrue($result); - $this->assertFalse(Cache::read('some_value', 'apc')); - $this->assertEquals('survive', apcu_fetch('not_cake')); - apcu_delete('not_cake'); - } - - /** - * Tests that configuring groups for stored keys return the correct values when read/written - * Shows that altering the group value is equivalent to deleting all keys under the same - * group - * - * @return void - */ - public function testGroupsReadWrite() - { - Cache::config('apc_groups', [ - 'engine' => 'Apc', - 'duration' => 0, - 'groups' => ['group_a', 'group_b'], - 'prefix' => 'test_' - ]); - $this->assertTrue(Cache::write('test_groups', 'value', 'apc_groups')); - $this->assertEquals('value', Cache::read('test_groups', 'apc_groups')); - - apcu_inc('test_group_a'); - $this->assertFalse(Cache::read('test_groups', 'apc_groups')); - $this->assertTrue(Cache::write('test_groups', 'value2', 'apc_groups')); - $this->assertEquals('value2', Cache::read('test_groups', 'apc_groups')); - - apcu_inc('test_group_b'); - $this->assertFalse(Cache::read('test_groups', 'apc_groups')); - $this->assertTrue(Cache::write('test_groups', 'value3', 'apc_groups')); - $this->assertEquals('value3', Cache::read('test_groups', 'apc_groups')); - } - - /** - * Tests that deleting from a groups-enabled config is possible - * - * @return void - */ - public function testGroupDelete() - { - Cache::config('apc_groups', [ - 'engine' => 'Apc', - 'duration' => 0, - 'groups' => ['group_a', 'group_b'], - 'prefix' => 'test_' - ]); - $this->assertTrue(Cache::write('test_groups', 'value', 'apc_groups')); - $this->assertEquals('value', Cache::read('test_groups', 'apc_groups')); - $this->assertTrue(Cache::delete('test_groups', 'apc_groups')); - - $this->assertFalse(Cache::read('test_groups', 'apc_groups')); - } - - /** - * Test clearing a cache group - * - * @return void - */ - public function testGroupClear() - { - Cache::config('apc_groups', [ - 'engine' => 'Apc', - 'duration' => 0, - 'groups' => ['group_a', 'group_b'], - 'prefix' => 'test_' - ]); - - $this->assertTrue(Cache::write('test_groups', 'value', 'apc_groups')); - $this->assertTrue(Cache::clearGroup('group_a', 'apc_groups')); - $this->assertFalse(Cache::read('test_groups', 'apc_groups')); - - $this->assertTrue(Cache::write('test_groups', 'value2', 'apc_groups')); - $this->assertTrue(Cache::clearGroup('group_b', 'apc_groups')); - $this->assertFalse(Cache::read('test_groups', 'apc_groups')); - } - - /** - * Test add - * - * @return void - */ - public function testAdd() - { - Cache::delete('test_add_key', 'apc'); - - $result = Cache::add('test_add_key', 'test data', 'apc'); - $this->assertTrue($result); - - $expected = 'test data'; - $result = Cache::read('test_add_key', 'apc'); - $this->assertEquals($expected, $result); - - $result = Cache::add('test_add_key', 'test data 2', 'apc'); - $this->assertFalse($result); - } -} diff --git a/tests/TestCase/Cache/Engine/ApcuEngineTest.php b/tests/TestCase/Cache/Engine/ApcuEngineTest.php new file mode 100644 index 00000000000..1a3083c0e58 --- /dev/null +++ b/tests/TestCase/Cache/Engine/ApcuEngineTest.php @@ -0,0 +1,349 @@ +skipIf(!function_exists('apcu_store'), 'APCu is not installed or configured properly.'); + + if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { + $this->skipIf(!ini_get('apc.enable_cli'), 'APCu is not enabled for the CLI.'); + } + + Cache::enable(); + $this->_configCache(); + Cache::clearAll(); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + Cache::drop('apcu'); + Cache::drop('apcu_groups'); + } + + /** + * Helper method for testing. + * + * @param array $config + */ + protected function _configCache(array $config = []): void + { + $defaults = [ + 'className' => 'Apcu', + 'prefix' => 'cake_', + 'warnOnWriteFailures' => true, + ]; + $this->engine = 'apcu'; + Cache::drop('apcu'); + Cache::setConfig('apcu', array_merge($defaults, $config)); + } + + /** + * testReadAndWriteCache method + */ + public function testReadAndWriteCache(): void + { + $this->_configCache(['duration' => 1]); + + $result = Cache::read('test', 'apcu'); + $this->assertNull($result); + + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('test', $data, 'apcu'); + $this->assertTrue($result); + + $result = Cache::read('test', 'apcu'); + $expecting = $data; + $this->assertSame($expecting, $result); + + Cache::delete('test', 'apcu'); + } + + /** + * Writing cache entries with duration = 0 (forever) should work. + */ + public function testReadWriteDurationZero(): void + { + Cache::drop('apcu'); + Cache::setConfig('apcu', ['engine' => 'Apcu', 'duration' => 0, 'prefix' => 'cake_']); + Cache::write('zero', 'Should save', 'apcu'); + sleep(1); + + $result = Cache::read('zero', 'apcu'); + $this->assertSame('Should save', $result); + } + + /** + * Test get with default value + */ + public function testGetDefaultValue(): void + { + $apcu = Cache::pool('apcu'); + $this->assertFalse($apcu->get('nope', false)); + $this->assertNull($apcu->get('nope', null)); + $this->assertTrue($apcu->get('nope', true)); + $this->assertSame(0, $apcu->get('nope', 0)); + + $apcu->set('yep', 0); + $this->assertSame(0, $apcu->get('yep', false)); + } + + /** + * testExpiry method + */ + public function testExpiry(): void + { + $this->_configCache(['duration' => 1]); + + $result = Cache::read('test', 'apcu'); + $this->assertNull($result); + + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('other_test', $data, 'apcu'); + $this->assertTrue($result); + + sleep(2); + $result = Cache::read('other_test', 'apcu'); + $this->assertNull($result); + $this->assertSame(0, Cache::pool('apcu')->get('other_test', 0), 'expired values get default.'); + } + + /** + * test set ttl parameter + */ + public function testSetWithTtl(): void + { + $this->_configCache(['duration' => 99]); + $engine = Cache::pool('apcu'); + $this->assertNull($engine->get('test')); + + $data = 'this is a test of the emergency broadcasting system'; + $this->assertTrue($engine->set('default_ttl', $data)); + $this->assertTrue($engine->set('int_ttl', $data, 1)); + $this->assertTrue($engine->set('interval_ttl', $data, new DateInterval('PT1S'))); + + sleep(2); + $this->assertNull($engine->get('int_ttl')); + $this->assertNull($engine->get('interval_ttl')); + $this->assertSame($data, $engine->get('default_ttl')); + } + + /** + * testDeleteCache method + */ + public function testDeleteCache(): void + { + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('delete_test', $data, 'apcu'); + $this->assertTrue($result); + + $result = Cache::delete('delete_test', 'apcu'); + $this->assertTrue($result); + } + + /** + * testDecrement method + */ + public function testDecrement(): void + { + $result = Cache::write('test_decrement', 5, 'apcu'); + $this->assertTrue($result); + + $result = Cache::decrement('test_decrement', 1, 'apcu'); + $this->assertSame(4, $result); + + $result = Cache::read('test_decrement', 'apcu'); + $this->assertSame(4, $result); + + $result = Cache::decrement('test_decrement', 2, 'apcu'); + $this->assertSame(2, $result); + + $result = Cache::read('test_decrement', 'apcu'); + $this->assertSame(2, $result); + } + + /** + * testIncrement method + */ + public function testIncrement(): void + { + $result = Cache::write('test_increment', 5, 'apcu'); + $this->assertTrue($result); + + $result = Cache::increment('test_increment', 1, 'apcu'); + $this->assertSame(6, $result); + + $result = Cache::read('test_increment', 'apcu'); + $this->assertSame(6, $result); + + $result = Cache::increment('test_increment', 2, 'apcu'); + $this->assertSame(8, $result); + + $result = Cache::read('test_increment', 'apcu'); + $this->assertSame(8, $result); + } + + /** + * test the clearing of cache keys + */ + public function testClear(): void + { + apcu_store('not_cake', 'survive'); + Cache::write('some_value', 'value', 'apcu'); + + $result = Cache::clear('apcu'); + $this->assertTrue($result); + $this->assertNull(Cache::read('some_value', 'apcu')); + $this->assertSame('survive', apcu_fetch('not_cake')); + apcu_delete('not_cake'); + } + + /** + * Tests that configuring groups for stored keys return the correct values when read/written + * Shows that altering the group value is equivalent to deleting all keys under the same + * group + */ + public function testGroupsReadWrite(): void + { + Cache::setConfig('apcu_groups', [ + 'engine' => 'Apcu', + 'duration' => 0, + 'groups' => ['group_a', 'group_b'], + 'prefix' => 'test_', + 'warnOnWriteFailures' => true, + ]); + $this->assertTrue(Cache::write('test_groups', 'value', 'apcu_groups')); + $this->assertSame('value', Cache::read('test_groups', 'apcu_groups')); + + apcu_inc('test_group_a'); + $this->assertNull(Cache::read('test_groups', 'apcu_groups')); + $this->assertTrue(Cache::write('test_groups', 'value2', 'apcu_groups')); + $this->assertSame('value2', Cache::read('test_groups', 'apcu_groups')); + + apcu_inc('test_group_b'); + $this->assertNull(Cache::read('test_groups', 'apcu_groups')); + $this->assertTrue(Cache::write('test_groups', 'value3', 'apcu_groups')); + $this->assertSame('value3', Cache::read('test_groups', 'apcu_groups')); + } + + /** + * Tests that deleting from a groups-enabled config is possible + */ + public function testGroupDelete(): void + { + Cache::setConfig('apcu_groups', [ + 'engine' => 'Apcu', + 'duration' => 0, + 'groups' => ['group_a', 'group_b'], + 'prefix' => 'test_', + 'warnOnWriteFailures' => true, + ]); + $this->assertTrue(Cache::write('test_groups', 'value', 'apcu_groups')); + $this->assertSame('value', Cache::read('test_groups', 'apcu_groups')); + $this->assertTrue(Cache::delete('test_groups', 'apcu_groups')); + + $this->assertNull(Cache::read('test_groups', 'apcu_groups')); + } + + /** + * Test clearing a cache group + */ + public function testGroupClear(): void + { + Cache::setConfig('apcu_groups', [ + 'engine' => 'Apcu', + 'duration' => 0, + 'groups' => ['group_a', 'group_b'], + 'prefix' => 'test_', + 'warnOnWriteFailures' => true, + ]); + + $this->assertTrue(Cache::write('test_groups', 'value', 'apcu_groups')); + $this->assertTrue(Cache::clearGroup('group_a', 'apcu_groups')); + $this->assertNull(Cache::read('test_groups', 'apcu_groups')); + + $this->assertTrue(Cache::write('test_groups', 'value2', 'apcu_groups')); + $this->assertTrue(Cache::clearGroup('group_b', 'apcu_groups')); + $this->assertNull(Cache::read('test_groups', 'apcu_groups')); + } + + /** + * Test add + */ + public function testAdd(): void + { + Cache::delete('test_add_key', 'apcu'); + + $result = Cache::add('test_add_key', 'test data', 'apcu'); + $this->assertTrue($result); + + $expected = 'test data'; + $result = Cache::read('test_add_key', 'apcu'); + $this->assertSame($expected, $result); + + $result = Cache::add('test_add_key', 'test data 2', 'apcu'); + $this->assertFalse($result); + } +} diff --git a/tests/TestCase/Cache/Engine/ArrayEngineTest.php b/tests/TestCase/Cache/Engine/ArrayEngineTest.php new file mode 100644 index 00000000000..99407d004cd --- /dev/null +++ b/tests/TestCase/Cache/Engine/ArrayEngineTest.php @@ -0,0 +1,278 @@ +_configCache(); + Cache::clearAll(); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + Cache::drop('array'); + Cache::drop('array_groups'); + } + + /** + * Helper method for testing. + * + * @param array $config + */ + protected function _configCache($config = []): void + { + $defaults = [ + 'className' => 'Array', + 'prefix' => 'cake_', + 'warnOnWriteFailures' => true, + ]; + $this->engine = 'array'; + Cache::drop('array'); + Cache::setConfig('array', array_merge($defaults, $config)); + } + + /** + * testReadAndWriteCache method + */ + public function testReadAndWriteCache(): void + { + $this->_configCache(['duration' => 1]); + + $result = Cache::read('test', 'array'); + $this->assertNull($result); + + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('test', $data, 'array'); + $this->assertTrue($result); + + $result = Cache::read('test', 'array'); + $expecting = $data; + $this->assertSame($expecting, $result); + + Cache::delete('test', 'array'); + } + + /** + * testExpiry method + */ + public function testExpiry(): void + { + $this->_configCache(['duration' => 1]); + + $result = Cache::read('test', 'array'); + $this->assertNull($result); + + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('other_test', $data, 'array'); + $this->assertTrue($result); + + sleep(2); + $result = Cache::read('other_test', 'array'); + $this->assertNull($result); + } + + /** + * testDeleteCache method + */ + public function testDeleteCache(): void + { + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('delete_test', $data, 'array'); + $this->assertTrue($result); + + $result = Cache::delete('delete_test', 'array'); + $this->assertTrue($result); + } + + /** + * testDecrement method + */ + public function testDecrement(): void + { + $result = Cache::write('test_decrement', 5, 'array'); + $this->assertTrue($result); + + $result = Cache::decrement('test_decrement', 1, 'array'); + $this->assertSame(4, $result); + + $result = Cache::read('test_decrement', 'array'); + $this->assertSame(4, $result); + + $result = Cache::decrement('test_decrement', 2, 'array'); + $this->assertSame(2, $result); + + $result = Cache::read('test_decrement', 'array'); + $this->assertSame(2, $result); + } + + /** + * testIncrement method + */ + public function testIncrement(): void + { + $result = Cache::write('test_increment', 5, 'array'); + $this->assertTrue($result); + + $result = Cache::increment('test_increment', 1, 'array'); + $this->assertSame(6, $result); + + $result = Cache::read('test_increment', 'array'); + $this->assertSame(6, $result); + + $result = Cache::increment('test_increment', 2, 'array'); + $this->assertSame(8, $result); + + $result = Cache::read('test_increment', 'array'); + $this->assertSame(8, $result); + } + + /** + * test the clearing of cache keys + */ + public function testClear(): void + { + Cache::write('some_value', 'value', 'array'); + + $result = Cache::clear('array'); + $this->assertTrue($result); + $this->assertNull(Cache::read('some_value', 'array')); + } + + /** + * Tests that configuring groups for stored keys return the correct values when read/written + * Shows that altering the group value is equivalent to deleting all keys under the same + * group + */ + public function testGroupsReadWrite(): void + { + Cache::setConfig('array_groups', [ + 'engine' => 'array', + 'duration' => 30, + 'groups' => ['group_a', 'group_b'], + 'prefix' => 'test_', + 'warnOnWriteFailures' => true, + ]); + $this->assertTrue(Cache::write('test_groups', 'value', 'array_groups')); + $this->assertSame('value', Cache::read('test_groups', 'array_groups')); + + Cache::clearGroup('group_a', 'array_groups'); + $this->assertNull(Cache::read('test_groups', 'array_groups')); + $this->assertTrue(Cache::write('test_groups', 'value2', 'array_groups')); + $this->assertSame('value2', Cache::read('test_groups', 'array_groups')); + + Cache::clearGroup('group_b', 'array_groups'); + $this->assertNull(Cache::read('test_groups', 'array_groups')); + $this->assertTrue(Cache::write('test_groups', 'value3', 'array_groups')); + $this->assertSame('value3', Cache::read('test_groups', 'array_groups')); + } + + /** + * Tests that deleting from a groups-enabled config is possible + */ + public function testGroupDelete(): void + { + Cache::setConfig('array_groups', [ + 'engine' => 'array', + 'duration' => 10, + 'groups' => ['group_a', 'group_b'], + 'prefix' => 'test_', + 'warnOnWriteFailures' => true, + ]); + $this->assertTrue(Cache::write('test_groups', 'value', 'array_groups')); + $this->assertSame('value', Cache::read('test_groups', 'array_groups')); + + $this->assertTrue(Cache::delete('test_groups', 'array_groups')); + $this->assertNull(Cache::read('test_groups', 'array_groups')); + } + + /** + * Test clearing a cache group + */ + public function testGroupClear(): void + { + Cache::setConfig('array_groups', [ + 'engine' => 'array', + 'duration' => 10, + 'groups' => ['group_a', 'group_b'], + 'prefix' => 'test_', + 'warnOnWriteFailures' => true, + ]); + + $this->assertTrue(Cache::write('test_groups', 'value', 'array_groups')); + $this->assertTrue(Cache::clearGroup('group_a', 'array_groups')); + $this->assertNull(Cache::read('test_groups', 'array_groups')); + + $this->assertTrue(Cache::write('test_groups', 'value2', 'array_groups')); + $this->assertTrue(Cache::clearGroup('group_b', 'array_groups')); + $this->assertNull(Cache::read('test_groups', 'array_groups')); + } + + /** + * Test add + */ + public function testAdd(): void + { + Cache::delete('test_add_key', 'array'); + + $result = Cache::add('test_add_key', 'test data', 'array'); + $this->assertTrue($result); + + $expected = 'test data'; + $result = Cache::read('test_add_key', 'array'); + $this->assertSame($expected, $result); + + $result = Cache::add('test_add_key', 'test data 2', 'array'); + $this->assertFalse($result); + } + + /** + * Test writeMany() with Traversable + */ + public function testWriteManyTraversable(): void + { + $data = new ArrayObject([ + 'a' => 1, + 'b' => 'foo', + ]); + + $result = Cache::writeMany($data, 'array'); + $this->assertTrue($result); + + $this->assertSame(1, Cache::read('a', 'array')); + $this->assertSame('foo', Cache::read('b', 'array')); + } +} diff --git a/tests/TestCase/Cache/Engine/CacheEngineTest.php b/tests/TestCase/Cache/Engine/CacheEngineTest.php new file mode 100644 index 00000000000..49c657fd4ce --- /dev/null +++ b/tests/TestCase/Cache/Engine/CacheEngineTest.php @@ -0,0 +1,36 @@ +setConfig(['duration' => 10]); + + $result = $engine->getDuration($ttl); + + $this->assertSame($result, $expected); + } +} diff --git a/tests/TestCase/Cache/Engine/EngineEventsTrait.php b/tests/TestCase/Cache/Engine/EngineEventsTrait.php new file mode 100644 index 00000000000..0a587129f48 --- /dev/null +++ b/tests/TestCase/Cache/Engine/EngineEventsTrait.php @@ -0,0 +1,214 @@ +engine)->getEventManager(); + $manager->on(CacheBeforeGetEvent::NAME, function (CacheBeforeGetEvent $event) use (&$beforeEventIsCalled): void { + $this->assertSame('cake_test', $event->getKey()); + $this->assertSame(null, $event->getDefault()); + $beforeEventIsCalled = true; + }); + $manager->on(CacheAfterGetEvent::NAME, function (CacheAfterGetEvent $event) use (&$afterEventIsCalled): void { + $this->assertSame('cake_test', $event->getKey()); + if ($this->engine === 'apcu') { + $this->assertFalse($event->getValue()); + } else { + $this->assertNull($event->getValue()); + } + $this->assertFalse($event->getResult()); + $afterEventIsCalled = true; + }); + + Cache::read('test', $this->engine); + + $this->assertTrue($beforeEventIsCalled); + $this->assertTrue($afterEventIsCalled); + } + + public function testSetEventsAreFired(): void + { + $beforeEventIsCalled = false; + $afterEventIsCalled = false; + $manager = Cache::pool($this->engine)->getEventManager(); + $manager->on(CacheBeforeSetEvent::NAME, function (CacheBeforeSetEvent $event) use (&$beforeEventIsCalled): void { + $this->assertSame('cake_test', $event->getKey()); + $this->assertEquals(1234, $event->getValue()); + $this->assertEquals(3600, $event->getTtl()); + $beforeEventIsCalled = true; + }); + $manager->on(CacheAfterSetEvent::NAME, function (CacheAfterSetEvent $event) use (&$afterEventIsCalled): void { + $this->assertSame('cake_test', $event->getKey()); + $this->assertEquals(1234, $event->getValue()); + $this->assertEquals(3600, $event->getTtl()); + $afterEventIsCalled = true; + }); + + Cache::write('test', 1234, $this->engine); + + $this->assertTrue($beforeEventIsCalled); + $this->assertTrue($afterEventIsCalled); + } + + public function testAddEventsAreFired(): void + { + $beforeEventIsCalled = false; + $afterEventIsCalled = false; + $manager = Cache::pool($this->engine)->getEventManager(); + $manager->on(CacheBeforeAddEvent::NAME, function (CacheBeforeAddEvent $event) use (&$beforeEventIsCalled): void { + $this->assertSame('cake_test', $event->getKey()); + $this->assertEquals(1234, $event->getValue()); + $this->assertEquals(3600, $event->getTtl()); + $beforeEventIsCalled = true; + }); + $manager->on(CacheAfterAddEvent::NAME, function (CacheAfterAddEvent $event) use (&$afterEventIsCalled): void { + $this->assertSame('cake_test', $event->getKey()); + $this->assertEquals(1234, $event->getValue()); + $this->assertEquals(3600, $event->getTtl()); + $this->assertTrue($event->getResult()); + $afterEventIsCalled = true; + }); + + Cache::delete('test', $this->engine); + Cache::add('test', 1234, $this->engine); + + $this->assertTrue($beforeEventIsCalled); + $this->assertTrue($afterEventIsCalled); + } + + public function testIncDecEventsAreFired(): void + { + $this->skipIf($this->engine === 'file_test', 'File engine does not support increment/decrement.'); + + $beforeIncEventIsCalled = false; + $beforeDecEventIsCalled = false; + $afterIncEventIsCalled = false; + $afterDecEventIsCalled = false; + $manager = Cache::pool($this->engine)->getEventManager(); + $manager->on(CacheBeforeIncrementEvent::NAME, function (CacheBeforeIncrementEvent $event) use (&$beforeIncEventIsCalled): void { + $this->assertSame('cake_test', $event->getKey()); + $this->assertEquals(1234, $event->getOffset()); + $beforeIncEventIsCalled = true; + }); + $manager->on(CacheBeforeDecrementEvent::NAME, function (CacheBeforeDecrementEvent $event) use (&$beforeDecEventIsCalled): void { + $this->assertSame('cake_test', $event->getKey()); + $this->assertEquals(234, $event->getOffset()); + $beforeDecEventIsCalled = true; + }); + $manager->on(CacheAfterIncrementEvent::NAME, function (CacheAfterIncrementEvent $event) use (&$afterIncEventIsCalled): void { + $this->assertSame('cake_test', $event->getKey()); + $this->assertEquals(1234, $event->getOffset()); + if ($this->engine !== 'memcached') { + // No idea why memcached doesn't work in CI + $this->assertTrue($event->getResult()); + $this->assertEquals(1234, $event->getValue()); + } + $afterIncEventIsCalled = true; + }); + $manager->on(CacheAfterDecrementEvent::NAME, function (CacheAfterDecrementEvent $event) use (&$afterDecEventIsCalled): void { + $this->assertSame('cake_test', $event->getKey()); + $this->assertEquals(234, $event->getOffset()); + if ($this->engine !== 'memcached') { + // No idea why memcached doesn't work in CI + $this->assertTrue($event->getResult()); + $this->assertEquals(1000, $event->getValue()); + } + $afterDecEventIsCalled = true; + }); + + Cache::delete('test', $this->engine); + Cache::increment('test', 1234, $this->engine); + Cache::decrement('test', 234, $this->engine); + + $this->assertTrue($beforeIncEventIsCalled); + $this->assertTrue($afterIncEventIsCalled); + $this->assertTrue($beforeDecEventIsCalled); + $this->assertTrue($afterDecEventIsCalled); + } + + public function testDeleteEventsAreFired(): void + { + $beforeEventIsCalled = false; + $afterEventIsCalled = false; + $manager = Cache::pool($this->engine)->getEventManager(); + $manager->on(CacheBeforeDeleteEvent::NAME, function (CacheBeforeDeleteEvent $event) use (&$beforeEventIsCalled): void { + $this->assertSame('cake_test', $event->getKey()); + $beforeEventIsCalled = true; + }); + $manager->on(CacheAfterDeleteEvent::NAME, function (CacheAfterDeleteEvent $event) use (&$afterEventIsCalled): void { + $this->assertSame('cake_test', $event->getKey()); + $this->assertTrue($event->getResult()); + $afterEventIsCalled = true; + }); + + // We need to write something first so delete returns true. + Cache::write('test', 1234, $this->engine); + Cache::delete('test', $this->engine); + + $this->assertTrue($beforeEventIsCalled); + $this->assertTrue($afterEventIsCalled); + } + + public function testClearEventsAreFired(): void + { + $eventIsCalled = false; + $manager = Cache::pool($this->engine)->getEventManager(); + $manager->on(CacheClearedEvent::NAME, function (CacheClearedEvent $e) use (&$eventIsCalled): void { + $eventIsCalled = true; + }); + + Cache::clear($this->engine); + + $this->assertTrue($eventIsCalled); + } + + public function testClearGroupEventsAreFired(): void + { + $eventIsCalled = false; + $manager = Cache::pool($this->engine)->getEventManager(); + $manager->on(CacheGroupClearEvent::NAME, function (CacheGroupClearEvent $event) use (&$eventIsCalled): void { + $this->assertSame('someGroup', $event->getGroup()); + $eventIsCalled = true; + }); + + Cache::clearGroup('someGroup', $this->engine); + + $this->assertTrue($eventIsCalled); + } +} diff --git a/tests/TestCase/Cache/Engine/FileEngineTest.php b/tests/TestCase/Cache/Engine/FileEngineTest.php index 7976f5b30e8..e9f2da17087 100644 --- a/tests/TestCase/Cache/Engine/FileEngineTest.php +++ b/tests/TestCase/Cache/Engine/FileEngineTest.php @@ -1,4 +1,6 @@ _configCache(); - Cache::clear(false, 'file_test'); + Cache::clear('file_test'); } /** * tearDown method - * - * @return void */ - public function tearDown() + protected function tearDown(): void { Cache::drop('file_test'); Cache::drop('file_groups'); @@ -56,60 +56,67 @@ public function tearDown() * Helper method for testing. * * @param array $config - * @return void */ - protected function _configCache($config = []) + protected function _configCache($config = []): void { $defaults = [ 'className' => 'File', 'path' => TMP . 'tests', ]; + $this->engine = 'file_test'; Cache::drop('file_test'); - Cache::config('file_test', array_merge($defaults, $config)); + Cache::setConfig('file_test', array_merge($defaults, $config)); + } + + /** + * Test get with default value + */ + public function testGetDefaultValue(): void + { + $file = Cache::pool('file_test'); + $this->assertFalse($file->get('nope', false)); + $this->assertNull($file->get('nope', null)); + $this->assertTrue($file->get('nope', true)); + $this->assertSame(0, $file->get('nope', 0)); + + $file->set('yep', 0); + $this->assertSame(0, $file->get('yep', false)); } /** * testReadAndWriteCache method - * - * @return void */ - public function testReadAndWriteCacheExpired() + public function testReadAndWriteCacheExpired(): void { $this->_configCache(['duration' => 1]); $result = Cache::read('test', 'file_test'); - $expecting = ''; - $this->assertEquals($expecting, $result); + $this->assertNull($result); } /** * Test reading and writing to the cache. - * - * @return void */ - public function testReadAndwrite() + public function testReadAndWrite(): void { $result = Cache::read('test', 'file_test'); - $expecting = ''; - $this->assertEquals($expecting, $result); + $this->assertNull($result); $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('test', $data, 'file_test'); + Cache::write('test', $data, 'file_test'); $this->assertFileExists(TMP . 'tests/cake_test'); $result = Cache::read('test', 'file_test'); $expecting = $data; - $this->assertEquals($expecting, $result); + $this->assertSame($expecting, $result); Cache::delete('test', 'file_test'); } /** * Test read/write on the same cache key. Ensures file handles are re-wound. - * - * @return void */ - public function testConsecutiveReadWrite() + public function testConsecutiveReadWrite(): void { Cache::write('rw', 'first write', 'file_test'); $result = Cache::read('rw', 'file_test'); @@ -118,21 +125,19 @@ public function testConsecutiveReadWrite() $resultB = Cache::read('rw', 'file_test'); Cache::delete('rw', 'file_test'); - $this->assertEquals('first write', $result); - $this->assertEquals('second write', $resultB); + $this->assertSame('first write', $result); + $this->assertSame('second write', $resultB); } /** * testExpiry method - * - * @return void */ - public function testExpiry() + public function testExpiry(): void { $this->_configCache(['duration' => 1]); $result = Cache::read('test', 'file_test'); - $this->assertFalse($result); + $this->assertNull($result); $data = 'this is a test of the emergency broadcasting system'; $result = Cache::write('other_test', $data, 'file_test'); @@ -140,7 +145,8 @@ public function testExpiry() sleep(2); $result = Cache::read('other_test', 'file_test'); - $this->assertFalse($result); + $this->assertNull($result, 'Expired key no result.'); + $this->assertSame(0, Cache::pool('file_test')->get('other_test', 0), 'expired values get default.'); $this->_configCache(['duration' => '+1 second']); @@ -150,15 +156,47 @@ public function testExpiry() sleep(2); $result = Cache::read('other_test', 'file_test'); - $this->assertFalse($result); + $this->assertNull($result); + } + + /** + * test set ttl parameter + */ + public function testSetWithTtl(): void + { + $this->_configCache(['duration' => 99]); + $engine = Cache::pool('file_test'); + $this->assertNull($engine->get('test')); + + $data = 'this is a test of the emergency broadcasting system'; + $this->assertTrue($engine->set('default_ttl', $data)); + $this->assertTrue($engine->set('int_ttl', $data, 1)); + $this->assertTrue($engine->set('interval_ttl', $data, new DateInterval('PT1S'))); + $this->assertTrue($engine->setMultiple(['multi' => $data], 1)); + + sleep(2); + $this->assertNull($engine->get('int_ttl')); + $this->assertNull($engine->get('interval_ttl')); + $this->assertSame($data, $engine->get('default_ttl')); + $this->assertNull($engine->get('multi')); + } + + /** + * Test has() method + */ + public function testHas(): void + { + $engine = Cache::pool('file_test'); + $this->assertFalse($engine->has('test')); + + $this->assertTrue($engine->set('test', 1)); + $this->assertTrue($engine->has('test')); } /** * testDeleteCache method - * - * @return void */ - public function testDeleteCache() + public function testDeleteCache(): void { $data = 'this is a test of the emergency broadcasting system'; $result = Cache::write('delete_test', $data, 'file_test'); @@ -166,7 +204,7 @@ public function testDeleteCache() $result = Cache::delete('delete_test', 'file_test'); $this->assertTrue($result); - $this->assertFileNotExists(TMP . 'tests/delete_test'); + $this->assertFileDoesNotExist(TMP . 'tests/delete_test'); $result = Cache::delete('delete_test', 'file_test'); $this->assertFalse($result); @@ -174,10 +212,8 @@ public function testDeleteCache() /** * testSerialize method - * - * @return void */ - public function testSerialize() + public function testSerialize(): void { $this->_configCache(['serialize' => true]); $data = 'this is a test of the emergency broadcasting system'; @@ -187,17 +223,15 @@ public function testSerialize() $this->_configCache(['serialize' => false]); $read = Cache::read('serialize_test', 'file_test'); - $delete = Cache::delete('serialize_test', 'file_test'); + Cache::delete('serialize_test', 'file_test'); $this->assertSame($read, serialize($data)); $this->assertSame(unserialize($read), $data); } /** * testClear method - * - * @return void */ - public function testClear() + public function testClear(): void { $this->_configCache(['duration' => 0]); @@ -209,133 +243,108 @@ public function testClear() $this->assertFileExists(TMP . 'tests/cake_serialize_test2'); $this->assertFileExists(TMP . 'tests/cake_serialize_test3'); - sleep(1); - $result = Cache::clear(true, 'file_test'); + $result = Cache::clear('file_test'); $this->assertTrue($result); - $this->assertFileNotExists(TMP . 'tests/cake_serialize_test1'); - $this->assertFileNotExists(TMP . 'tests/cake_serialize_test2'); - $this->assertFileNotExists(TMP . 'tests/cake_serialize_test3'); - - $data = 'this is a test of the emergency broadcasting system'; - Cache::write('serialize_test1', $data, 'file_test'); - Cache::write('serialize_test2', $data, 'file_test'); - Cache::write('serialize_test3', $data, 'file_test'); - $this->assertFileExists(TMP . 'tests/cake_serialize_test1'); - $this->assertFileExists(TMP . 'tests/cake_serialize_test2'); - $this->assertFileExists(TMP . 'tests/cake_serialize_test3'); - - $result = Cache::clear(false, 'file_test'); - $this->assertTrue($result); - $this->assertFileNotExists(CACHE . 'cake_serialize_test1'); - $this->assertFileNotExists(CACHE . 'cake_serialize_test2'); - $this->assertFileNotExists(CACHE . 'cake_serialize_test3'); + $this->assertFileDoesNotExist(TMP . 'tests/cake_serialize_test1'); + $this->assertFileDoesNotExist(TMP . 'tests/cake_serialize_test2'); + $this->assertFileDoesNotExist(TMP . 'tests/cake_serialize_test3'); } /** * test that clear() doesn't wipe files not in the current engine's prefix. - * - * @return void */ - public function testClearWithPrefixes() + public function testClearWithPrefixes(): void { $FileOne = new FileEngine(); $FileOne->init([ 'prefix' => 'prefix_one_', - 'duration' => DAY + 'duration' => 3600, ]); $FileTwo = new FileEngine(); $FileTwo->init([ 'prefix' => 'prefix_two_', - 'duration' => DAY + 'duration' => 3600, ]); - - $dataOne = $dataTwo = $expected = 'content to cache'; - $FileOne->write('prefix_one_key_one', $dataOne); - $FileTwo->write('prefix_two_key_two', $dataTwo); - - $this->assertEquals($expected, $FileOne->read('prefix_one_key_one')); - $this->assertEquals($expected, $FileTwo->read('prefix_two_key_two')); - - $FileOne->clear(false); - $this->assertEquals($expected, $FileTwo->read('prefix_two_key_two'), 'secondary config was cleared by accident.'); - $FileTwo->clear(false); + $dataOne = 'content to cache'; + $dataTwo = 'content to cache'; + $expected = 'content to cache'; + $FileOne->set('prefix_one_key_one', $dataOne); + $FileTwo->set('prefix_two_key_two', $dataTwo); + + $this->assertSame($expected, $FileOne->get('prefix_one_key_one')); + $this->assertSame($expected, $FileTwo->get('prefix_two_key_two')); + + $FileOne->clear(); + $this->assertSame($expected, $FileTwo->get('prefix_two_key_two'), 'secondary config was cleared by accident.'); + $FileTwo->clear(); } /** * Test that clear() also removes files with group tags. - * - * @return void */ - public function testClearWithGroups() + public function testClearWithGroups(): void { $engine = new FileEngine(); $engine->init([ 'prefix' => 'cake_test_', - 'duration' => DAY, - 'groups' => ['short', 'round'] + 'duration' => 3600, + 'groups' => ['short', 'round'], ]); $key = 'cake_test_test_key'; - $engine->write($key, 'it works'); - $engine->clear(false); - $this->assertFalse($engine->read($key), 'Key should have been removed'); + $engine->set($key, 'it works'); + $engine->clear(); + $this->assertNull($engine->get($key), 'Key should have been removed'); } /** * Test that clear() also removes files with group tags. - * - * @return void */ - public function testClearWithNoKeys() + public function testClearWithNoKeys(): void { $engine = new FileEngine(); $engine->init([ 'prefix' => 'cake_test_', - 'duration' => DAY, - 'groups' => ['one', 'two'] + 'duration' => 3600, + 'groups' => ['one', 'two'], ]); $key = 'cake_test_test_key'; - $engine->clear(false); - $this->assertFalse($engine->read($key), 'No errors should be found'); + $engine->clear(); + $this->assertNull($engine->get($key), 'No errors should be found'); } /** * testKeyPath method - * - * @return void */ - public function testKeyPath() + public function testKeyPath(): void { $result = Cache::write('views.countries.something', 'here', 'file_test'); $this->assertTrue($result); - $this->assertFileExists(TMP . 'tests/cake_views_countries_something'); + $this->assertFileExists(TMP . 'tests/cake_views.countries.something'); $result = Cache::read('views.countries.something', 'file_test'); - $this->assertEquals('here', $result); + $this->assertSame('here', $result); - $result = Cache::clear(false, 'file_test'); + $key = 'colon:quote"slash/brackets[]'; + $result = Cache::write($key, 'here', 'file_test'); $this->assertTrue($result); + $this->assertFileExists(TMP . 'tests/cake_colon%3Aquote%22slash%2Fbrackets%5B%5D'); - $result = Cache::write('domain.test.com:8080', 'here', 'file_test'); - $this->assertTrue($result); - $this->assertFileExists(TMP . 'tests/cake_domain_test_com_8080'); + $result = Cache::read($key, 'file_test'); + $this->assertSame('here', $result); - $result = Cache::write('command>dir|more', 'here', 'file_test'); + $result = Cache::clear('file_test'); $this->assertTrue($result); - $this->assertFileExists(TMP . 'tests/cake_command_dir_more'); } /** * testRemoveWindowsSlashesFromCache method - * - * @return void */ - public function testRemoveWindowsSlashesFromCache() + public function testRemoveWindowsSlashesFromCache(): void { - Cache::config('windows_test', [ + Cache::setConfig('windows_test', [ 'engine' => 'File', - 'isWindows' => true, 'prefix' => null, - 'path' => TMP + 'path' => CACHE, ]); $expected = [ @@ -364,7 +373,7 @@ public function testRemoveWindowsSlashesFromCache() 6 => 'C:\dev\prj2\sites\vendors\simpletest\extensions\testdox', 7 => 'C:\dev\prj2\sites\vendors\simpletest\docs', 8 => 'C:\dev\prj2\sites\vendors\simpletest\docs\fr', 9 => 'C:\dev\prj2\sites\vendors\simpletest\docs\en'], 'C:\dev\prj2\sites\main_site\views\helpers' => [ - 0 => 'C:\dev\prj2\sites\main_site\views\helpers'] + 0 => 'C:\dev\prj2\sites\main_site\views\helpers'], ]; Cache::write('test_dir_map', $expected, 'windows_test'); @@ -377,10 +386,8 @@ public function testRemoveWindowsSlashesFromCache() /** * testWriteQuotedString method - * - * @return void */ - public function testWriteQuotedString() + public function testWriteQuotedString(): void { Cache::write('App.doubleQuoteTest', '"this is a quoted string"', 'file_test'); $this->assertSame(Cache::read('App.doubleQuoteTest', 'file_test'), '"this is a quoted string"'); @@ -388,10 +395,10 @@ public function testWriteQuotedString() $this->assertSame(Cache::read('App.singleQuoteTest', 'file_test'), "'this is a quoted string'"); Cache::drop('file_test'); - Cache::config('file_test', [ + Cache::setConfig('file_test', [ 'className' => 'File', 'isWindows' => true, - 'path' => TMP . 'tests' + 'path' => TMP . 'tests', ]); $this->assertSame(Cache::read('App.doubleQuoteTest', 'file_test'), '"this is a quoted string"'); @@ -403,18 +410,16 @@ public function testWriteQuotedString() /** * check that FileEngine does not generate an error when a configured Path does not exist in debug mode. - * - * @return void */ - public function testPathDoesNotExist() + public function testPathDoesNotExist(): void { Configure::write('debug', true); $dir = TMP . 'tests/autocreate-' . microtime(true); Cache::drop('file_test'); - Cache::config('file_test', [ + Cache::setConfig('file_test', [ 'engine' => 'File', - 'path' => $dir + 'path' => $dir, ]); Cache::read('Test', 'file_test'); @@ -426,18 +431,16 @@ public function testPathDoesNotExist() /** * Test that under debug 0 directories do get made. - * - * @return void */ - public function testPathDoesNotExistDebugOff() + public function testPathDoesNotExistDebugOff(): void { Configure::write('debug', false); $dir = TMP . 'tests/autocreate-' . microtime(true); Cache::drop('file_test'); - Cache::config('file_test', [ + Cache::setConfig('file_test', [ 'engine' => 'File', - 'path' => $dir + 'path' => $dir, ]); Cache::read('Test', 'file_test'); @@ -449,62 +452,58 @@ public function testPathDoesNotExistDebugOff() /** * Testing the mask setting in FileEngine - * - * @return void */ - public function testMaskSetting() + public function testMaskSetting(): void { if (DS === '\\') { $this->markTestSkipped('File permission testing does not work on Windows.'); } - Cache::config('mask_test', ['engine' => 'File', 'path' => TMP . 'tests']); + Cache::setConfig('mask_test', ['engine' => 'File', 'path' => TMP . 'tests']); $data = 'This is some test content'; - $write = Cache::write('masking_test', $data, 'mask_test'); + Cache::write('masking_test', $data, 'mask_test'); $result = substr(sprintf('%o', fileperms(TMP . 'tests/cake_masking_test')), -4); $expected = '0664'; - $this->assertEquals($expected, $result); + $this->assertSame($expected, $result); Cache::delete('masking_test', 'mask_test'); Cache::drop('mask_test'); - Cache::config('mask_test', ['engine' => 'File', 'mask' => 0666, 'path' => TMP . 'tests']); + Cache::setConfig('mask_test', ['engine' => 'File', 'mask' => 0666, 'path' => TMP . 'tests']); Cache::write('masking_test', $data, 'mask_test'); $result = substr(sprintf('%o', fileperms(TMP . 'tests/cake_masking_test')), -4); $expected = '0666'; - $this->assertEquals($expected, $result); + $this->assertSame($expected, $result); Cache::delete('masking_test', 'mask_test'); Cache::drop('mask_test'); - Cache::config('mask_test', ['engine' => 'File', 'mask' => 0644, 'path' => TMP . 'tests']); + Cache::setConfig('mask_test', ['engine' => 'File', 'mask' => 0644, 'path' => TMP . 'tests']); Cache::write('masking_test', $data, 'mask_test'); $result = substr(sprintf('%o', fileperms(TMP . 'tests/cake_masking_test')), -4); $expected = '0644'; - $this->assertEquals($expected, $result); + $this->assertSame($expected, $result); Cache::delete('masking_test', 'mask_test'); Cache::drop('mask_test'); - Cache::config('mask_test', ['engine' => 'File', 'mask' => 0640, 'path' => TMP . 'tests']); + Cache::setConfig('mask_test', ['engine' => 'File', 'mask' => 0640, 'path' => TMP . 'tests']); Cache::write('masking_test', $data, 'mask_test'); $result = substr(sprintf('%o', fileperms(TMP . 'tests/cake_masking_test')), -4); $expected = '0640'; - $this->assertEquals($expected, $result); + $this->assertSame($expected, $result); Cache::delete('masking_test', 'mask_test'); Cache::drop('mask_test'); } /** * Tests that configuring groups for stored keys return the correct values when read/written - * - * @return void */ - public function testGroupsReadWrite() + public function testGroupsReadWrite(): void { - Cache::config('file_groups', [ + Cache::setConfig('file_groups', [ 'engine' => 'File', 'duration' => 3600, - 'groups' => ['group_a', 'group_b'] + 'groups' => ['group_a', 'group_b'], ]); $this->assertTrue(Cache::write('test_groups', 'value', 'file_groups')); - $this->assertEquals('value', Cache::read('test_groups', 'file_groups')); + $this->assertSame('value', Cache::read('test_groups', 'file_groups')); $this->assertTrue(Cache::write('test_groups2', 'value2', 'file_groups')); $this->assertTrue(Cache::write('test_groups3', 'value3', 'file_groups')); @@ -512,71 +511,65 @@ public function testGroupsReadWrite() /** * Test that clearing with repeat writes works properly - * - * @return void */ - public function testClearingWithRepeatWrites() + public function testClearingWithRepeatWrites(): void { - Cache::config('repeat', [ + Cache::setConfig('repeat', [ 'engine' => 'File', - 'groups' => ['users'] + 'groups' => ['users'], ]); $this->assertTrue(Cache::write('user', 'rchavik', 'repeat')); - $this->assertEquals('rchavik', Cache::read('user', 'repeat')); + $this->assertSame('rchavik', Cache::read('user', 'repeat')); Cache::delete('user', 'repeat'); - $this->assertFalse(Cache::read('user', 'repeat')); + $this->assertNull(Cache::read('user', 'repeat')); $this->assertTrue(Cache::write('user', 'ADmad', 'repeat')); - $this->assertEquals('ADmad', Cache::read('user', 'repeat')); + $this->assertSame('ADmad', Cache::read('user', 'repeat')); Cache::clearGroup('users', 'repeat'); - $this->assertFalse(Cache::read('user', 'repeat')); + $this->assertNull(Cache::read('user', 'repeat')); $this->assertTrue(Cache::write('user', 'markstory', 'repeat')); - $this->assertEquals('markstory', Cache::read('user', 'repeat')); + $this->assertSame('markstory', Cache::read('user', 'repeat')); Cache::drop('repeat'); } /** * Tests that deleting from a groups-enabled config is possible - * - * @return void */ - public function testGroupDelete() + public function testGroupDelete(): void { - Cache::config('file_groups', [ + Cache::setConfig('file_groups', [ 'engine' => 'File', 'duration' => 3600, - 'groups' => ['group_a', 'group_b'] + 'groups' => ['group_a', 'group_b'], ]); $this->assertTrue(Cache::write('test_groups', 'value', 'file_groups')); - $this->assertEquals('value', Cache::read('test_groups', 'file_groups')); + $this->assertSame('value', Cache::read('test_groups', 'file_groups')); $this->assertTrue(Cache::delete('test_groups', 'file_groups')); - $this->assertFalse(Cache::read('test_groups', 'file_groups')); + $this->assertNull(Cache::read('test_groups', 'file_groups')); } /** * Test clearing a cache group - * - * @return void */ - public function testGroupClear() + public function testGroupClear(): void { - Cache::config('file_groups', [ + Cache::setConfig('file_groups', [ 'engine' => 'File', 'duration' => 3600, - 'groups' => ['group_a', 'group_b'] + 'groups' => ['group_a', 'group_b'], ]); - Cache::config('file_groups2', [ + Cache::setConfig('file_groups2', [ 'engine' => 'File', 'duration' => 3600, - 'groups' => ['group_b'] + 'groups' => ['group_b'], ]); - Cache::config('file_groups3', [ + Cache::setConfig('file_groups3', [ 'engine' => 'File', 'duration' => 3600, 'groups' => ['group_b'], @@ -588,46 +581,42 @@ public function testGroupClear() $this->assertTrue(Cache::write('test_groups3', 'value 3', 'file_groups3')); $this->assertTrue(Cache::clearGroup('group_b', 'file_groups')); - $this->assertFalse(Cache::read('test_groups', 'file_groups')); - $this->assertFalse(Cache::read('test_groups2', 'file_groups2')); - $this->assertEquals('value 3', Cache::read('test_groups3', 'file_groups3')); + $this->assertNull(Cache::read('test_groups', 'file_groups')); + $this->assertNull(Cache::read('test_groups2', 'file_groups2')); + $this->assertSame('value 3', Cache::read('test_groups3', 'file_groups3')); $this->assertTrue(Cache::write('test_groups4', 'value', 'file_groups')); $this->assertTrue(Cache::write('test_groups5', 'value 2', 'file_groups2')); $this->assertTrue(Cache::write('test_groups6', 'value 3', 'file_groups3')); $this->assertTrue(Cache::clearGroup('group_b', 'file_groups')); - $this->assertFalse(Cache::read('test_groups4', 'file_groups')); - $this->assertFalse(Cache::read('test_groups5', 'file_groups2')); - $this->assertEquals('value 3', Cache::read('test_groups6', 'file_groups3')); + $this->assertNull(Cache::read('test_groups4', 'file_groups')); + $this->assertNull(Cache::read('test_groups5', 'file_groups2')); + $this->assertSame('value 3', Cache::read('test_groups6', 'file_groups3')); } /** * Test that clearGroup works with no prefix. - * - * @return void */ - public function testGroupClearNoPrefix() + public function testGroupClearNoPrefix(): void { - Cache::config('file_groups', [ + Cache::setConfig('file_groups', [ 'className' => 'File', 'duration' => 3600, 'prefix' => '', - 'groups' => ['group_a', 'group_b'] + 'groups' => ['group_a', 'group_b'], ]); Cache::write('key_1', 'value', 'file_groups'); Cache::write('key_2', 'value', 'file_groups'); Cache::clearGroup('group_a', 'file_groups'); - $this->assertFalse(Cache::read('key_1', 'file_groups'), 'Did not delete'); - $this->assertFalse(Cache::read('key_2', 'file_groups'), 'Did not delete'); + $this->assertNull(Cache::read('key_1', 'file_groups'), 'Did not delete'); + $this->assertNull(Cache::read('key_2', 'file_groups'), 'Did not delete'); } /** * Test that failed add write return false. - * - * @return void */ - public function testAdd() + public function testAdd(): void { Cache::delete('test_add_key', 'file_test'); @@ -636,9 +625,34 @@ public function testAdd() $expected = 'test data'; $result = Cache::read('test_add_key', 'file_test'); - $this->assertEquals($expected, $result); + $this->assertSame($expected, $result); $result = Cache::add('test_add_key', 'test data 2', 'file_test'); $this->assertFalse($result); } + + /** + * Tests that only files inside of the configured path are being deleted. + */ + public function testClearIsRestrictedToConfiguredPath(): void + { + $this->_configCache([ + 'prefix' => '', + 'path' => TMP . 'tests', + ]); + + $unrelatedFile = tempnam(TMP, 'file_test'); + file_put_contents($unrelatedFile, 'data'); + $this->assertFileExists($unrelatedFile); + + Cache::write('key', 'data', 'file_test'); + $this->assertFileExists(TMP . 'tests/key'); + + $result = Cache::clear('file_test'); + $this->assertTrue($result); + $this->assertFileDoesNotExist(TMP . 'tests/key'); + + $this->assertFileExists($unrelatedFile); + $this->assertTrue(unlink($unrelatedFile)); + } } diff --git a/tests/TestCase/Cache/Engine/MemcachedEngineTest.php b/tests/TestCase/Cache/Engine/MemcachedEngineTest.php index e2502834b13..dd6995db3e5 100644 --- a/tests/TestCase/Cache/Engine/MemcachedEngineTest.php +++ b/tests/TestCase/Cache/Engine/MemcachedEngineTest.php @@ -1,4 +1,6 @@ skipIf(!class_exists('Memcached'), 'Memcached is not installed or configured properly.'); - // @codingStandardsIgnoreStart - $socket = @fsockopen('127.0.0.1', 11211, $errno, $errstr, 1); - // @codingStandardsIgnoreEnd + $this->port = env('MEMCACHED_PORT', $this->port); + + // phpcs:disable + $socket = @fsockopen('127.0.0.1', (int)$this->port, $errno, $errstr, 1); + // phpcs:enable $this->skipIf(!$socket, 'Memcached is not running.'); fclose($socket); @@ -48,25 +60,24 @@ public function setUp() * Helper method for testing. * * @param array $config - * @return void */ - protected function _configCache($config = []) + protected function _configCache($config = []): void { $defaults = [ 'className' => 'Memcached', 'prefix' => 'cake_', - 'duration' => 3600 + 'duration' => 3600, + 'servers' => ['127.0.0.1:' . $this->port], ]; + $this->engine = 'memcached'; Cache::drop('memcached'); - Cache::config('memcached', array_merge($defaults, $config)); + Cache::setConfig('memcached', array_merge($defaults, $config)); } /** * tearDown method - * - * @return void */ - public function tearDown() + protected function tearDown(): void { parent::tearDown(); Cache::drop('memcached'); @@ -80,18 +91,15 @@ public function tearDown() /** * testConfig method - * - * @return void */ - public function testConfig() + public function testConfig(): void { - $config = Cache::engine('memcached')->config(); + $config = Cache::pool('memcached')->getConfig(); unset($config['path']); $expecting = [ 'prefix' => 'cake_', 'duration' => 3600, - 'probability' => 100, - 'servers' => ['127.0.0.1'], + 'servers' => ['127.0.0.1:' . $this->port], 'persistent' => false, 'compress' => false, 'username' => null, @@ -107,229 +115,213 @@ public function testConfig() /** * testCompressionSetting method - * - * @return void */ - public function testCompressionSetting() + public function testCompressionSetting(): void { $Memcached = new MemcachedEngine(); $Memcached->init([ 'engine' => 'Memcached', - 'servers' => ['127.0.0.1:11211'], - 'compress' => false + 'servers' => ['127.0.0.1:' . $this->port], + 'compress' => false, ]); - $this->assertFalse($Memcached->getOption(\Memcached::OPT_COMPRESSION)); + $this->assertFalse($Memcached->getOption(Memcached::OPT_COMPRESSION)); $MemcachedCompressed = new MemcachedEngine(); $MemcachedCompressed->init([ 'engine' => 'Memcached', - 'servers' => ['127.0.0.1:11211'], - 'compress' => true + 'servers' => ['127.0.0.1:' . $this->port], + 'compress' => true, ]); - $this->assertTrue($MemcachedCompressed->getOption(\Memcached::OPT_COMPRESSION)); + $this->assertTrue($MemcachedCompressed->getOption(Memcached::OPT_COMPRESSION)); } /** * test setting options - * - * @return void */ - public function testOptionsSetting() + public function testOptionsSetting(): void { $memcached = new MemcachedEngine(); $memcached->init([ 'engine' => 'Memcached', - 'servers' => ['127.0.0.1:11211'], + 'servers' => ['127.0.0.1:' . $this->port], 'options' => [ - Memcached::OPT_BINARY_PROTOCOL => true - ] + Memcached::OPT_BINARY_PROTOCOL => true, + ], ]); - $this->assertEquals(1, $memcached->getOption(Memcached::OPT_BINARY_PROTOCOL)); + $this->assertSame(1, $memcached->getOption(Memcached::OPT_BINARY_PROTOCOL)); } /** * test accepts only valid serializer engine - * - * @return void */ - public function testInvalidSerializerSetting() + public function testInvalidSerializerSetting(): void { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('invalid_serializer is not a valid serializer engine for Memcached'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('`invalid_serializer` is not a valid serializer engine for Memcached.'); $Memcached = new MemcachedEngine(); $config = [ 'className' => 'Memcached', - 'servers' => ['127.0.0.1:11211'], + 'servers' => ['127.0.0.1:' . $this->port], 'persistent' => false, - 'serialize' => 'invalid_serializer' + 'serialize' => 'invalid_serializer', ]; $Memcached->init($config); } /** * testPhpSerializerSetting method - * - * @return void */ - public function testPhpSerializerSetting() + public function testPhpSerializerSetting(): void { $Memcached = new MemcachedEngine(); $config = [ 'className' => 'Memcached', - 'servers' => ['127.0.0.1:11211'], + 'servers' => ['127.0.0.1:' . $this->port], 'persistent' => false, - 'serialize' => 'php' + 'serialize' => 'php', ]; $Memcached->init($config); - $this->assertEquals(Memcached::SERIALIZER_PHP, $Memcached->getOption(Memcached::OPT_SERIALIZER)); + $this->assertSame(Memcached::SERIALIZER_PHP, $Memcached->getOption(Memcached::OPT_SERIALIZER)); } /** * testJsonSerializerSetting method - * - * @return void */ - public function testJsonSerializerSetting() + public function testJsonSerializerSetting(): void { $this->skipIf( !Memcached::HAVE_JSON, - 'Memcached extension is not compiled with json support' + 'Memcached extension is not compiled with json support', ); $Memcached = new MemcachedEngine(); $config = [ 'engine' => 'Memcached', - 'servers' => ['127.0.0.1:11211'], + 'servers' => ['127.0.0.1:' . $this->port], 'persistent' => false, - 'serialize' => 'json' + 'serialize' => 'json', ]; $Memcached->init($config); - $this->assertEquals(Memcached::SERIALIZER_JSON, $Memcached->getOption(Memcached::OPT_SERIALIZER)); + $this->assertSame(Memcached::SERIALIZER_JSON, $Memcached->getOption(Memcached::OPT_SERIALIZER)); } /** * testIgbinarySerializerSetting method - * - * @return void */ - public function testIgbinarySerializerSetting() + public function testIgbinarySerializerSetting(): void { $this->skipIf( !Memcached::HAVE_IGBINARY, - 'Memcached extension is not compiled with igbinary support' + 'Memcached extension is not compiled with igbinary support', ); $Memcached = new MemcachedEngine(); $config = [ 'engine' => 'Memcached', - 'servers' => ['127.0.0.1:11211'], + 'servers' => ['127.0.0.1:' . $this->port], 'persistent' => false, - 'serialize' => 'igbinary' + 'serialize' => 'igbinary', ]; $Memcached->init($config); - $this->assertEquals(Memcached::SERIALIZER_IGBINARY, $Memcached->getOption(Memcached::OPT_SERIALIZER)); + $this->assertSame(Memcached::SERIALIZER_IGBINARY, $Memcached->getOption(Memcached::OPT_SERIALIZER)); } /** * testMsgpackSerializerSetting method - * - * @return void */ - public function testMsgpackSerializerSetting() + public function testMsgpackSerializerSetting(): void { $this->skipIf( !defined('Memcached::HAVE_MSGPACK') || !Memcached::HAVE_MSGPACK, - 'Memcached extension is not compiled with msgpack support' + 'Memcached extension is not compiled with msgpack support', ); $Memcached = new MemcachedEngine(); $config = [ 'engine' => 'Memcached', - 'servers' => ['127.0.0.1:11211'], + 'servers' => ['127.0.0.1:' . $this->port], 'persistent' => false, - 'serialize' => 'msgpack' + 'serialize' => 'msgpack', ]; $Memcached->init($config); - $this->assertEquals(Memcached::SERIALIZER_MSGPACK, $Memcached->getOption(Memcached::OPT_SERIALIZER)); + $this->assertSame(Memcached::SERIALIZER_MSGPACK, $Memcached->getOption(Memcached::OPT_SERIALIZER)); } /** * testJsonSerializerThrowException method - * - * @return void */ - public function testJsonSerializerThrowException() + public function testJsonSerializerThrowException(): void { $this->skipIf( - Memcached::HAVE_JSON, - 'Memcached extension is compiled with json support' + (bool)Memcached::HAVE_JSON, + 'Memcached extension is compiled with json support', ); $Memcached = new MemcachedEngine(); $config = [ 'className' => 'Memcached', - 'servers' => ['127.0.0.1:11211'], + 'servers' => ['127.0.0.1:' . $this->port], 'persistent' => false, - 'serialize' => 'json' + 'serialize' => 'json', ]; - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Memcached extension is not compiled with json support'); $Memcached->init($config); } /** * testMsgpackSerializerThrowException method - * - * @return void */ - public function testMsgpackSerializerThrowException() + public function testMsgpackSerializerThrowException(): void { $this->skipIf( - defined('Memcached::HAVE_MSGPACK') && Memcached::HAVE_MSGPACK, - 'Memcached extension is compiled with msgpack support' + !defined('Memcached::HAVE_MSGPACK'), + 'Memcached::HAVE_MSGPACK constant is not available in Memcached below 3.0.0', + ); + $this->skipIf( + (bool)Memcached::HAVE_MSGPACK, + 'Memcached extension is compiled with msgpack support', ); $Memcached = new MemcachedEngine(); $config = [ 'engine' => 'Memcached', - 'servers' => ['127.0.0.1:11211'], + 'servers' => ['127.0.0.1:' . $this->port], 'persistent' => false, - 'serialize' => 'msgpack' + 'serialize' => 'msgpack', ]; - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('msgpack is not a valid serializer engine for Memcached'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Memcached extension is not compiled with msgpack support'); $Memcached->init($config); } /** * testIgbinarySerializerThrowException method - * - * @return void */ - public function testIgbinarySerializerThrowException() + public function testIgbinarySerializerThrowException(): void { $this->skipIf( - Memcached::HAVE_IGBINARY, - 'Memcached extension is compiled with igbinary support' + (bool)Memcached::HAVE_IGBINARY, + 'Memcached extension is compiled with igbinary support', ); $Memcached = new MemcachedEngine(); $config = [ 'engine' => 'Memcached', - 'servers' => ['127.0.0.1:11211'], + 'servers' => ['127.0.0.1:' . $this->port], 'persistent' => false, - 'serialize' => 'igbinary' + 'serialize' => 'igbinary', ]; - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Memcached extension is not compiled with igbinary support'); $Memcached->init($config); } @@ -337,48 +329,68 @@ public function testIgbinarySerializerThrowException() /** * test using authentication without memcached installed with SASL support * throw an exception - * - * @return void */ - public function testSaslAuthException() + public function testSaslAuthException(): void { - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Memcached extension is not build with SASL support'); $this->skipIf( method_exists(Memcached::class, 'setSaslAuthData'), - 'Cannot test exception when sasl has been compiled in.' + 'Cannot test exception when sasl has been compiled in.', ); $MemcachedEngine = new MemcachedEngine(); $config = [ 'engine' => 'Memcached', - 'servers' => ['127.0.0.1:11211'], + 'servers' => ['127.0.0.1:' . $this->port], 'persistent' => false, 'username' => 'test', - 'password' => 'password' + 'password' => 'password', ]; - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Memcached extension is not built with SASL support'); $MemcachedEngine->init($config); } + /** + * testConfigDifferentPorts method + */ + public function testConfigDifferentPorts(): void + { + $Memcached1 = new MemcachedEngine(); + $config1 = [ + 'className' => 'Memcached', + 'servers' => ['127.0.0.1:11211'], + 'persistent' => '123', + ]; + $Memcached1->init($config1); + + $Memcached2 = new MemcachedEngine(); + $config2 = [ + 'className' => 'Memcached', + 'servers' => ['127.0.0.1:11212'], + 'persistent' => '123', + ]; + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid cache configuration. Multiple persistent cache'); + $Memcached2->init($config2); + } + /** * testConfig method - * - * @return void */ - public function testMultipleServers() + public function testMultipleServers(): void { - $servers = ['127.0.0.1:11211', '127.0.0.1:11222']; + $servers = ['127.0.0.1:' . $this->port, '127.0.0.1:11222']; $available = true; - $Memcached = new \Memcached(); + $Memcached = new Memcached(); foreach ($servers as $server) { - list($host, $port) = explode(':', $server); - //@codingStandardsIgnoreStart - if (!$Memcached->addServer($host, $port)) { + [$host, $port] = explode(':', $server); + // phpcs:disable + if (!$Memcached->addServer($host, (int)$port)) { $available = false; } - //@codingStandardsIgnoreEnd + // phpcs:enable } $this->skipIf(!$available, 'Need memcached servers at ' . implode(', ', $servers) . ' to run this test.'); @@ -386,17 +398,15 @@ public function testMultipleServers() $Memcached = new MemcachedEngine(); $Memcached->init(['engine' => 'Memcached', 'servers' => $servers]); - $config = $Memcached->config(); + $config = $Memcached->getConfig(); $this->assertEquals($config['servers'], $servers); Cache::drop('dual_server'); } /** * test connecting to an ipv6 server. - * - * @return void */ - public function testConnectIpv6() + public function testConnectIpv6(): void { $Memcached = new MemcachedEngine(); $result = $Memcached->init([ @@ -404,18 +414,16 @@ public function testConnectIpv6() 'duration' => 200, 'engine' => 'Memcached', 'servers' => [ - '[::1]:11211' - ] + '[::1]:' . $this->port, + ], ]); $this->assertTrue($result); } /** * test domain starts with u - * - * @return void */ - public function testParseServerStringWithU() + public function testParseServerStringWithU(): void { $Memcached = new MemcachedEngine(); $result = $Memcached->parseServerString('udomain.net:13211'); @@ -424,10 +432,8 @@ public function testParseServerStringWithU() /** * test non latin domains. - * - * @return void */ - public function testParseServerStringNonLatin() + public function testParseServerStringNonLatin(): void { $Memcached = new MemcachedEngine(); $result = $Memcached->parseServerString('schülervz.net:13211'); @@ -439,10 +445,8 @@ public function testParseServerStringNonLatin() /** * test unix sockets. - * - * @return void */ - public function testParseServerStringUnix() + public function testParseServerStringUnix(): void { $Memcached = new MemcachedEngine(); $result = $Memcached->parseServerString('unix:///path/to/memcachedd.sock'); @@ -451,16 +455,13 @@ public function testParseServerStringUnix() /** * testReadAndWriteCache method - * - * @return void */ - public function testReadAndWriteCache() + public function testReadAndWriteCache(): void { $this->_configCache(['duration' => 1]); $result = Cache::read('test', 'memcached'); - $expecting = ''; - $this->assertEquals($expecting, $result); + $this->assertNull($result); $data = 'this is a test of the emergency broadcasting system'; $result = Cache::write('test', $data, 'memcached'); @@ -468,17 +469,30 @@ public function testReadAndWriteCache() $result = Cache::read('test', 'memcached'); $expecting = $data; - $this->assertEquals($expecting, $result); + $this->assertSame($expecting, $result); Cache::delete('test', 'memcached'); } + /** + * Test get with default value + */ + public function testGetDefaultValue(): void + { + $memcache = Cache::pool('memcached'); + $this->assertFalse($memcache->get('nope', false)); + $this->assertNull($memcache->get('nope', null)); + $this->assertTrue($memcache->get('nope', true)); + $this->assertSame(0, $memcache->get('nope', 0)); + + $memcache->set('yep', 0); + $this->assertSame(0, $memcache->get('yep', false)); + } + /** * testReadMany method - * - * @return void */ - public function testReadMany() + public function testReadMany(): void { $this->_configCache(['duration' => 2]); $data = [ @@ -486,7 +500,7 @@ public function testReadMany() 'App.trueTest' => true, 'App.nullTest' => null, 'App.zeroTest' => 0, - 'App.zeroTest2' => '0' + 'App.zeroTest2' => '0', ]; foreach ($data as $key => $value) { Cache::write($key, $value, 'memcached'); @@ -494,20 +508,48 @@ public function testReadMany() $read = Cache::readMany(array_merge(array_keys($data), ['App.doesNotExist']), 'memcached'); - $this->assertSame($read['App.falseTest'], false); - $this->assertSame($read['App.trueTest'], true); - $this->assertSame($read['App.nullTest'], null); + $this->assertFalse($read['App.falseTest']); + $this->assertTrue($read['App.trueTest']); + $this->assertNull($read['App.nullTest']); $this->assertSame($read['App.zeroTest'], 0); $this->assertSame($read['App.zeroTest2'], '0'); - $this->assertSame($read['App.doesNotExist'], false); + $this->assertNull($read['App.doesNotExist']); } /** - * testWriteMany method + * Test readMany where null is a valid cache value * - * @return void + * @throws \Psr\SimpleCache\InvalidArgumentException + */ + public function testReadManyTreatNullAsValidCacheValue(): void + { + $this->_configCache(['duration' => 2]); + $data = [ + 'App.falseTest' => false, + 'App.trueTest' => true, + 'App.nullTest' => null, + 'App.zeroTest' => 0, + 'App.zeroTest2' => '0', + ]; + foreach ($data as $key => $value) { + Cache::write($key, $value, 'memcached'); + } + + $default = new Exception('Cache key not found'); + $read = Cache::pool('memcached')->getMultiple(array_merge(array_keys($data), ['App.doesNotExist']), $default); + + $this->assertFalse($read['App.falseTest']); + $this->assertTrue($read['App.trueTest']); + $this->assertNull($read['App.nullTest']); + $this->assertSame($read['App.zeroTest'], 0); + $this->assertSame($read['App.zeroTest2'], '0'); + $this->assertSame($default, $read['App.doesNotExist']); + } + + /** + * testWriteMany method */ - public function testWriteMany() + public function testWriteMany(): void { $this->_configCache(['duration' => 2]); $data = [ @@ -515,28 +557,26 @@ public function testWriteMany() 'App.trueTest' => true, 'App.nullTest' => null, 'App.zeroTest' => 0, - 'App.zeroTest2' => '0' + 'App.zeroTest2' => '0', ]; Cache::writeMany($data, 'memcached'); - $this->assertSame(Cache::read('App.falseTest', 'memcached'), false); - $this->assertSame(Cache::read('App.trueTest', 'memcached'), true); - $this->assertSame(Cache::read('App.nullTest', 'memcached'), null); + $this->assertFalse(Cache::read('App.falseTest', 'memcached')); + $this->assertTrue(Cache::read('App.trueTest', 'memcached')); + $this->assertNull(Cache::read('App.nullTest', 'memcached')); $this->assertSame(Cache::read('App.zeroTest', 'memcached'), 0); $this->assertSame(Cache::read('App.zeroTest2', 'memcached'), '0'); } /** * testExpiry method - * - * @return void */ - public function testExpiry() + public function testExpiry(): void { $this->_configCache(['duration' => 1]); $result = Cache::read('test', 'memcached'); - $this->assertFalse($result); + $this->assertNull($result); $data = 'this is a test of the emergency broadcasting system'; $result = Cache::write('other_test', $data, 'memcached'); @@ -544,7 +584,7 @@ public function testExpiry() sleep(2); $result = Cache::read('other_test', 'memcached'); - $this->assertFalse($result); + $this->assertNull($result); $this->_configCache(['duration' => '+1 second']); @@ -554,10 +594,10 @@ public function testExpiry() sleep(3); $result = Cache::read('other_test', 'memcached'); - $this->assertFalse($result); + $this->assertNull($result); $result = Cache::read('other_test', 'memcached'); - $this->assertFalse($result); + $this->assertNull($result); $this->_configCache(['duration' => '+29 days']); $data = 'this is a test of the emergency broadcasting system'; @@ -567,15 +607,33 @@ public function testExpiry() sleep(2); $result = Cache::read('long_expiry_test', 'memcached'); $expecting = $data; - $this->assertEquals($expecting, $result); + $this->assertSame($expecting, $result); + } + + /** + * test set ttl parameter + */ + public function testSetWithTtl(): void + { + $this->_configCache(['duration' => 99]); + $engine = Cache::pool('memcached'); + $this->assertNull($engine->get('test')); + + $data = 'this is a test of the emergency broadcasting system'; + $this->assertTrue($engine->set('default_ttl', $data)); + $this->assertTrue($engine->set('int_ttl', $data, 1)); + $this->assertTrue($engine->set('interval_ttl', $data, new DateInterval('PT1S'))); + + sleep(2); + $this->assertNull($engine->get('int_ttl')); + $this->assertNull($engine->get('interval_ttl')); + $this->assertSame($data, $engine->get('default_ttl')); } /** * testDeleteCache method - * - * @return void */ - public function testDeleteCache() + public function testDeleteCache(): void { $data = 'this is a test of the emergency broadcasting system'; $result = Cache::write('delete_test', $data, 'memcached'); @@ -587,12 +645,9 @@ public function testDeleteCache() /** * testDeleteMany method - * - * @return void */ - public function testDeleteMany() + public function testDeleteMany(): void { - $this->skipIf(defined('HHVM_VERSION'), 'HHVM does not implement deleteMulti'); $this->_configCache(); $data = [ 'App.falseTest' => false, @@ -608,102 +663,94 @@ public function testDeleteMany() Cache::deleteMany(array_merge(array_keys($data), ['App.doesNotExist']), 'memcached'); - $this->assertSame(Cache::read('App.falseTest', 'memcached'), false); - $this->assertSame(Cache::read('App.trueTest', 'memcached'), false); - $this->assertSame(Cache::read('App.nullTest', 'memcached'), false); - $this->assertSame(Cache::read('App.zeroTest', 'memcached'), false); - $this->assertSame(Cache::read('App.zeroTest2', 'memcached'), false); - $this->assertSame(Cache::read('App.keepTest', 'memcached'), 'keepMe'); + $this->assertNull(Cache::read('App.falseTest', 'memcached')); + $this->assertNull(Cache::read('App.trueTest', 'memcached')); + $this->assertNull(Cache::read('App.nullTest', 'memcached')); + $this->assertNull(Cache::read('App.zeroTest', 'memcached')); + $this->assertNull(Cache::read('App.zeroTest2', 'memcached')); + $this->assertSame('keepMe', Cache::read('App.keepTest', 'memcached')); } /** * testDecrement method - * - * @return void */ - public function testDecrement() + public function testDecrement(): void { $result = Cache::write('test_decrement', 5, 'memcached'); $this->assertTrue($result); $result = Cache::decrement('test_decrement', 1, 'memcached'); - $this->assertEquals(4, $result); + $this->assertSame(4, $result); $result = Cache::read('test_decrement', 'memcached'); - $this->assertEquals(4, $result); + $this->assertSame(4, $result); $result = Cache::decrement('test_decrement', 2, 'memcached'); - $this->assertEquals(2, $result); + $this->assertSame(2, $result); $result = Cache::read('test_decrement', 'memcached'); - $this->assertEquals(2, $result); + $this->assertSame(2, $result); Cache::delete('test_decrement', 'memcached'); } /** * test decrementing compressed keys - * - * @return void */ - public function testDecrementCompressedKeys() + public function testDecrementCompressedKeys(): void { - Cache::config('compressed_memcached', [ + Cache::setConfig('compressed_memcached', [ 'engine' => 'Memcached', 'duration' => '+2 seconds', - 'servers' => ['127.0.0.1:11211'], - 'compress' => true + 'servers' => ['127.0.0.1:' . $this->port], + 'compress' => true, ]); $result = Cache::write('test_decrement', 5, 'compressed_memcached'); $this->assertTrue($result); $result = Cache::decrement('test_decrement', 1, 'compressed_memcached'); - $this->assertEquals(4, $result); + $this->assertSame(4, $result); $result = Cache::read('test_decrement', 'compressed_memcached'); - $this->assertEquals(4, $result); + $this->assertSame(4, $result); $result = Cache::decrement('test_decrement', 2, 'compressed_memcached'); - $this->assertEquals(2, $result); + $this->assertSame(2, $result); $result = Cache::read('test_decrement', 'compressed_memcached'); - $this->assertEquals(2, $result); + $this->assertSame(2, $result); Cache::delete('test_decrement', 'compressed_memcached'); } /** * testIncrement method - * - * @return void */ - public function testIncrement() + public function testIncrement(): void { $result = Cache::write('test_increment', 5, 'memcached'); $this->assertTrue($result); $result = Cache::increment('test_increment', 1, 'memcached'); - $this->assertEquals(6, $result); + $this->assertSame(6, $result); $result = Cache::read('test_increment', 'memcached'); - $this->assertEquals(6, $result); + $this->assertSame(6, $result); $result = Cache::increment('test_increment', 2, 'memcached'); - $this->assertEquals(8, $result); + $this->assertSame(8, $result); $result = Cache::read('test_increment', 'memcached'); - $this->assertEquals(8, $result); + $this->assertSame(8, $result); Cache::delete('test_increment', 'memcached'); } /** * Test that increment and decrement set ttls. - * - * @return void */ - public function testIncrementDecrementExpiring() + public function testIncrementDecrementExpiring(): void { $this->_configCache(['duration' => 1]); Cache::write('test_increment', 1, 'memcached'); @@ -712,74 +759,70 @@ public function testIncrementDecrementExpiring() $this->assertSame(2, Cache::increment('test_increment', 1, 'memcached')); $this->assertSame(0, Cache::decrement('test_decrement', 1, 'memcached')); - sleep(1); + sleep(2); - $this->assertFalse(Cache::read('test_increment', 'memcached')); - $this->assertFalse(Cache::read('test_decrement', 'memcached')); + $this->assertNull(Cache::read('test_increment', 'memcached')); + $this->assertNull(Cache::read('test_decrement', 'memcached')); } /** * test incrementing compressed keys - * - * @return void */ - public function testIncrementCompressedKeys() + public function testIncrementCompressedKeys(): void { - Cache::config('compressed_memcached', [ + Cache::setConfig('compressed_memcached', [ 'engine' => 'Memcached', 'duration' => '+2 seconds', - 'servers' => ['127.0.0.1:11211'], - 'compress' => true + 'servers' => ['127.0.0.1:' . $this->port], + 'compress' => true, ]); $result = Cache::write('test_increment', 5, 'compressed_memcached'); $this->assertTrue($result); $result = Cache::increment('test_increment', 1, 'compressed_memcached'); - $this->assertEquals(6, $result); + $this->assertSame(6, $result); $result = Cache::read('test_increment', 'compressed_memcached'); - $this->assertEquals(6, $result); + $this->assertSame(6, $result); $result = Cache::increment('test_increment', 2, 'compressed_memcached'); - $this->assertEquals(8, $result); + $this->assertSame(8, $result); $result = Cache::read('test_increment', 'compressed_memcached'); - $this->assertEquals(8, $result); + $this->assertSame(8, $result); Cache::delete('test_increment', 'compressed_memcached'); } /** * test that configurations don't conflict, when a file engine is declared after a memcached one. - * - * @return void */ - public function testConfigurationConflict() + public function testConfigurationConflict(): void { - Cache::config('long_memcached', [ + Cache::setConfig('long_memcached', [ 'engine' => 'Memcached', 'duration' => '+3 seconds', - 'servers' => ['127.0.0.1:11211'], + 'servers' => ['127.0.0.1:' . $this->port], ]); - Cache::config('short_memcached', [ + Cache::setConfig('short_memcached', [ 'engine' => 'Memcached', 'duration' => '+2 seconds', - 'servers' => ['127.0.0.1:11211'], + 'servers' => ['127.0.0.1:' . $this->port], ]); $this->assertTrue(Cache::write('duration_test', 'yay', 'long_memcached')); $this->assertTrue(Cache::write('short_duration_test', 'boo', 'short_memcached')); - $this->assertEquals('yay', Cache::read('duration_test', 'long_memcached'), 'Value was not read %s'); - $this->assertEquals('boo', Cache::read('short_duration_test', 'short_memcached'), 'Value was not read %s'); + $this->assertSame('yay', Cache::read('duration_test', 'long_memcached'), 'Value was not read %s'); + $this->assertSame('boo', Cache::read('short_duration_test', 'short_memcached'), 'Value was not read %s'); usleep(500000); - $this->assertEquals('yay', Cache::read('duration_test', 'long_memcached'), 'Value was not read %s'); + $this->assertSame('yay', Cache::read('duration_test', 'long_memcached'), 'Value was not read %s'); usleep(3000000); - $this->assertFalse(Cache::read('short_duration_test', 'short_memcached'), 'Cache was not invalidated %s'); - $this->assertFalse(Cache::read('duration_test', 'long_memcached'), 'Value did not expire %s'); + $this->assertNull(Cache::read('short_duration_test', 'short_memcached'), 'Cache was not invalidated %s'); + $this->assertNull(Cache::read('duration_test', 'long_memcached'), 'Value did not expire %s'); Cache::delete('duration_test', 'long_memcached'); Cache::delete('short_duration_test', 'short_memcached'); @@ -787,127 +830,117 @@ public function testConfigurationConflict() /** * test clearing memcached. - * - * @return void */ - public function testClear() + public function testClear(): void { - Cache::config('memcached2', [ + Cache::setConfig('memcached2', [ 'engine' => 'Memcached', 'prefix' => 'cake2_', - 'duration' => 3600 + 'duration' => 3600, + 'servers' => ['127.0.0.1:' . $this->port], ]); Cache::write('some_value', 'cache1', 'memcached'); - $result = Cache::clear(true, 'memcached'); - $this->assertTrue($result); - $this->assertEquals('cache1', Cache::read('some_value', 'memcached')); - Cache::write('some_value', 'cache2', 'memcached2'); - $result = Cache::clear(false, 'memcached'); - $this->assertTrue($result); - $this->assertFalse(Cache::read('some_value', 'memcached')); - $this->assertEquals('cache2', Cache::read('some_value', 'memcached2')); + sleep(1); + $this->assertTrue(Cache::clear('memcached')); + + $this->assertNull(Cache::read('some_value', 'memcached')); + $this->assertSame('cache2', Cache::read('some_value', 'memcached2')); - Cache::clear(false, 'memcached2'); + Cache::clear('memcached2'); } /** * test that a 0 duration can successfully write. - * - * @return void */ - public function testZeroDuration() + public function testZeroDuration(): void { $this->_configCache(['duration' => 0]); $result = Cache::write('test_key', 'written!', 'memcached'); $this->assertTrue($result); $result = Cache::read('test_key', 'memcached'); - $this->assertEquals('written!', $result); + $this->assertSame('written!', $result); } /** * Tests that configuring groups for stored keys return the correct values when read/written * Shows that altering the group value is equivalent to deleting all keys under the same * group - * - * @return void */ - public function testGroupReadWrite() + public function testGroupReadWrite(): void { - Cache::config('memcached_groups', [ + Cache::setConfig('memcached_groups', [ 'engine' => 'Memcached', 'duration' => 3600, 'groups' => ['group_a', 'group_b'], - 'prefix' => 'test_' + 'prefix' => 'test_', + 'servers' => ['127.0.0.1:' . $this->port], ]); - Cache::config('memcached_helper', [ + Cache::setConfig('memcached_helper', [ 'engine' => 'Memcached', 'duration' => 3600, - 'prefix' => 'test_' + 'prefix' => 'test_', + 'servers' => ['127.0.0.1:' . $this->port], ]); $this->assertTrue(Cache::write('test_groups', 'value', 'memcached_groups')); - $this->assertEquals('value', Cache::read('test_groups', 'memcached_groups')); + $this->assertSame('value', Cache::read('test_groups', 'memcached_groups')); Cache::increment('group_a', 1, 'memcached_helper'); - $this->assertFalse(Cache::read('test_groups', 'memcached_groups')); + $this->assertNull(Cache::read('test_groups', 'memcached_groups')); $this->assertTrue(Cache::write('test_groups', 'value2', 'memcached_groups')); - $this->assertEquals('value2', Cache::read('test_groups', 'memcached_groups')); + $this->assertSame('value2', Cache::read('test_groups', 'memcached_groups')); Cache::increment('group_b', 1, 'memcached_helper'); - $this->assertFalse(Cache::read('test_groups', 'memcached_groups')); + $this->assertNull(Cache::read('test_groups', 'memcached_groups')); $this->assertTrue(Cache::write('test_groups', 'value3', 'memcached_groups')); - $this->assertEquals('value3', Cache::read('test_groups', 'memcached_groups')); + $this->assertSame('value3', Cache::read('test_groups', 'memcached_groups')); } /** * Tests that deleting from a groups-enabled config is possible - * - * @return void */ - public function testGroupDelete() + public function testGroupDelete(): void { - Cache::config('memcached_groups', [ + Cache::setConfig('memcached_groups', [ 'engine' => 'Memcached', 'duration' => 3600, - 'groups' => ['group_a', 'group_b'] + 'groups' => ['group_a', 'group_b'], + 'servers' => ['127.0.0.1:' . $this->port], ]); $this->assertTrue(Cache::write('test_groups', 'value', 'memcached_groups')); - $this->assertEquals('value', Cache::read('test_groups', 'memcached_groups')); + $this->assertSame('value', Cache::read('test_groups', 'memcached_groups')); $this->assertTrue(Cache::delete('test_groups', 'memcached_groups')); - $this->assertFalse(Cache::read('test_groups', 'memcached_groups')); + $this->assertNull(Cache::read('test_groups', 'memcached_groups')); } /** * Test clearing a cache group - * - * @return void */ - public function testGroupClear() + public function testGroupClear(): void { - Cache::config('memcached_groups', [ + Cache::setConfig('memcached_groups', [ 'engine' => 'Memcached', 'duration' => 3600, - 'groups' => ['group_a', 'group_b'] + 'groups' => ['group_a', 'group_b'], + 'servers' => ['127.0.0.1:' . $this->port], ]); $this->assertTrue(Cache::write('test_groups', 'value', 'memcached_groups')); $this->assertTrue(Cache::clearGroup('group_a', 'memcached_groups')); - $this->assertFalse(Cache::read('test_groups', 'memcached_groups')); + $this->assertNull(Cache::read('test_groups', 'memcached_groups')); $this->assertTrue(Cache::write('test_groups', 'value2', 'memcached_groups')); $this->assertTrue(Cache::clearGroup('group_b', 'memcached_groups')); - $this->assertFalse(Cache::read('test_groups', 'memcached_groups')); + $this->assertNull(Cache::read('test_groups', 'memcached_groups')); } /** * Test add - * - * @return void */ - public function testAdd() + public function testAdd(): void { Cache::delete('test_add_key', 'memcached'); @@ -916,7 +949,7 @@ public function testAdd() $expected = 'test data'; $result = Cache::read('test_add_key', 'memcached'); - $this->assertEquals($expected, $result); + $this->assertSame($expected, $result); $result = Cache::add('test_add_key', 'test data 2', 'memcached'); $this->assertFalse($result); diff --git a/tests/TestCase/Cache/Engine/NullEngineTest.php b/tests/TestCase/Cache/Engine/NullEngineTest.php new file mode 100644 index 00000000000..031a0a98be4 --- /dev/null +++ b/tests/TestCase/Cache/Engine/NullEngineTest.php @@ -0,0 +1,75 @@ + NullEngine::class, + ]); + } + + public function testAdd(): void + { + $result = Cache::add('test_key', 'test_value', 'null'); + $this->assertTrue($result); + } + + public function testReadMany(): void + { + $keys = [ + 'key1', + 'key2', + 'key3', + ]; + + $result1 = Cache::readMany($keys, 'null'); + + $this->assertSame([ + 'key1' => null, + 'key2' => null, + 'key3' => null, + ], $result1); + + $e = new Exception('Cache key not found'); + $result2 = Cache::pool('null')->getMultiple($keys, $e); + + $this->assertSame([ + 'key1' => $e, + 'key2' => $e, + 'key3' => $e, + ], $result2); + } +} diff --git a/tests/TestCase/Cache/Engine/RedisClusterEngineTest.php b/tests/TestCase/Cache/Engine/RedisClusterEngineTest.php new file mode 100644 index 00000000000..bf79ad7f5a8 --- /dev/null +++ b/tests/TestCase/Cache/Engine/RedisClusterEngineTest.php @@ -0,0 +1,600 @@ +skipIf( + !class_exists('RedisCluster'), + 'Redis extension is not installed or configured properly.', + ); + + if ($this->skipTest === null) { + $this->skipTest = false; + $nodes = array_map(function (string $node) { + [$host, $port] = explode(':', $node); + + return ['host' => $host, 'port' => (int)$port]; + }, $this->redisClusterNodes()); + + foreach ($nodes as $node) { + // phpcs:disable + $socket = @fsockopen($node['host'], $node['port'], $errno, $errstr, 1); + // phpcs:enable + + if ($socket === false) { + $this->skipTest = ($this->skipTest === false ? '' : "\n") . + "Connection to Redis node {$node['host']}:{$node['port']} failed: {$errstr} ({$errno})"; + } else { + fclose($socket); + } + } + } + $this->skipIf($this->skipTest !== false, $this->skipTest === false ? 'Not skipping' : $this->skipTest); + + Cache::enable(); + $this->configCache(); + } + + /** + * tearDown method + * + * @return void + */ + public function tearDown(): void + { + Log::drop('default'); + parent::tearDown(); + Cache::drop('redis'); + Cache::drop('redis_groups'); + Cache::drop('redis_helper'); + } + + /** + * Helper method for testing. + * + * @param array $config + * @return void + */ + protected function configCache($config = []): void + { + $defaults = [ + 'className' => 'Redis', + 'nodes' => $this->redisClusterNodes(), + ]; + + Cache::drop('redis'); + Cache::setConfig('redis', array_merge($defaults, $config)); + } + + /** + * Redis cluster nodes + * + * @return string[] + */ + protected function redisClusterNodes(): array + { + $env = getenv('REDIS_CLUSTER_NODES'); + if ($env !== false) { + return explode(',', $env); + } + + return [ + '127.0.0.1:6379', + '127.0.0.1:6380', + ]; + } + + /** + * testConfig method + * + * @return void + */ + public function testConfig(): void + { + $config = Cache::pool('redis')->getConfig(); + $expecting = [ + 'clusterName' => null, + 'groups' => [], + 'password' => null, + 'persistent' => true, + 'prefix' => 'cake_', + 'readTimeout' => 0, + 'timeout' => 0, + 'scanCount' => 10, + 'duration' => 3600, + 'nodes' => $this->redisClusterNodes(), + 'database' => 0, + 'port' => 6379, + 'tls' => false, + 'host' => null, + 'server' => '127.0.0.1', + 'unix_socket' => false, + 'clearUsesFlushDb' => false, + 'failover' => null, + 'allowedClasses' => true, + ]; + $this->assertEquals($expecting, $config); + } + + /** + * testConnect method + * + * @return void + */ + public function testConnect(): void + { + $Redis = new RedisEngine(); + $this->assertTrue($Redis->init(Cache::pool('redis')->getConfig())); + } + + /** + * testConnect method + * + * @return void + */ + public function testConnectNamedClusterWithoutNodes(): void + { + $this->setupLog('error'); + $this->assertFalse((new RedisEngine())->init([ + 'className' => 'Redis', + 'clusterName' => 'mycluster', + ])); + $this->assertLogMessageContains('error', 'RedisEngine requires one or more nodes in cluster mode'); + } + + /** + * Test that a Redis cluster connection failure logs an error + * and returns false from the `init()` method. + * + * This test simulates a RedisCluster connection failure and + * verifies that the expected error message is logged. + * + * @return void + */ + public function testConnectRedisClusterFailureLogsError(): void + { + $mock = new class extends RedisEngine { + public function init(array $config = []): bool + { + // Prevent init logic from running connectCluster, simulate failure instead + Log::error('RedisClusterEngine could not connect. Got error: Mocked cluster failure'); + + return false; + } + }; + + $this->setupLog('error'); + $result = $mock->init([ + 'nodes' => ['127.0.0.1:7000'], + 'persistent' => true, + ]); + $this->assertLogMessageContains('error', 'RedisClusterEngine could not connect. Got error: Mocked cluster failure'); + $this->assertFalse($result); + } + + /** + * testConnectRedisClusterWithTlsConfig method + * + * @return void + */ + public function testConnectRedisClusterWithTlsConfig(): void + { + $mock = Mockery::mock(RedisEngine::class) + ->makePartial(); + + $mock->shouldAllowMockingProtectedMethods() + ->shouldReceive('connectRedisCluster') + ->once() + ->andReturn(true); + + $config = [ + 'nodes' => $this->redisClusterNodes(), + 'tls' => true, + 'ssl_ca' => '/tmp/fake-cert.pem', + 'ssl_key' => '/tmp/fake-key.pem', + 'ssl_cert' => '/tmp/fake-cert.pem', + 'timeout' => 1, + 'readTimeout' => 1, + 'persistent' => true, + ]; + + $this->assertTrue($mock->init($config)); + } + + /** + * testWriteNumbers method + * + * @return void + */ + public function testWriteNumbers(): void + { + Cache::write('test-counter', 1, 'redis'); + $this->assertSame(1, Cache::read('test-counter', 'redis')); + + Cache::write('test-counter', 0, 'redis'); + $this->assertSame(0, Cache::read('test-counter', 'redis')); + + Cache::write('test-counter', -1, 'redis'); + $this->assertSame(-1, Cache::read('test-counter', 'redis')); + } + + /** + * testReadAndWriteCache method + * + * @return void + */ + public function testReadAndWriteCache(): void + { + $this->configCache(['duration' => 1]); + + $result = Cache::read('test', 'redis'); + $expecting = ''; + $this->assertEquals($expecting, $result); + + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('test', $data, 'redis'); + $this->assertTrue($result); + + $result = Cache::read('test', 'redis'); + $expecting = $data; + $this->assertEquals($expecting, $result); + + $data = [1, 2, 3]; + $this->assertTrue(Cache::write('array_data', $data, 'redis')); + $this->assertEquals($data, Cache::read('array_data', 'redis')); + + Cache::delete('test', 'redis'); + } + + /** + * testExpiry method + * + * @return void + */ + public function testExpiry(): void + { + $this->configCache(['duration' => 1]); + + $result = Cache::read('test', 'redis'); + $this->assertNull($result); + + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('other_test', $data, 'redis'); + $this->assertTrue($result); + + sleep(2); + $result = Cache::read('other_test', 'redis'); + $this->assertNull($result); + + $this->configCache(['duration' => '+1 second']); + + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('other_test', $data, 'redis'); + $this->assertTrue($result); + + sleep(2); + $result = Cache::read('other_test', 'redis'); + $this->assertNull($result); + + sleep(2); + + $result = Cache::read('other_test', 'redis'); + $this->assertNull($result); + + $this->configCache(['duration' => '+29 days']); + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('long_expiry_test', $data, 'redis'); + $this->assertTrue($result); + + sleep(2); + $result = Cache::read('long_expiry_test', 'redis'); + $expecting = $data; + $this->assertSame($expecting, $result); + } + + /** + * testDeleteCache method + * + * @return void + */ + public function testDeleteCache(): void + { + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('delete_test', $data, 'redis'); + $this->assertTrue($result); + + $result = Cache::delete('delete_test', 'redis'); + $this->assertTrue($result); + } + + /** + * testDecrement method + * + * @return void + */ + public function testDecrement(): void + { + Cache::delete('test_decrement', 'redis'); + $result = Cache::write('test_decrement', 5, 'redis'); + $this->assertTrue($result); + + $result = Cache::decrement('test_decrement', 1, 'redis'); + $this->assertEquals(4, $result); + + $result = Cache::read('test_decrement', 'redis'); + $this->assertEquals(4, $result); + + $result = Cache::decrement('test_decrement', 2, 'redis'); + $this->assertEquals(2, $result); + + $result = Cache::read('test_decrement', 'redis'); + $this->assertEquals(2, $result); + } + + /** + * testIncrement method + * + * @return void + */ + public function testIncrement(): void + { + Cache::delete('test_increment', 'redis'); + $result = Cache::increment('test_increment', 1, 'redis'); + $this->assertEquals(1, $result); + + $result = Cache::read('test_increment', 'redis'); + $this->assertEquals(1, $result); + + $result = Cache::increment('test_increment', 2, 'redis'); + $this->assertEquals(3, $result); + + $result = Cache::read('test_increment', 'redis'); + $this->assertEquals(3, $result); + } + + /** + * Test that increment() and decrement() can live forever. + * + * @return void + */ + public function testIncrementDecrementForever(): void + { + $this->configCache(['duration' => 0]); + Cache::delete('test_increment', 'redis'); + Cache::delete('test_decrement', 'redis'); + + $result = Cache::increment('test_increment', 1, 'redis'); + $this->assertEquals(1, $result); + + $result = Cache::decrement('test_decrement', 1, 'redis'); + $this->assertEquals(-1, $result); + + $this->assertEquals(1, Cache::read('test_increment', 'redis')); + $this->assertEquals(-1, Cache::read('test_decrement', 'redis')); + } + + /** + * Test that increment and decrement set ttls. + * + * @return void + */ + public function testIncrementDecrementExpiring(): void + { + $this->configCache(['duration' => 1]); + Cache::delete('test_increment', 'redis'); + Cache::delete('test_decrement', 'redis'); + + $this->assertSame(1, Cache::increment('test_increment', 1, 'redis')); + $this->assertSame(-1, Cache::decrement('test_decrement', 1, 'redis')); + + sleep(2); + + $this->assertNull(Cache::read('test_increment', 'redis')); + $this->assertNull(Cache::read('test_decrement', 'redis')); + } + + /** + * test clearing redis. + * + * @return void + */ + public function testClear(): void + { + Cache::setConfig('redis2', [ + 'className' => 'Redis', + 'duration' => 3600, + 'nodes' => $this->redisClusterNodes(), + 'prefix' => 'cake2_', + ]); + + Cache::write('some_value', 'cache1', 'redis'); + $result = Cache::clear('redis'); + $this->assertTrue($result); + $this->assertNull(Cache::read('some_value', 'redis')); + + Cache::write('some_value', 'cache2', 'redis2'); + $result = Cache::clear('redis'); + $this->assertTrue($result); + $this->assertNull(Cache::read('some_value', 'redis')); + $this->assertSame('cache2', Cache::read('some_value', 'redis2')); + + Cache::clear('redis2'); + } + + /** + * testClearBlocking method + */ + public function testClearBlocking(): void + { + Cache::setConfig('redis_clear_blocking', [ + 'className' => 'Redis', + 'duration' => 3600, + 'nodes' => $this->redisClusterNodes(), + 'prefix' => 'cake2_', + ]); + + Cache::write('some_value', 'cache1', 'redis'); + $result = Cache::pool('redis')->clearBlocking(); + $this->assertTrue($result); + $this->assertNull(Cache::read('some_value', 'redis')); + + Cache::write('some_value', 'cache2', 'redis_clear_blocking'); + $result = Cache::pool('redis')->clearBlocking(); + $this->assertTrue($result); + $this->assertNull(Cache::read('some_value', 'redis')); + $this->assertSame('cache2', Cache::read('some_value', 'redis_clear_blocking')); + + Cache::pool('redis_clear_blocking')->clearBlocking(); + } + + /** + * test that a 0 duration can successfully write. + * + * @return void + */ + public function testZeroDuration(): void + { + $this->configCache(['duration' => 0]); + $result = Cache::write('test_key', 'written!', 'redis'); + + $this->assertTrue($result); + $result = Cache::read('test_key', 'redis'); + $this->assertEquals('written!', $result); + } + + /** + * Tests that configuring groups for stored keys return the correct values when read/written + * Shows that altering the group value is equivalent to deleting all keys under the same + * group + * + * @return void + */ + public function testGroupReadWrite(): void + { + Cache::setConfig('redis_groups', [ + 'className' => 'Redis', + 'groups' => ['group_a', 'group_b'], + 'nodes' => $this->redisClusterNodes(), + 'prefix' => 'test_', + 'password' => null, + ]); + Cache::setConfig('redis_helper', [ + 'className' => 'Redis', + 'nodes' => $this->redisClusterNodes(), + 'prefix' => 'test_', + 'password' => null, + ]); + $this->assertTrue(Cache::write('test_groups', 'value', 'redis_groups')); + $this->assertSame('value', Cache::read('test_groups', 'redis_groups')); + + Cache::increment('group_a', 1, 'redis_helper'); + $this->assertNull(Cache::read('test_groups', 'redis_groups')); + $this->assertTrue(Cache::write('test_groups', 'value2', 'redis_groups')); + $this->assertSame('value2', Cache::read('test_groups', 'redis_groups')); + + Cache::increment('group_b', 1, 'redis_helper'); + $this->assertNull(Cache::read('test_groups', 'redis_groups')); + $this->assertTrue(Cache::write('test_groups', 'value3', 'redis_groups')); + $this->assertSame('value3', Cache::read('test_groups', 'redis_groups')); + } + + /** + * Tests that deleting from a groups-enabled config is possible + * + * @return void + */ + public function testGroupDelete(): void + { + Cache::setConfig('redis_groups', [ + 'className' => 'Redis', + 'groups' => ['group_a', 'group_b'], + 'nodes' => $this->redisClusterNodes(), + 'password' => null, + ]); + $this->assertTrue(Cache::write('test_groups', 'value', 'redis_groups')); + $this->assertEquals('value', Cache::read('test_groups', 'redis_groups')); + $this->assertTrue(Cache::delete('test_groups', 'redis_groups')); + + $this->assertNull(Cache::read('test_groups', 'redis_groups')); + } + + /** + * Test clearing a cache group + * + * @return void + */ + public function testGroupClear(): void + { + Cache::setConfig('redis_groups', [ + 'className' => 'Redis', + 'groups' => ['group_a', 'group_b'], + 'nodes' => $this->redisClusterNodes(), + 'password' => null, + ]); + + $this->assertTrue(Cache::write('test_groups', 'value', 'redis_groups')); + $this->assertTrue(Cache::clearGroup('group_a', 'redis_groups')); + $this->assertNull(Cache::read('test_groups', 'redis_groups')); + + $this->assertTrue(Cache::write('test_groups', 'value2', 'redis_groups')); + $this->assertTrue(Cache::clearGroup('group_b', 'redis_groups')); + $this->assertNull(Cache::read('test_groups', 'redis_groups')); + } + + /** + * Test add + * + * @return void + */ + public function testAdd(): void + { + Cache::delete('test_add_key', 'redis'); + + $result = Cache::add('test_add_key', 'test data', 'redis'); + $this->assertTrue($result); + + $expected = 'test data'; + $result = Cache::read('test_add_key', 'redis'); + $this->assertEquals($expected, $result); + + $result = Cache::add('test_add_key', 'test data 2', 'redis'); + $this->assertFalse($result); + } + + /** + * Test has + */ + public function testHas(): void + { + $redis = Cache::pool('redis'); + $this->assertFalse($redis->has('nope')); + + $redis->set('yep', 0); + $this->assertTrue($redis->has('yep')); + } +} diff --git a/tests/TestCase/Cache/Engine/RedisEngineTest.php b/tests/TestCase/Cache/Engine/RedisEngineTest.php index 6673b955413..1d0b9c5d66e 100644 --- a/tests/TestCase/Cache/Engine/RedisEngineTest.php +++ b/tests/TestCase/Cache/Engine/RedisEngineTest.php @@ -1,4 +1,6 @@ skipIf(!class_exists('Redis'), 'Redis extension is not installed or configured properly.'); - $this->skipIf(version_compare(PHP_VERSION, '7.2.0dev', '>='), 'Redis is misbehaving in PHP7.2'); - // @codingStandardsIgnoreStart - $socket = @fsockopen('127.0.0.1', 6379, $errno, $errstr, 1); - // @codingStandardsIgnoreEnd - $this->skipIf(!$socket, 'Redis is not running.'); - fclose($socket); + $this->port = env('REDIS_PORT', $this->port); + + if ($this->skipTest === null) { + // phpcs:disable + $socket = @fsockopen('127.0.0.1', (int)$this->port, $errno, $errstr, 1); + // phpcs:enable + + $this->skipTest = $socket === false; + + if ($socket !== false) { + fclose($socket); + } + } + + $this->skipIf($this->skipTest, 'Redis is not running.'); Cache::enable(); $this->_configCache(); @@ -47,13 +70,14 @@ public function setUp() /** * tearDown method - * - * @return void */ - public function tearDown() + protected function tearDown(): void { parent::tearDown(); Cache::drop('redis'); + Cache::drop('redis2'); + Cache::drop('redis_clear_blocking'); + Cache::drop('redis_dsn'); Cache::drop('redis_groups'); Cache::drop('redis_helper'); } @@ -62,63 +86,67 @@ public function tearDown() * Helper method for testing. * * @param array $config - * @return void */ - protected function _configCache($config = []) + protected function _configCache($config = []): void { $defaults = [ 'className' => 'Redis', 'prefix' => 'cake_', - 'duration' => 3600 + 'duration' => 3600, + 'port' => $this->port, ]; + $this->engine = 'redis'; Cache::drop('redis'); - Cache::config('redis', array_merge($defaults, $config)); + Cache::setConfig('redis', array_merge($defaults, $config)); } /** * testConfig method - * - * @return void */ - public function testConfig() + public function testConfig(): void { - $config = Cache::engine('redis')->config(); + $config = Cache::pool('redis')->getConfig(); $expecting = [ 'prefix' => 'cake_', 'duration' => 3600, - 'probability' => 100, 'groups' => [], 'server' => '127.0.0.1', - 'port' => 6379, + 'port' => $this->port, + 'tls' => false, 'timeout' => 0, 'persistent' => true, 'password' => false, 'database' => 0, 'unix_socket' => false, 'host' => null, + 'scanCount' => 10, + 'readTimeout' => 0, + 'clusterName' => null, + 'nodes' => [], + 'clearUsesFlushDb' => false, + 'failover' => null, + 'allowedClasses' => true, ]; $this->assertEquals($expecting, $config); } /** * testConfigDsn method - * - * @return void */ - public function testConfigDsn() + public function testConfigDsn(): void { - Cache::config('redis_dsn', [ - 'url' => 'redis://localhost:6379?database=1&prefix=redis_' + Cache::setConfig('redis_dsn', [ + 'url' => 'redis://localhost:' . $this->port . '?database=1&prefix=redis_', ]); - $config = Cache::engine('redis_dsn')->config(); + $config = Cache::pool('redis_dsn')->getConfig(); $expecting = [ 'prefix' => 'redis_', 'duration' => 3600, - 'probability' => 100, 'groups' => [], 'server' => 'localhost', - 'port' => 6379, + 'port' => $this->port, + 'tls' => false, 'timeout' => 0, 'persistent' => true, 'password' => false, @@ -126,43 +154,319 @@ public function testConfigDsn() 'unix_socket' => false, 'host' => 'localhost', 'scheme' => 'redis', + 'scanCount' => 10, + 'readTimeout' => 0, + 'clusterName' => null, + 'nodes' => [], + 'clearUsesFlushDb' => false, + 'failover' => null, + 'allowedClasses' => true, ]; $this->assertEquals($expecting, $config); + } - Cache::drop('redis_dsn'); + /** + * testConfigDsnSSLContext method + */ + public function testConfigDsnSSLContext(): void + { + $url = 'redis://localhost:' . $this->port; + + $url .= '?ssl_ca=/tmp/cert.crt'; + $url .= '&ssl_key=/tmp/local.key'; + $url .= '&ssl_cert=/tmp/local.crt'; + + Cache::setConfig('redis_dsn', compact('url')); + + $config = Cache::pool('redis_dsn')->getConfig(); + $expecting = [ + 'prefix' => 'cake_', + 'duration' => 3600, + 'groups' => [], + 'server' => 'localhost', + 'port' => $this->port, + 'tls' => false, + 'timeout' => 0, + 'persistent' => true, + 'password' => false, + 'database' => 0, + 'unix_socket' => false, + 'host' => 'localhost', + 'scheme' => 'redis', + 'scanCount' => 10, + 'ssl_ca' => '/tmp/cert.crt', + 'ssl_key' => '/tmp/local.key', + 'ssl_cert' => '/tmp/local.crt', + 'readTimeout' => 0, + 'clusterName' => null, + 'nodes' => [], + 'clearUsesFlushDb' => false, + 'failover' => null, + 'allowedClasses' => true, + ]; + $this->assertEquals($expecting, $config); } /** * testConnect method - * - * @return void */ - public function testConnect() + public function testConnect(): void { $Redis = new RedisEngine(); - $this->assertTrue($Redis->init(Cache::engine('redis')->config())); + $this->assertTrue($Redis->init(Cache::pool('redis')->getConfig())); + } + + /** + * testConnectTransient method + */ + public function testConnectTransient(): void + { + $Redis = Mockery::mock(RedisEngine::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $phpredis = Mockery::mock(Redis::class); + + $phpredis->shouldReceive('select') + ->once() + ->with((int)$Redis->getConfig('database')) + ->andReturn(true); + + $phpredis->shouldReceive('connect') + ->once() + ->with( + $Redis->getConfig('server'), + (int)$this->port, + (int)$Redis->getConfig('timeout'), + ) + ->andReturn(true); + + $Redis->shouldReceive('_createRedisInstance') + ->once() + ->andReturn($phpredis); + + $config = [ + 'port' => $this->port, + 'persistent' => false, + ]; + $this->assertTrue($Redis->init($config + Cache::pool('redis')->getConfig())); + + $Redis = Mockery::mock(RedisEngine::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $phpredis = Mockery::mock(Redis::class); + + $phpredis->shouldReceive('select') + ->once() + ->with((int)$Redis->getConfig('database')) + ->andReturn(true); + + $phpredis->shouldReceive('connect') + ->once() + ->with( + 'tls://' . $Redis->getConfig('server'), + (int)$this->port, + (int)$Redis->getConfig('timeout'), + ) + ->andReturn(true); + + $Redis->shouldReceive('_createRedisInstance') + ->once() + ->andReturn($phpredis); + + $config = [ + 'port' => $this->port, + 'persistent' => false, + 'tls' => true, + ]; + $this->assertTrue($Redis->init($config + Cache::pool('redis')->getConfig())); + } + + /** + * testConnectTransientContext method + */ + public function testConnectTransientContext(): void + { + $Redis = Mockery::mock(RedisEngine::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $phpredis = Mockery::mock(Redis::class); + + $cafile = ROOT . DS . 'vendor' . DS . 'composer' . DS . 'ca-bundle' . DS . 'res' . DS . 'cacert.pem'; + + $context = [ + 'ssl' => [ + 'cafile' => $cafile, + ], + ]; + + $phpredis->shouldReceive('select') + ->once() + ->with((int)$Redis->getConfig('database')) + ->andReturn(true); + + $phpredis->shouldReceive('connect') + ->once() + ->with( + $Redis->getConfig('server'), + (int)$this->port, + (int)$Redis->getConfig('timeout'), + null, + 0, + 0.0, + $context, + ) + ->andReturn(true); + + $Redis->shouldReceive('_createRedisInstance') + ->once() + ->andReturn($phpredis); + + $config = [ + 'port' => $this->port, + 'persistent' => false, + 'ssl_ca' => $cafile, + ]; + + $this->assertTrue($Redis->init($config + Cache::pool('redis')->getConfig())); + } + + /** + * testConnectPersistent method + */ + public function testConnectPersistent(): void + { + $Redis = Mockery::mock(RedisEngine::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $phpredis = Mockery::mock(Redis::class); + + $expectedPersistentId = $this->port . $Redis->getConfig('timeout') . $Redis->getConfig('database'); + + $phpredis->shouldReceive('select') + ->once() + ->with((int)$Redis->getConfig('database')) + ->andReturn(true); + + $phpredis->shouldReceive('pconnect') + ->once() + ->with( + $Redis->getConfig('server'), + (int)$this->port, + (int)$Redis->getConfig('timeout'), + $expectedPersistentId, + ) + ->andReturn(true); + + $Redis->shouldReceive('_createRedisInstance') + ->once() + ->andReturn($phpredis); + + $config = [ + 'port' => $this->port, + ]; + $this->assertTrue($Redis->init($config + Cache::pool('redis')->getConfig())); + + $Redis = Mockery::mock(RedisEngine::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $phpredis = Mockery::mock(Redis::class); + + $phpredis->shouldReceive('select') + ->once() + ->with((int)$Redis->getConfig('database')) + ->andReturn(true); + + $phpredis->shouldReceive('pconnect') + ->once() + ->with( + 'tls://' . $Redis->getConfig('server'), + (int)$this->port, + (int)$Redis->getConfig('timeout'), + $expectedPersistentId, + ) + ->andReturn(true); + + $Redis->shouldReceive('_createRedisInstance') + ->once() + ->andReturn($phpredis); + + $config = [ + 'port' => $this->port, + 'tls' => true, + ]; + $this->assertTrue($Redis->init($config + Cache::pool('redis')->getConfig())); + } + + /** + * testConnectPersistentContext method + */ + public function testConnectPersistentContext(): void + { + $Redis = Mockery::mock(RedisEngine::class) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $phpredis = Mockery::mock(Redis::class); + + $expectedPersistentId = $this->port . $Redis->getConfig('timeout') . $Redis->getConfig('database'); + + $cafile = ROOT . DS . 'vendor' . DS . 'composer' . DS . 'ca-bundle' . DS . 'res' . DS . 'cacert.pem'; + + $context = [ + 'ssl' => [ + 'cafile' => $cafile, + ], + ]; + + $phpredis->shouldReceive('select') + ->once() + ->with((int)$Redis->getConfig('database')) + ->andReturn(true); + + $phpredis->shouldReceive('pconnect') + ->once() + ->with( + $Redis->getConfig('server'), + (int)$this->port, + (int)$Redis->getConfig('timeout'), + $expectedPersistentId, + 0, + 0.0, + $context, + ) + ->andReturn(true); + + $Redis->shouldReceive('_createRedisInstance') + ->once() + ->andReturn($phpredis); + + $config = [ + 'port' => $this->port, + 'persistent' => true, + 'ssl_ca' => $cafile, + ]; + $this->assertTrue($Redis->init($config + Cache::pool('redis')->getConfig())); } /** * testMultiDatabaseOperations method - * - * @return void */ - public function testMultiDatabaseOperations() + public function testMultiDatabaseOperations(): void { - Cache::config('redisdb0', [ + Cache::setConfig('redisdb0', [ 'engine' => 'Redis', 'prefix' => 'cake2_', 'duration' => 3600, 'persistent' => false, + 'port' => $this->port, ]); - Cache::config('redisdb1', [ + Cache::setConfig('redisdb1', [ 'engine' => 'Redis', 'database' => 1, 'prefix' => 'cake2_', 'duration' => 3600, 'persistent' => false, + 'port' => $this->port, ]); $result = Cache::write('save_in_0', true, 'redisdb0'); @@ -173,17 +477,17 @@ public function testMultiDatabaseOperations() $result = Cache::write('save_in_1', true, 'redisdb1'); $this->assertTrue($result); $exist = Cache::read('save_in_0', 'redisdb1'); - $this->assertFalse($exist); + $this->assertNull($exist); $exist = Cache::read('save_in_1', 'redisdb1'); $this->assertTrue($exist); Cache::delete('save_in_0', 'redisdb0'); $exist = Cache::read('save_in_0', 'redisdb0'); - $this->assertFalse($exist); + $this->assertNull($exist); Cache::delete('save_in_1', 'redisdb1'); $exist = Cache::read('save_in_1', 'redisdb1'); - $this->assertFalse($exist); + $this->assertNull($exist); Cache::drop('redisdb0'); Cache::drop('redisdb1'); @@ -191,60 +495,165 @@ public function testMultiDatabaseOperations() /** * test write numbers method - * - * @return void */ - public function testWriteNumbers() + public function testWriteNumbers(): void { - $result = Cache::write('test-counter', 1, 'redis'); + Cache::write('test-counter', 1, 'redis'); $this->assertSame(1, Cache::read('test-counter', 'redis')); - $result = Cache::write('test-counter', 0, 'redis'); + Cache::write('test-counter', 0, 'redis'); $this->assertSame(0, Cache::read('test-counter', 'redis')); - $result = Cache::write('test-counter', -1, 'redis'); + Cache::write('test-counter', -1, 'redis'); $this->assertSame(-1, Cache::read('test-counter', 'redis')); } /** * testReadAndWriteCache method - * - * @return void */ - public function testReadAndWriteCache() + public function testReadAndWriteCache(): void { $this->_configCache(['duration' => 1]); $result = Cache::read('test', 'redis'); - $expecting = ''; - $this->assertEquals($expecting, $result); + $this->assertNull($result); $data = 'this is a test of the emergency broadcasting system'; $result = Cache::write('test', $data, 'redis'); $this->assertTrue($result); $result = Cache::read('test', 'redis'); - $expecting = $data; - $this->assertEquals($expecting, $result); + $this->assertSame($data, $result); $data = [1, 2, 3]; $this->assertTrue(Cache::write('array_data', $data, 'redis')); $this->assertEquals($data, Cache::read('array_data', 'redis')); + $result = Cache::write('test', false, 'redis'); + $this->assertTrue($result); + + $result = Cache::read('test', 'redis'); + $this->assertFalse($result); + + $result = Cache::write('test', null, 'redis'); + $this->assertTrue($result); + + $result = Cache::read('test', 'redis'); + $this->assertNull($result); + Cache::delete('test', 'redis'); } + /** + * Objects are unserialized normally with the default `allowedClasses` of `true`. + */ + public function testAllowedClassesDefaultAllowsObjects(): void + { + $this->_configCache(); + + $data = new stdClass(); + $data->foo = 'bar'; + $this->assertTrue(Cache::write('object', $data, 'redis')); + + $result = Cache::read('object', 'redis'); + $this->assertInstanceOf(stdClass::class, $result); + $this->assertSame('bar', $result->foo); + + Cache::delete('object', 'redis'); + } + + /** + * Setting `allowedClasses` to `false` blocks object unserialization, + * yielding an incomplete class instance instead of the original object. + */ + public function testAllowedClassesFalseBlocksObjects(): void + { + $this->_configCache(['allowedClasses' => false]); + + $data = new stdClass(); + $data->foo = 'bar'; + $this->assertTrue(Cache::write('object', $data, 'redis')); + + $result = Cache::read('object', 'redis'); + $this->assertInstanceOf(__PHP_Incomplete_Class::class, $result); + + Cache::delete('object', 'redis'); + } + + /** + * An array of `allowedClasses` permits only the listed classes; others + * are returned as incomplete class instances. + */ + public function testAllowedClassesWhitelist(): void + { + $this->_configCache(['allowedClasses' => [stdClass::class]]); + + $allowed = new stdClass(); + $allowed->foo = 'bar'; + $this->assertTrue(Cache::write('allowed', $allowed, 'redis')); + $this->assertInstanceOf(stdClass::class, Cache::read('allowed', 'redis')); + + $blocked = new DateInterval('PT1S'); + $this->assertTrue(Cache::write('blocked', $blocked, 'redis')); + $this->assertInstanceOf(__PHP_Incomplete_Class::class, Cache::read('blocked', 'redis')); + + Cache::delete('allowed', 'redis'); + Cache::delete('blocked', 'redis'); + } + + /** + * Integers keep round-tripping correctly regardless of `allowedClasses`. + */ + public function testAllowedClassesPreservesScalars(): void + { + $this->_configCache(['allowedClasses' => false]); + + $this->assertTrue(Cache::write('int', 42, 'redis')); + $this->assertSame(42, Cache::read('int', 'redis')); + + $this->assertTrue(Cache::write('array', ['a' => 1, 'b' => 2], 'redis')); + $this->assertSame(['a' => 1, 'b' => 2], Cache::read('array', 'redis')); + + Cache::delete('int', 'redis'); + Cache::delete('array', 'redis'); + } + + /** + * Test get with default value + */ + public function testGetDefaultValue(): void + { + $redis = Cache::pool('redis'); + $this->assertFalse($redis->get('nope', false)); + $this->assertNull($redis->get('nope', null)); + $this->assertTrue($redis->get('nope', true)); + $this->assertSame(0, $redis->get('nope', 0)); + + $redis->set('yep', 0); + $this->assertSame(0, $redis->get('yep', false)); + } + + /** + * Test has + */ + public function testHas(): void + { + $redis = Cache::pool('redis'); + $this->assertFalse($redis->has('nope')); + + $redis->set('yep', 0); + $this->assertTrue($redis->has('yep')); + } + /** * testExpiry method - * - * @return void */ - public function testExpiry() + public function testExpiry(): void { $this->_configCache(['duration' => 1]); $result = Cache::read('test', 'redis'); - $this->assertFalse($result); + $this->assertNull($result); $data = 'this is a test of the emergency broadcasting system'; $result = Cache::write('other_test', $data, 'redis'); @@ -252,7 +661,7 @@ public function testExpiry() sleep(2); $result = Cache::read('other_test', 'redis'); - $this->assertFalse($result); + $this->assertNull($result); $this->_configCache(['duration' => '+1 second']); @@ -262,12 +671,12 @@ public function testExpiry() sleep(2); $result = Cache::read('other_test', 'redis'); - $this->assertFalse($result); + $this->assertNull($result); sleep(2); $result = Cache::read('other_test', 'redis'); - $this->assertFalse($result); + $this->assertNull($result); $this->_configCache(['duration' => '+29 days']); $data = 'this is a test of the emergency broadcasting system'; @@ -277,15 +686,33 @@ public function testExpiry() sleep(2); $result = Cache::read('long_expiry_test', 'redis'); $expecting = $data; - $this->assertEquals($expecting, $result); + $this->assertSame($expecting, $result); + } + + /** + * test set ttl parameter + */ + public function testSetWithTtl(): void + { + $this->_configCache(['duration' => 99]); + $engine = Cache::pool('redis'); + $this->assertNull($engine->get('test')); + + $data = 'this is a test of the emergency broadcasting system'; + $this->assertTrue($engine->set('default_ttl', $data)); + $this->assertTrue($engine->set('int_ttl', $data, 1)); + $this->assertTrue($engine->set('interval_ttl', $data, new DateInterval('PT1S'))); + + sleep(2); + $this->assertNull($engine->get('int_ttl')); + $this->assertNull($engine->get('interval_ttl')); + $this->assertSame($data, $engine->get('default_ttl')); } /** * testDeleteCache method - * - * @return void */ - public function testDeleteCache() + public function testDeleteCache(): void { $data = 'this is a test of the emergency broadcasting system'; $result = Cache::write('delete_test', $data, 'redis'); @@ -295,78 +722,102 @@ public function testDeleteCache() $this->assertTrue($result); } + /** + * testDeleteCacheAsync method + */ + public function testDeleteCacheAsync(): void + { + $data = 'this is a test of the emergency broadcasting system'; + $result = Cache::write('delete_async_test', $data, 'redis'); + $this->assertTrue($result); + + $result = Cache::pool('redis')->deleteAsync('delete_async_test'); + $this->assertTrue($result); + } + /** * testDecrement method - * - * @return void */ - public function testDecrement() + public function testDecrement(): void { Cache::delete('test_decrement', 'redis'); $result = Cache::write('test_decrement', 5, 'redis'); $this->assertTrue($result); $result = Cache::decrement('test_decrement', 1, 'redis'); - $this->assertEquals(4, $result); + $this->assertSame(4, $result); $result = Cache::read('test_decrement', 'redis'); - $this->assertEquals(4, $result); + $this->assertSame(4, $result); $result = Cache::decrement('test_decrement', 2, 'redis'); - $this->assertEquals(2, $result); + $this->assertSame(2, $result); $result = Cache::read('test_decrement', 'redis'); - $this->assertEquals(2, $result); + $this->assertSame(2, $result); } /** * testIncrement method - * - * @return void */ - public function testIncrement() + public function testIncrement(): void { Cache::delete('test_increment', 'redis'); $result = Cache::increment('test_increment', 1, 'redis'); - $this->assertEquals(1, $result); + $this->assertSame(1, $result); + + $result = Cache::read('test_increment', 'redis'); + $this->assertSame(1, $result); + + $result = Cache::increment('test_increment', 2, 'redis'); + $this->assertSame(3, $result); + + $result = Cache::read('test_increment', 'redis'); + $this->assertSame(3, $result); + } + + /** + * testIncrementAfterWrite method + */ + public function testIncrementAfterWrite(): void + { + Cache::delete('test_increment', 'redis'); + $result = Cache::write('test_increment', 1, 'redis'); + $this->assertTrue($result); $result = Cache::read('test_increment', 'redis'); - $this->assertEquals(1, $result); + $this->assertSame(1, $result); $result = Cache::increment('test_increment', 2, 'redis'); - $this->assertEquals(3, $result); + $this->assertSame(3, $result); $result = Cache::read('test_increment', 'redis'); - $this->assertEquals(3, $result); + $this->assertSame(3, $result); } /** * Test that increment() and decrement() can live forever. - * - * @return void */ - public function testIncrementDecrementForvever() + public function testIncrementDecrementForvever(): void { $this->_configCache(['duration' => 0]); Cache::delete('test_increment', 'redis'); Cache::delete('test_decrement', 'redis'); $result = Cache::increment('test_increment', 1, 'redis'); - $this->assertEquals(1, $result); + $this->assertSame(1, $result); $result = Cache::decrement('test_decrement', 1, 'redis'); - $this->assertEquals(-1, $result); + $this->assertSame(-1, $result); - $this->assertEquals(1, Cache::read('test_increment', 'redis')); - $this->assertEquals(-1, Cache::read('test_decrement', 'redis')); + $this->assertSame(1, Cache::read('test_increment', 'redis')); + $this->assertSame(-1, Cache::read('test_decrement', 'redis')); } /** * Test that increment and decrement set ttls. - * - * @return void */ - public function testIncrementDecrementExpiring() + public function testIncrementDecrementExpiring(): void { $this->_configCache(['duration' => 1]); Cache::delete('test_increment', 'redis'); @@ -377,133 +828,204 @@ public function testIncrementDecrementExpiring() sleep(2); - $this->assertFalse(Cache::read('test_increment', 'redis')); - $this->assertFalse(Cache::read('test_decrement', 'redis')); + $this->assertNull(Cache::read('test_increment', 'redis')); + $this->assertNull(Cache::read('test_decrement', 'redis')); } /** * test clearing redis. - * - * @return void */ - public function testClear() + public function testClear(): void { - Cache::config('redis2', [ + Cache::setConfig('redis2', [ 'engine' => 'Redis', 'prefix' => 'cake2_', - 'duration' => 3600 + 'duration' => 3600, + 'port' => $this->port, ]); Cache::write('some_value', 'cache1', 'redis'); - $result = Cache::clear(true, 'redis'); + $result = Cache::clear('redis'); $this->assertTrue($result); - $this->assertEquals('cache1', Cache::read('some_value', 'redis')); + $this->assertNull(Cache::read('some_value', 'redis')); Cache::write('some_value', 'cache2', 'redis2'); - $result = Cache::clear(false, 'redis'); + $result = Cache::clear('redis'); $this->assertTrue($result); - $this->assertFalse(Cache::read('some_value', 'redis')); - $this->assertEquals('cache2', Cache::read('some_value', 'redis2')); + $this->assertNull(Cache::read('some_value', 'redis')); + $this->assertSame('cache2', Cache::read('some_value', 'redis2')); - Cache::clear(false, 'redis2'); + Cache::clear('redis2'); + } + + /** + * test clearing redis. + */ + public function testClearWithFlush(): void + { + Cache::setConfig('redis2', [ + 'engine' => 'Redis', + 'prefix' => 'cake2_', + 'duration' => 3600, + 'port' => $this->port, + 'clearUsesFlushDb' => true, + ]); + + Cache::write('some_value', 'cache1', 'redis2'); + $result = Cache::clear('redis2'); + $this->assertTrue($result); + $this->assertNull(Cache::read('some_value', 'redis2')); + + Cache::write('some_value', 'cache2', 'redis'); + $result = Cache::clear('redis2'); + $this->assertTrue($result); + + // Both cache prefixes are cleared + $this->assertNull(Cache::read('some_value', 'redis')); + $this->assertNull(Cache::read('some_value', 'redis2')); + } + + /** + * testClearBlocking method + */ + public function testClearBlocking(): void + { + Cache::setConfig('redis_clear_blocking', [ + 'engine' => 'Redis', + 'prefix' => 'cake2_', + 'duration' => 3600, + 'port' => $this->port, + ]); + + Cache::write('some_value', 'cache1', 'redis_clear_blocking'); + $result = Cache::pool('redis_clear_blocking')->clearBlocking(); + $this->assertTrue($result); + $this->assertNull(Cache::read('some_value', 'redis_clear_blocking')); + + Cache::write('some_value', 'cache2', 'redis'); + $result = Cache::pool('redis_clear_blocking')->clearBlocking(); + $this->assertTrue($result); + $this->assertSame('cache2', Cache::read('some_value', 'redis')); + $this->assertNull(Cache::read('some_value', 'redis_clear_blocking')); + } + + /** + * testClearBlocking method + */ + public function testClearBlockingWithFlush(): void + { + Cache::setConfig('redis_clear_blocking', [ + 'engine' => 'Redis', + 'prefix' => 'cake2_', + 'duration' => 3600, + 'port' => $this->port, + 'clearUsesFlushDb' => true, + ]); + + Cache::write('some_value', 'cache1', 'redis'); + $result = Cache::pool('redis_clear_blocking')->clearBlocking(); + $this->assertTrue($result); + $this->assertNull(Cache::read('some_value', 'redis')); + + Cache::write('some_value', 'cache2', 'redis_clear_blocking'); + $result = Cache::pool('redis_clear_blocking')->clearBlocking(); + $this->assertTrue($result); + + // Both cache prefixes are cleared + $this->assertNull(Cache::read('some_value', 'redis')); + $this->assertNull(Cache::read('some_value', 'redis_clear_blocking')); } /** * test that a 0 duration can successfully write. - * - * @return void */ - public function testZeroDuration() + public function testZeroDuration(): void { $this->_configCache(['duration' => 0]); $result = Cache::write('test_key', 'written!', 'redis'); $this->assertTrue($result); $result = Cache::read('test_key', 'redis'); - $this->assertEquals('written!', $result); + $this->assertSame('written!', $result); } /** * Tests that configuring groups for stored keys return the correct values when read/written * Shows that altering the group value is equivalent to deleting all keys under the same * group - * - * @return void */ - public function testGroupReadWrite() + public function testGroupReadWrite(): void { - Cache::config('redis_groups', [ + Cache::setConfig('redis_groups', [ 'engine' => 'Redis', 'duration' => 3600, 'groups' => ['group_a', 'group_b'], - 'prefix' => 'test_' + 'prefix' => 'test_', + 'port' => $this->port, ]); - Cache::config('redis_helper', [ + Cache::setConfig('redis_helper', [ 'engine' => 'Redis', 'duration' => 3600, - 'prefix' => 'test_' + 'prefix' => 'test_', + 'port' => $this->port, ]); $this->assertTrue(Cache::write('test_groups', 'value', 'redis_groups')); - $this->assertEquals('value', Cache::read('test_groups', 'redis_groups')); + $this->assertSame('value', Cache::read('test_groups', 'redis_groups')); Cache::increment('group_a', 1, 'redis_helper'); - $this->assertFalse(Cache::read('test_groups', 'redis_groups')); + $this->assertNull(Cache::read('test_groups', 'redis_groups')); $this->assertTrue(Cache::write('test_groups', 'value2', 'redis_groups')); - $this->assertEquals('value2', Cache::read('test_groups', 'redis_groups')); + $this->assertSame('value2', Cache::read('test_groups', 'redis_groups')); Cache::increment('group_b', 1, 'redis_helper'); - $this->assertFalse(Cache::read('test_groups', 'redis_groups')); + $this->assertNull(Cache::read('test_groups', 'redis_groups')); $this->assertTrue(Cache::write('test_groups', 'value3', 'redis_groups')); - $this->assertEquals('value3', Cache::read('test_groups', 'redis_groups')); + $this->assertSame('value3', Cache::read('test_groups', 'redis_groups')); } /** * Tests that deleting from a groups-enabled config is possible - * - * @return void */ - public function testGroupDelete() + public function testGroupDelete(): void { - Cache::config('redis_groups', [ + Cache::setConfig('redis_groups', [ 'engine' => 'Redis', 'duration' => 3600, - 'groups' => ['group_a', 'group_b'] + 'groups' => ['group_a', 'group_b'], + 'port' => $this->port, ]); $this->assertTrue(Cache::write('test_groups', 'value', 'redis_groups')); - $this->assertEquals('value', Cache::read('test_groups', 'redis_groups')); + $this->assertSame('value', Cache::read('test_groups', 'redis_groups')); $this->assertTrue(Cache::delete('test_groups', 'redis_groups')); - $this->assertFalse(Cache::read('test_groups', 'redis_groups')); + $this->assertNull(Cache::read('test_groups', 'redis_groups')); } /** * Test clearing a cache group - * - * @return void */ - public function testGroupClear() + public function testGroupClear(): void { - Cache::config('redis_groups', [ + Cache::setConfig('redis_groups', [ 'engine' => 'Redis', 'duration' => 3600, - 'groups' => ['group_a', 'group_b'] + 'groups' => ['group_a', 'group_b'], + 'port' => $this->port, ]); $this->assertTrue(Cache::write('test_groups', 'value', 'redis_groups')); $this->assertTrue(Cache::clearGroup('group_a', 'redis_groups')); - $this->assertFalse(Cache::read('test_groups', 'redis_groups')); + $this->assertNull(Cache::read('test_groups', 'redis_groups')); $this->assertTrue(Cache::write('test_groups', 'value2', 'redis_groups')); $this->assertTrue(Cache::clearGroup('group_b', 'redis_groups')); - $this->assertFalse(Cache::read('test_groups', 'redis_groups')); + $this->assertNull(Cache::read('test_groups', 'redis_groups')); } /** * Test add - * - * @return void */ - public function testAdd() + public function testAdd(): void { Cache::delete('test_add_key', 'redis'); @@ -512,7 +1034,7 @@ public function testAdd() $expected = 'test data'; $result = Cache::read('test_add_key', 'redis'); - $this->assertEquals($expected, $result); + $this->assertSame($expected, $result); $result = Cache::add('test_add_key', 'test data 2', 'redis'); $this->assertFalse($result); diff --git a/tests/TestCase/Cache/Engine/WincacheEngineTest.php b/tests/TestCase/Cache/Engine/WincacheEngineTest.php deleted file mode 100644 index d338e8abde4..00000000000 --- a/tests/TestCase/Cache/Engine/WincacheEngineTest.php +++ /dev/null @@ -1,279 +0,0 @@ -skipIf(!function_exists('wincache_ucache_set'), 'Wincache is not installed or configured properly.'); - $this->skipIf(!ini_get('wincache.enablecli'), 'Wincache is not enabled on the CLI.'); - Cache::enable(); - $this->_configCache(); - } - - /** - * tearDown method - * - * @return void - */ - public function tearDown() - { - parent::tearDown(); - Cache::drop('wincache'); - Cache::drop('wincache_groups'); - } - - /** - * Helper method for testing. - * - * @param array $config - * @return void - */ - protected function _configCache($config = []) - { - $defaults = [ - 'className' => 'Wincache', - 'prefix' => 'cake_' - ]; - Cache::drop('wincache'); - Cache::config('wincache', array_merge($defaults, $config)); - } - - /** - * testReadAndWriteCache method - * - * @return void - */ - public function testReadAndWriteCache() - { - $this->_configCache(['duration' => 1]); - - $result = Cache::read('test', 'wincache'); - $expecting = ''; - $this->assertEquals($expecting, $result); - - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('test', $data, 'wincache'); - $this->assertTrue($result); - - $result = Cache::read('test', 'wincache'); - $expecting = $data; - $this->assertEquals($expecting, $result); - - Cache::delete('test', 'wincache'); - } - - /** - * testExpiry method - * - * @return void - */ - public function testExpiry() - { - $this->_configCache(['duration' => 1]); - - $result = Cache::read('test', 'wincache'); - $this->assertFalse($result); - - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('other_test', $data, 'wincache'); - $this->assertTrue($result); - - sleep(2); - $result = Cache::read('other_test', 'wincache'); - $this->assertFalse($result); - - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('other_test', $data, 'wincache'); - $this->assertTrue($result); - - sleep(2); - $result = Cache::read('other_test', 'wincache'); - $this->assertFalse($result); - } - - /** - * testDeleteCache method - * - * @return void - */ - public function testDeleteCache() - { - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('delete_test', $data, 'wincache'); - $this->assertTrue($result); - - $result = Cache::delete('delete_test', 'wincache'); - $this->assertTrue($result); - } - - /** - * testDecrement method - * - * @return void - */ - public function testDecrement() - { - $this->skipIf( - !function_exists('wincache_ucache_dec'), - 'No wincache_ucache_dec() function, cannot test decrement().' - ); - - $result = Cache::write('test_decrement', 5, 'wincache'); - $this->assertTrue($result); - - $result = Cache::decrement('test_decrement', 1, 'wincache'); - $this->assertEquals(4, $result); - - $result = Cache::read('test_decrement', 'wincache'); - $this->assertEquals(4, $result); - - $result = Cache::decrement('test_decrement', 2, 'wincache'); - $this->assertEquals(2, $result); - - $result = Cache::read('test_decrement', 'wincache'); - $this->assertEquals(2, $result); - } - - /** - * testIncrement method - * - * @return void - */ - public function testIncrement() - { - $this->skipIf( - !function_exists('wincache_ucache_inc'), - 'No wincache_inc() function, cannot test increment().' - ); - - $result = Cache::write('test_increment', 5, 'wincache'); - $this->assertTrue($result); - - $result = Cache::increment('test_increment', 1, 'wincache'); - $this->assertEquals(6, $result); - - $result = Cache::read('test_increment', 'wincache'); - $this->assertEquals(6, $result); - - $result = Cache::increment('test_increment', 2, 'wincache'); - $this->assertEquals(8, $result); - - $result = Cache::read('test_increment', 'wincache'); - $this->assertEquals(8, $result); - } - - /** - * test the clearing of cache keys - * - * @return void - */ - public function testClear() - { - wincache_ucache_set('not_cake', 'safe'); - Cache::write('some_value', 'value', 'wincache'); - - $result = Cache::clear(false, 'wincache'); - $this->assertTrue($result); - $this->assertFalse(Cache::read('some_value', 'wincache')); - $this->assertEquals('safe', wincache_ucache_get('not_cake')); - } - - /** - * Tests that configuring groups for stored keys return the correct values when read/written - * Shows that altering the group value is equivalent to deleting all keys under the same - * group - * - * @return void - */ - public function testGroupsReadWrite() - { - Cache::config('wincache_groups', [ - 'engine' => 'Wincache', - 'duration' => 0, - 'groups' => ['group_a', 'group_b'], - 'prefix' => 'test_' - ]); - $this->assertTrue(Cache::write('test_groups', 'value', 'wincache_groups')); - $this->assertEquals('value', Cache::read('test_groups', 'wincache_groups')); - - wincache_ucache_inc('test_group_a'); - $this->assertFalse(Cache::read('test_groups', 'wincache_groups')); - $this->assertTrue(Cache::write('test_groups', 'value2', 'wincache_groups')); - $this->assertEquals('value2', Cache::read('test_groups', 'wincache_groups')); - - wincache_ucache_inc('test_group_b'); - $this->assertFalse(Cache::read('test_groups', 'wincache_groups')); - $this->assertTrue(Cache::write('test_groups', 'value3', 'wincache_groups')); - $this->assertEquals('value3', Cache::read('test_groups', 'wincache_groups')); - } - - /** - * Tests that deleting from a groups-enabled config is possible - * - * @return void - */ - public function testGroupDelete() - { - Cache::config('wincache_groups', [ - 'engine' => 'Wincache', - 'duration' => 0, - 'groups' => ['group_a', 'group_b'], - 'prefix' => 'test_' - ]); - $this->assertTrue(Cache::write('test_groups', 'value', 'wincache_groups')); - $this->assertEquals('value', Cache::read('test_groups', 'wincache_groups')); - $this->assertTrue(Cache::delete('test_groups', 'wincache_groups')); - - $this->assertFalse(Cache::read('test_groups', 'wincache_groups')); - } - - /** - * Test clearing a cache group - * - * @return void - */ - public function testGroupClear() - { - Cache::config('wincache_groups', [ - 'engine' => 'Wincache', - 'duration' => 0, - 'groups' => ['group_a', 'group_b'], - 'prefix' => 'test_' - ]); - - $this->assertTrue(Cache::write('test_groups', 'value', 'wincache_groups')); - $this->assertTrue(Cache::clearGroup('group_a', 'wincache_groups')); - $this->assertFalse(Cache::read('test_groups', 'wincache_groups')); - - $this->assertTrue(Cache::write('test_groups', 'value2', 'wincache_groups')); - $this->assertTrue(Cache::clearGroup('group_b', 'wincache_groups')); - $this->assertFalse(Cache::read('test_groups', 'wincache_groups')); - } -} diff --git a/tests/TestCase/Cache/Engine/XcacheEngineTest.php b/tests/TestCase/Cache/Engine/XcacheEngineTest.php deleted file mode 100644 index 49f39b40f67..00000000000 --- a/tests/TestCase/Cache/Engine/XcacheEngineTest.php +++ /dev/null @@ -1,315 +0,0 @@ -markTestSkipped('Xcache is not installed or configured properly'); - } - Cache::enable(); - Cache::config('xcache', ['engine' => 'Xcache', 'prefix' => 'cake_']); - } - - /** - * Helper method for testing. - * - * @param array $config - * @return void - */ - protected function _configCache($config = []) - { - $defaults = [ - 'className' => 'Xcache', - 'prefix' => 'cake_', - ]; - Cache::drop('xcache'); - Cache::config('xcache', array_merge($defaults, $config)); - } - - /** - * tearDown method - * - * @return void - */ - public function tearDown() - { - parent::tearDown(); - Cache::drop('xcache'); - Cache::drop('xcache_groups'); - } - - /** - * testConfig method - * - * @return void - */ - public function testConfig() - { - $config = Cache::engine('xcache')->config(); - $expecting = [ - 'prefix' => 'cake_', - 'duration' => 3600, - 'probability' => 100, - 'groups' => [], - ]; - $this->assertTrue(isset($config['PHP_AUTH_USER'])); - $this->assertTrue(isset($config['PHP_AUTH_PW'])); - - unset($config['PHP_AUTH_USER'], $config['PHP_AUTH_PW']); - $this->assertEquals($config, $expecting); - } - - /** - * testReadAndWriteCache method - * - * @return void - */ - public function testReadAndWriteCache() - { - $result = Cache::read('test', 'xcache'); - $expecting = ''; - $this->assertEquals($expecting, $result); - - // String - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('test', $data, 'xcache'); - $this->assertTrue($result); - - $result = Cache::read('test', 'xcache'); - $expecting = $data; - $this->assertEquals($expecting, $result); - - // Integer - $data = 100; - $result = Cache::write('test', 100, 'xcache'); - $this->assertTrue($result); - - $result = Cache::read('test', 'xcache'); - $this->assertSame(100, $result); - - // Object - $data = (object)['value' => 'an object']; - $result = Cache::write('test', $data, 'xcache'); - $this->assertTrue($result); - - $result = Cache::read('test', 'xcache'); - $this->assertInstanceOf('stdClass', $result); - $this->assertEquals('an object', $result->value); - - Cache::delete('test', 'xcache'); - } - - /** - * testExpiry method - * - * @return void - */ - public function testExpiry() - { - $this->_configCache(['duration' => 1]); - $result = Cache::read('test', 'xcache'); - $this->assertFalse($result); - - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('other_test', $data, 'xcache'); - $this->assertTrue($result); - - sleep(2); - $result = Cache::read('other_test', 'xcache'); - $this->assertFalse($result); - - $this->_configCache(['duration' => '+1 second']); - - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('other_test', $data, 'xcache'); - $this->assertTrue($result); - - sleep(2); - $result = Cache::read('other_test', 'xcache'); - $this->assertFalse($result); - } - - /** - * testDeleteCache method - * - * @return void - */ - public function testDeleteCache() - { - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('delete_test', $data, 'xcache'); - $this->assertTrue($result); - - $result = Cache::delete('delete_test', 'xcache'); - $this->assertTrue($result); - } - - /** - * testClearCache method - * - * @return void - */ - public function testClearCache() - { - if ((PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg')) { - $this->markTestSkipped('Xcache administration functions are not available for the CLI.'); - } - $data = 'this is a test of the emergency broadcasting system'; - $result = Cache::write('clear_test_1', $data, 'xcache'); - $this->assertTrue($result); - - $result = Cache::write('clear_test_2', $data, 'xcache'); - $this->assertTrue($result); - - $result = Cache::clear(false, 'xcache'); - $this->assertTrue($result); - } - - /** - * testDecrement method - * - * @return void - */ - public function testDecrement() - { - $result = Cache::write('test_decrement', 5, 'xcache'); - $this->assertTrue($result); - - $result = Cache::decrement('test_decrement', 1, 'xcache'); - $this->assertEquals(4, $result); - - $result = Cache::read('test_decrement', 'xcache'); - $this->assertEquals(4, $result); - - $result = Cache::decrement('test_decrement', 2, 'xcache'); - $this->assertEquals(2, $result); - - $result = Cache::read('test_decrement', 'xcache'); - $this->assertEquals(2, $result); - } - - /** - * testIncrement method - * - * @return void - */ - public function testIncrement() - { - $result = Cache::write('test_increment', 5, 'xcache'); - $this->assertTrue($result); - - $result = Cache::increment('test_increment', 1, 'xcache'); - $this->assertEquals(6, $result); - - $result = Cache::read('test_increment', 'xcache'); - $this->assertEquals(6, $result); - - $result = Cache::increment('test_increment', 2, 'xcache'); - $this->assertEquals(8, $result); - - $result = Cache::read('test_increment', 'xcache'); - $this->assertEquals(8, $result); - } - - /** - * Tests that configuring groups for stored keys return the correct values when read/written - * Shows that altering the group value is equivalent to deleting all keys under the same - * group - * - * @return void - */ - public function testGroupsReadWrite() - { - Cache::config('xcache_groups', [ - 'engine' => 'Xcache', - 'duration' => 0, - 'groups' => ['group_a', 'group_b'], - 'prefix' => 'test_' - ]); - $this->assertTrue(Cache::write('test_groups', 'value', 'xcache_groups')); - $this->assertEquals('value', Cache::read('test_groups', 'xcache_groups')); - - xcache_inc('test_group_a', 1); - $this->assertFalse(Cache::read('test_groups', 'xcache_groups')); - $this->assertTrue(Cache::write('test_groups', 'value2', 'xcache_groups')); - $this->assertEquals('value2', Cache::read('test_groups', 'xcache_groups')); - - xcache_inc('test_group_b', 1); - $this->assertFalse(Cache::read('test_groups', 'xcache_groups')); - $this->assertTrue(Cache::write('test_groups', 'value3', 'xcache_groups')); - $this->assertEquals('value3', Cache::read('test_groups', 'xcache_groups')); - } - - /** - * Tests that deleting from a groups-enabled config is possible - * - * @return void - */ - public function testGroupDelete() - { - Cache::config('xcache_groups', [ - 'engine' => 'Xcache', - 'duration' => 0, - 'groups' => ['group_a', 'group_b'], - 'prefix' => 'test_' - ]); - $this->assertTrue(Cache::write('test_groups', 'value', 'xcache_groups')); - $this->assertEquals('value', Cache::read('test_groups', 'xcache_groups')); - $this->assertTrue(Cache::delete('test_groups', 'xcache_groups')); - - $this->assertFalse(Cache::read('test_groups', 'xcache_groups')); - } - - /** - * Test clearing a cache group - * - * @return void - */ - public function testGroupClear() - { - Cache::config('xcache_groups', [ - 'engine' => 'Xcache', - 'duration' => 0, - 'groups' => ['group_a', 'group_b'], - 'prefix' => 'test_' - ]); - - $this->assertTrue(Cache::write('test_groups', 'value', 'xcache_groups')); - $this->assertTrue(Cache::clearGroup('group_a', 'xcache_groups')); - $this->assertFalse(Cache::read('test_groups', 'xcache_groups')); - - $this->assertTrue(Cache::write('test_groups', 'value2', 'xcache_groups')); - $this->assertTrue(Cache::clearGroup('group_b', 'xcache_groups')); - $this->assertFalse(Cache::read('test_groups', 'xcache_groups')); - } -} diff --git a/tests/TestCase/Collection/CollectionTest.php b/tests/TestCase/Collection/CollectionTest.php index e47629f3c20..d42abcc8d54 100644 --- a/tests/TestCase/Collection/CollectionTest.php +++ b/tests/TestCase/Collection/CollectionTest.php @@ -1,4 +1,6 @@ data = $data; - - parent::__construct($data); - } - - public function checkValues() - { - return true; - } -} +use PHPUnit\Framework\Attributes\DataProvider; +use TestApp\Collection\CountableIterator; +use TestApp\Collection\TestCollection; +use TestApp\Model\Enum\ArticleStatus; +use TestApp\Model\Enum\NonBacked; +use TestApp\Model\Enum\Priority; +use function Cake\Collection\collection; /** - * CollectionTest + * Collection Test */ class CollectionTest extends TestCase { - /** * Tests that it is possible to convert an array into a collection - * - * @return void */ - public function testArrayIsWrapped() + public function testArrayIsWrapped(): void { $items = [1, 2, 3]; $collection = new Collection($items); @@ -86,41 +62,40 @@ public function testArrayIsWrapped() * * @return array */ - public function avgProvider() + public static function avgProvider(): array { $items = [1, 2, 3]; return [ 'array' => [$items], - 'iterator' => [$this->yieldItems($items)] + 'iterator' => [self::yieldItems($items)], ]; } /** * Tests the avg method - * - * @dataProvider avgProvider - * @return void */ - public function testAvg($items) + #[DataProvider('avgProvider')] + public function testAvg(iterable $items): void { $collection = new Collection($items); - $this->assertEquals(2, $collection->avg()); + $this->assertSame(2, $collection->avg()); $items = [['foo' => 1], ['foo' => 2], ['foo' => 3]]; $collection = new Collection($items); - $this->assertEquals(2, $collection->avg('foo')); + $this->assertSame(2, $collection->avg('foo')); } /** * Tests the avg method when on an empty collection - * - * @return void */ - public function testAvgWithEmptyCollection() + public function testAvgWithEmptyCollection(): void { $collection = new Collection([]); $this->assertNull($collection->avg()); + + $collection = new Collection([null, null]); + $this->assertSame(0, $collection->avg()); } /** @@ -128,26 +103,24 @@ public function testAvgWithEmptyCollection() * * @return array */ - public function avgWithMatcherProvider() + public static function avgWithMatcherProvider(): array { $items = [['foo' => 1], ['foo' => 2], ['foo' => 3]]; return [ 'array' => [$items], - 'iterator' => [$this->yieldItems($items)] + 'iterator' => [self::yieldItems($items)], ]; } /** * ests the avg method - * - * @dataProvider avgWithMatcherProvider - * @return void */ - public function testAvgWithMatcher($items) + #[DataProvider('avgWithMatcherProvider')] + public function testAvgWithMatcher(iterable $items): void { $collection = new Collection($items); - $this->assertEquals(2, $collection->avg('foo')); + $this->assertSame(2, $collection->avg('foo')); } /** @@ -155,49 +128,46 @@ public function testAvgWithMatcher($items) * * @return array */ - public function medianProvider() + public static function medianProvider(): array { $items = [5, 2, 4]; return [ 'array' => [$items], - 'iterator' => [$this->yieldItems($items)] + 'iterator' => [self::yieldItems($items)], ]; } /** * Tests the median method - * - * @dataProvider medianProvider - * @return void */ - public function testMedian($items) + #[DataProvider('medianProvider')] + public function testMedian(iterable $items): void { $collection = new Collection($items); - $this->assertEquals(4, $collection->median()); + $this->assertSame(4, $collection->median()); } /** * Tests the median method when on an empty collection - * - * @return void */ - public function testMedianWithEmptyCollection() + public function testMedianWithEmptyCollection(): void { $collection = new Collection([]); $this->assertNull($collection->median()); + + $collection = new Collection([null, null]); + $this->assertSame(0, $collection->median()); } /** * Tests the median method - * - * @dataProvider simpleProvider - * @return void */ - public function testMedianEven($items) + #[DataProvider('simpleProvider')] + public function testMedianEven(iterable $items): void { $collection = new Collection($items); - $this->assertEquals(2.5, $collection->median()); + $this->assertSame(2.5, $collection->median()); } /** @@ -205,129 +175,113 @@ public function testMedianEven($items) * * @return array */ - public function medianWithMatcherProvider() + public static function medianWithMatcherProvider(): array { $items = [ ['invoice' => ['total' => 400]], ['invoice' => ['total' => 500]], ['invoice' => ['total' => 200]], ['invoice' => ['total' => 100]], - ['invoice' => ['total' => 333]] + ['invoice' => ['total' => 333]], ]; return [ 'array' => [$items], - 'iterator' => [$this->yieldItems($items)] + 'iterator' => [self::yieldItems($items)], ]; } /** * Tests the median method - * - * @dataProvider medianWithMatcherProvider - * @return void */ - public function testMedianWithMatcher($items) + #[DataProvider('medianWithMatcherProvider')] + public function testMedianWithMatcher(iterable $items): void { - $this->assertEquals(333, (new Collection($items))->median('invoice.total')); + $this->assertSame(333, (new Collection($items))->median('invoice.total')); } /** * Tests that it is possible to convert an iterator into a collection - * - * @return void */ - public function testIteratorIsWrapped() + public function testIteratorIsWrapped(): void { - $items = new \ArrayObject([1, 2, 3]); + $items = new ArrayObject([1, 2, 3]); $collection = new Collection($items); $this->assertEquals(iterator_to_array($items), iterator_to_array($collection)); } /** * Test running a method over all elements in the collection - * - * @return void */ - public function testEach() + public function testEach(): void { $items = ['a' => 1, 'b' => 2, 'c' => 3]; $collection = new Collection($items); - $callable = $this->getMockBuilder(\StdClass::class) - ->setMethods(['__invoke']) - ->getMock(); - $callable->expects($this->at(0)) - ->method('__invoke') - ->with(1, 'a'); - $callable->expects($this->at(1)) - ->method('__invoke') - ->with(2, 'b'); - $callable->expects($this->at(2)) - ->method('__invoke') - ->with(3, 'c'); - $collection->each($callable); + + $results = []; + $collection->each(function ($value, $key) use (&$results): void { + $results[] = [$key => $value]; + }); + $this->assertSame([['a' => 1], ['b' => 2], ['c' => 3]], $results); } - public function filterProvider() + public static function filterProvider(): array { $items = [1, 2, 0, 3, false, 4, null, 5, '']; return [ 'array' => [$items], - 'iterator' => [$this->yieldItems($items)] + 'iterator' => [self::yieldItems($items)], ]; } /** * Test filter() with no callback. - * - * @dataProvider filterProvider - * @return void */ - public function testFilterNoCallback($items) + #[DataProvider('filterProvider')] + public function testFilterNoCallback(iterable $items): void { $collection = new Collection($items); $result = $collection->filter()->toArray(); $expected = [1, 2, 3, 4, 5]; - $this->assertEquals($expected, array_values($result)); + $this->assertSame($expected, array_values($result)); } /** * Tests that it is possible to chain filter() as it returns a collection object - * - * @return void */ - public function testFilterChaining() + public function testFilterChaining(): void { $items = ['a' => 1, 'b' => 2, 'c' => 3]; $collection = new Collection($items); - $callable = $this->getMockBuilder(\StdClass::class) - ->setMethods(['__invoke']) - ->getMock(); - $callable->expects($this->once()) - ->method('__invoke') - ->with(3, 'c'); $filtered = $collection->filter(function ($value, $key, $iterator) { return $value > 2; }); + $this->assertInstanceOf(Collection::class, $filtered); - $this->assertInstanceOf('Cake\Collection\Collection', $filtered); - $filtered->each($callable); + $results = []; + $filtered->each(function ($value, $key) use (&$results): void { + $results[] = [$key => $value]; + }); + $this->assertSame([['c' => 3]], $results); } /** * Tests reject - * - * @return void */ - public function testReject() + public function testReject(): void { $collection = new Collection([]); $result = $collection->reject(function ($v) { return false; }); $this->assertSame([], iterator_to_array($result)); + $this->assertInstanceOf(Collection::class, $result); + + $collection = new Collection(['a' => null, 'b' => 2, 'c' => false]); + $result = $collection->reject(); + $this->assertEquals(['a' => null, 'c' => false], iterator_to_array($result)); $items = ['a' => 1, 'b' => 2, 'c' => 3]; $collection = new Collection($items); @@ -337,78 +291,110 @@ public function testReject() return $v > 2; }); $this->assertEquals(['a' => 1, 'b' => 2], iterator_to_array($result)); - $this->assertInstanceOf('Cake\Collection\Collection', $result); + } + + public function testUnique(): void + { + $collection = new Collection([]); + $result = $collection->unique(); + $this->assertSame([], iterator_to_array($result)); + $this->assertInstanceOf(Collection::class, $result); + + $items = ['a' => 1, 'b' => 2, 'c' => 3]; + $collection = new Collection($items); + $result = $collection->unique(); + $this->assertEquals(['a' => 1, 'b' => 2, 'c' => 3], iterator_to_array($result)); + + $items = ['a' => 1, 'b' => 2, 'c' => 1, 'd' => 2, 'e' => 1, 'f' => 3]; + $collection = new Collection($items); + $result = $collection->unique(); + $this->assertEquals(['a' => 1, 'b' => 2, 'f' => 3], iterator_to_array($result)); + + $result = $collection->unique(fn($v) => (string)$v); + $this->assertEquals(['a' => 1, 'b' => 2, 'f' => 3], iterator_to_array($result)); + + $result = $collection->unique(fn($v, $k) => $k); + $this->assertEquals(['a' => 1, 'b' => 2, 'c' => 1, 'd' => 2, 'e' => 1, 'f' => 3], iterator_to_array($result)); } /** * Tests every when the callback returns true for all elements - * - * @return void */ - public function testEveryReturnTrue() + public function testEveryReturnTrue(): void { $items = ['a' => 1, 'b' => 2, 'c' => 3]; $collection = new Collection($items); - $callable = $this->getMockBuilder(\StdClass::class) - ->setMethods(['__invoke']) - ->getMock(); - $callable->expects($this->at(0)) - ->method('__invoke') - ->with(1, 'a') - ->will($this->returnValue(true)); - $callable->expects($this->at(1)) - ->method('__invoke') - ->with(2, 'b') - ->will($this->returnValue(true)); - $callable->expects($this->at(2)) - ->method('__invoke') - ->with(3, 'c') - ->will($this->returnValue(true)); - $this->assertTrue($collection->every($callable)); + $results = []; + $this->assertTrue($collection->every(function ($value, $key) use (&$results) { + $results[] = [$key => $value]; + + return true; + })); + $this->assertSame([['a' => 1], ['b' => 2], ['c' => 3]], $results); } /** * Tests every when the callback returns false for one of the elements - * - * @return void */ - public function testEveryReturnFalse() + public function testEveryReturnFalse(): void { $items = ['a' => 1, 'b' => 2, 'c' => 3]; $collection = new Collection($items); - $callable = $this->getMockBuilder(\StdClass::class) - ->setMethods(['__invoke']) - ->getMock(); - - $callable->expects($this->at(0)) - ->method('__invoke') - ->with(1, 'a') - ->will($this->returnValue(true)); - $callable->expects($this->at(1)) - ->method('__invoke') - ->with(2, 'b') - ->will($this->returnValue(false)); - $callable->expects($this->exactly(2))->method('__invoke'); - $this->assertFalse($collection->every($callable)); - $items = []; + $results = []; + $this->assertFalse($collection->every(function ($value, $key) use (&$results) { + $results[] = [$key => $value]; + + return $key !== 'b'; + })); + $this->assertSame([['a' => 1], ['b' => 2]], $results); + } + + /** + * Tests any() when one of the calls return true + */ + public function testAnyReturnTrue(): void + { + $collection = new Collection([]); + $result = $collection->any(function ($v) { + return true; + }); + $this->assertFalse($result); + + $items = ['a' => 1, 'b' => 2, 'c' => 3]; + $collection = new Collection($items); + + $results = []; + $this->assertTrue($collection->some(function ($value, $key) use (&$results) { + $results[] = [$key => $value]; + + return $key === 'b'; + })); + $this->assertSame([['a' => 1], ['b' => 2]], $results); + } + + /** + * Tests any() when none of the calls return true + */ + public function testAnyReturnFalse(): void + { + $items = ['a' => 1, 'b' => 2, 'c' => 3]; $collection = new Collection($items); - $callable = $this->getMockBuilder(\StdClass::class) - ->setMethods(['__invoke']) - ->getMock(); - $callable->expects($this->never()) - ->method('__invoke'); - $this->assertTrue($collection->every($callable)); + $results = []; + $this->assertFalse($collection->any(function ($value, $key) use (&$results) { + $results[] = [$key => $value]; + + return false; + })); + $this->assertSame([['a' => 1], ['b' => 2], ['c' => 3]], $results); } /** * Tests some() when one of the calls return true - * - * @return void */ - public function testSomeReturnTrue() + public function testSomeReturnTrue(): void { $collection = new Collection([]); $result = $collection->some(function ($v) { @@ -418,56 +404,37 @@ public function testSomeReturnTrue() $items = ['a' => 1, 'b' => 2, 'c' => 3]; $collection = new Collection($items); - $callable = $this->getMockBuilder(\StdClass::class) - ->setMethods(['__invoke']) - ->getMock(); - $callable->expects($this->at(0)) - ->method('__invoke') - ->with(1, 'a') - ->will($this->returnValue(false)); - $callable->expects($this->at(1)) - ->method('__invoke') - ->with(2, 'b') - ->will($this->returnValue(true)); - $callable->expects($this->exactly(2))->method('__invoke'); - $this->assertTrue($collection->some($callable)); + $results = []; + $this->assertTrue($collection->some(function ($value, $key) use (&$results) { + $results[] = [$key => $value]; + + return $key === 'b'; + })); + $this->assertSame([['a' => 1], ['b' => 2]], $results); } /** * Tests some() when none of the calls return true - * - * @return void */ - public function testSomeReturnFalse() + public function testSomeReturnFalse(): void { $items = ['a' => 1, 'b' => 2, 'c' => 3]; $collection = new Collection($items); - $callable = $this->getMockBuilder(\StdClass::class) - ->setMethods(['__invoke']) - ->getMock(); - $callable->expects($this->at(0)) - ->method('__invoke') - ->with(1, 'a') - ->will($this->returnValue(false)); - $callable->expects($this->at(1)) - ->method('__invoke') - ->with(2, 'b') - ->will($this->returnValue(false)); - $callable->expects($this->at(2)) - ->method('__invoke') - ->with(3, 'c') - ->will($this->returnValue(false)); - $this->assertFalse($collection->some($callable)); + $results = []; + $this->assertFalse($collection->some(function ($value, $key) use (&$results) { + $results[] = [$key => $value]; + + return false; + })); + $this->assertSame([['a' => 1], ['b' => 2], ['c' => 3]], $results); } /** * Tests contains - * - * @return void */ - public function testContains() + public function testContains(): void { $collection = new Collection([]); $this->assertFalse($collection->contains('a')); @@ -485,23 +452,21 @@ public function testContains() * * @return array */ - public function simpleProvider() + public static function simpleProvider(): array { $items = ['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4]; return [ 'array' => [$items], - 'iterator' => [$this->yieldItems($items)] + 'iterator' => [self::yieldItems($items)], ]; } /** * Tests map - * - * @dataProvider simpleProvider - * @return void */ - public function testMap($items) + #[DataProvider('simpleProvider')] + public function testMap(iterable $items): void { $collection = new Collection($items); $map = $collection->map(function ($v, $k, $it) use ($collection) { @@ -509,68 +474,32 @@ public function testMap($items) return $v * $v; }); - $this->assertInstanceOf('Cake\Collection\Iterator\ReplaceIterator', $map); + $this->assertInstanceOf(ReplaceIterator::class, $map); $this->assertEquals(['a' => 1, 'b' => 4, 'c' => 9, 'd' => 16], iterator_to_array($map)); } /** * Tests reduce with initial value - * - * @dataProvider simpleProvider - * @return void */ - public function testReduceWithInitialValue($items) + #[DataProvider('simpleProvider')] + public function testReduceWithInitialValue(iterable $items): void { $collection = new Collection($items); - $callable = $this->getMockBuilder(\StdClass::class) - ->setMethods(['__invoke']) - ->getMock(); - - $callable->expects($this->at(0)) - ->method('__invoke') - ->with(10, 1, 'a') - ->will($this->returnValue(11)); - $callable->expects($this->at(1)) - ->method('__invoke') - ->with(11, 2, 'b') - ->will($this->returnValue(13)); - $callable->expects($this->at(2)) - ->method('__invoke') - ->with(13, 3, 'c') - ->will($this->returnValue(16)); - $callable->expects($this->at(3)) - ->method('__invoke') - ->with(16, 4, 'd') - ->will($this->returnValue(20)); - $this->assertEquals(20, $collection->reduce($callable, 10)); + $this->assertSame(20, $collection->reduce(function ($reduction, $value, $key) { + return $value + $reduction; + }, 10)); } /** * Tests reduce without initial value - * - * @dataProvider simpleProvider - * @return void */ - public function testReduceWithoutInitialValue($items) + #[DataProvider('simpleProvider')] + public function testReduceWithoutInitialValue(iterable $items): void { $collection = new Collection($items); - $callable = $this->getMockBuilder(\StdClass::class) - ->setMethods(['__invoke']) - ->getMock(); - - $callable->expects($this->at(0)) - ->method('__invoke') - ->with(1, 2, 'b') - ->will($this->returnValue(3)); - $callable->expects($this->at(1)) - ->method('__invoke') - ->with(3, 3, 'c') - ->will($this->returnValue(6)); - $callable->expects($this->at(2)) - ->method('__invoke') - ->with(6, 4, 'd') - ->will($this->returnValue(10)); - $this->assertEquals(10, $collection->reduce($callable)); + $this->assertSame(10, $collection->reduce(function ($reduction, $value, $key) { + return $value + $reduction; + })); } /** @@ -578,27 +507,25 @@ public function testReduceWithoutInitialValue($items) * * @return array */ - public function extractProvider() + public static function extractProvider(): array { $items = [['a' => ['b' => ['c' => 1]]], 2]; return [ 'array' => [$items], - 'iterator' => [$this->yieldItems($items)] + 'iterator' => [self::yieldItems($items)], ]; } /** * Tests extract - * - * @dataProvider extractProvider - * @return void */ - public function testExtract($items) + #[DataProvider('extractProvider')] + public function testExtract(iterable $items): void { $collection = new Collection($items); $map = $collection->extract('a.b.c'); - $this->assertInstanceOf('Cake\Collection\Iterator\ExtractIterator', $map); + $this->assertInstanceOf(ExtractIterator::class, $map); $this->assertEquals([1, null], iterator_to_array($map)); } @@ -607,31 +534,29 @@ public function testExtract($items) * * @return array */ - public function sortProvider() + public static function sortProvider(): array { $items = [ ['a' => ['b' => ['c' => 4]]], ['a' => ['b' => ['c' => 10]]], - ['a' => ['b' => ['c' => 6]]] + ['a' => ['b' => ['c' => 6]]], ]; return [ 'array' => [$items], - 'iterator' => [$this->yieldItems($items)] + 'iterator' => [self::yieldItems($items)], ]; } /** * Tests sort - * - * @dataProvider sortProvider - * @return void */ - public function testSortString($items) + #[DataProvider('sortProvider')] + public function testSortString(iterable $items): void { $collection = new Collection($items); $map = $collection->sortBy('a.b.c'); - $this->assertInstanceOf('Cake\Collection\Collection', $map); + $this->assertInstanceOf(Collection::class, $map); $expected = [ ['a' => ['b' => ['c' => 10]]], ['a' => ['b' => ['c' => 6]]], @@ -642,11 +567,9 @@ public function testSortString($items) /** * Tests max - * - * @dataProvider sortProvider - * @return void */ - public function testMax($items) + #[DataProvider('sortProvider')] + public function testMax(iterable $items): void { $collection = new Collection($items); $this->assertEquals(['a' => ['b' => ['c' => 10]]], $collection->max('a.b.c')); @@ -654,52 +577,81 @@ public function testMax($items) /** * Tests max - * - * @dataProvider sortProvider - * @return void */ - public function testMaxCallback($items) + #[DataProvider('sortProvider')] + public function testMaxCallback(iterable $items): void { $collection = new Collection($items); $callback = function ($e) { - return $e['a']['b']['c'] * - 1; + return $e['a']['b']['c'] * -1; }; $this->assertEquals(['a' => ['b' => ['c' => 4]]], $collection->max($callback)); } /** * Tests max - * - * @dataProvider sortProvider - * @return void */ - public function testMaxCallable($items) + #[DataProvider('sortProvider')] + public function testMaxCallable(iterable $items): void { $collection = new Collection($items); - $callback = function ($e) { - return $e['a']['b']['c'] * - 1; - }; - $this->assertEquals(['a' => ['b' => ['c' => 4]]], $collection->max($callback)); + $this->assertEquals(['a' => ['b' => ['c' => 4]]], $collection->max(function ($e) { + return $e['a']['b']['c'] * -1; + })); + } + + /** + * Test max with a collection of Entities + */ + public function testMaxWithEntities(): void + { + $collection = new Collection([ + new Entity(['id' => 1, 'count' => 18]), + new Entity(['id' => 2, 'count' => 9]), + new Entity(['id' => 3, 'count' => 42]), + new Entity(['id' => 4, 'count' => 4]), + new Entity(['id' => 5, 'count' => 22]), + ]); + + $expected = new Entity(['id' => 3, 'count' => 42]); + + $this->assertEquals($expected, $collection->max('count')); } /** * Tests min - * - * @dataProvider sortProvider - * @return void */ - public function testMin($items) + #[DataProvider('sortProvider')] + public function testMin(iterable $items): void { $collection = new Collection($items); $this->assertEquals(['a' => ['b' => ['c' => 4]]], $collection->min('a.b.c')); } + /** + * Test min with a collection of Entities + */ + public function testMinWithEntities(): void + { + $collection = new Collection([ + new Entity(['id' => 1, 'count' => 18]), + new Entity(['id' => 2, 'count' => 9]), + new Entity(['id' => 3, 'count' => 42]), + new Entity(['id' => 4, 'count' => 4]), + new Entity(['id' => 5, 'count' => 22]), + ]); + + $expected = new Entity(['id' => 4, 'count' => 4]); + + $this->assertEquals($expected, $collection->min('count')); + } + /** * Provider for some groupBy tests * * @return array */ - public function groupByProvider() + public static function groupByProvider(): array { $items = [ ['id' => 1, 'name' => 'foo', 'parent_id' => 10], @@ -709,17 +661,15 @@ public function groupByProvider() return [ 'array' => [$items], - 'iterator' => [$this->yieldItems($items)] + 'iterator' => [self::yieldItems($items)], ]; } /** * Tests groupBy - * - * @dataProvider groupByProvider - * @return void */ - public function testGroupBy($items) + #[DataProvider('groupByProvider')] + public function testGroupBy(iterable $items): void { $collection = new Collection($items); $grouped = $collection->groupBy('parent_id'); @@ -730,19 +680,17 @@ public function testGroupBy($items) ], 11 => [ ['id' => 2, 'name' => 'bar', 'parent_id' => 11], - ] + ], ]; $this->assertEquals($expected, iterator_to_array($grouped)); - $this->assertInstanceOf('Cake\Collection\Collection', $grouped); + $this->assertInstanceOf(Collection::class, $grouped); } /** * Tests groupBy - * - * @dataProvider groupByProvider - * @return void */ - public function testGroupByCallback($items) + #[DataProvider('groupByProvider')] + public function testGroupByCallback(iterable $items): void { $collection = new Collection($items); $expected = [ @@ -752,7 +700,7 @@ public function testGroupByCallback($items) ], 11 => [ ['id' => 2, 'name' => 'bar', 'parent_id' => 11], - ] + ], ]; $grouped = $collection->groupBy(function ($element) { return $element['parent_id']; @@ -760,12 +708,34 @@ public function testGroupByCallback($items) $this->assertEquals($expected, iterator_to_array($grouped)); } + public function testGroupByPreserveIndex(): void + { + $items = [ + 'first' => ['name' => 'foo', 'type' => 'a'], + 'second' => ['name' => 'bar', 'type' => 'b'], + 'third' => ['name' => 'baz', 'type' => 'b'], + 'fourth' => ['name' => 'aah', 'type' => 'a'], + ]; + + $collection = new Collection($items); + $grouped = $collection->groupBy('type', true); + $expected = [ + 'a' => [ + 'first' => ['name' => 'foo', 'type' => 'a'], + 'fourth' => ['name' => 'aah', 'type' => 'a'], + ], + 'b' => [ + 'second' => ['name' => 'bar', 'type' => 'b'], + 'third' => ['name' => 'baz', 'type' => 'b'], + ], + ]; + $this->assertEquals($expected, iterator_to_array($grouped)); + } + /** * Tests grouping by a deep key - * - * @return void */ - public function testGroupByDeepKey() + public function testGroupByDeepKey(): void { $items = [ ['id' => 1, 'name' => 'foo', 'thing' => ['parent_id' => 10]], @@ -781,17 +751,83 @@ public function testGroupByDeepKey() ], 11 => [ ['id' => 2, 'name' => 'bar', 'thing' => ['parent_id' => 11]], - ] + ], + ]; + $this->assertEquals($expected, iterator_to_array($grouped)); + } + + /** + * Tests grouping by an enum key + */ + public function testGroupByEnum(): void + { + $items = [ + ['id' => 1, 'name' => 'foo', 'thing' => NonBacked::Basic], + ['id' => 2, 'name' => 'bar', 'thing' => NonBacked::Advanced], + ['id' => 3, 'name' => 'baz', 'thing' => NonBacked::Basic], + ]; + $collection = new Collection($items); + $grouped = $collection->groupBy('thing'); + $expected = [ + NonBacked::Basic->name => [ + ['id' => 1, 'name' => 'foo', 'thing' => NonBacked::Basic], + ['id' => 3, 'name' => 'baz', 'thing' => NonBacked::Basic], + ], + NonBacked::Advanced->name => [ + ['id' => 2, 'name' => 'bar', 'thing' => NonBacked::Advanced], + ], + ]; + $this->assertEquals($expected, iterator_to_array($grouped)); + } + + /** + * Tests grouping by a backed enum key + */ + public function testGroupByBackedEnum(): void + { + $items = [ + ['id' => 1, 'name' => 'foo', 'thing' => Priority::Medium], + ['id' => 2, 'name' => 'bar', 'thing' => Priority::High], + ['id' => 3, 'name' => 'baz', 'thing' => Priority::Medium], + ]; + $collection = new Collection($items); + $grouped = $collection->groupBy('thing'); + $expected = [ + Priority::Medium->value => [ + ['id' => 1, 'name' => 'foo', 'thing' => Priority::Medium], + ['id' => 3, 'name' => 'baz', 'thing' => Priority::Medium], + ], + Priority::High->value => [ + ['id' => 2, 'name' => 'bar', 'thing' => Priority::High], + ], ]; $this->assertEquals($expected, iterator_to_array($grouped)); } + /** + * Tests passing an invalid path to groupBy. + */ + public function testGroupByInvalidPath(): void + { + $items = [ + ['id' => 1, 'name' => 'foo'], + ['id' => 2, 'name' => 'bar'], + ['id' => 3, 'name' => 'baz'], + ]; + $collection = new Collection($items); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot group by path that does not exist or contains a null value.'); + + $collection->groupBy('missing'); + } + /** * Provider for some indexBy tests * * @return array */ - public function indexByProvider() + public static function indexByProvider(): array { $items = [ ['id' => 1, 'name' => 'foo', 'parent_id' => 10], @@ -801,17 +837,15 @@ public function indexByProvider() return [ 'array' => [$items], - 'iterator' => [$this->yieldItems($items)] + 'iterator' => [self::yieldItems($items)], ]; } /** * Tests indexBy - * - * @dataProvider indexByProvider - * @return void */ - public function testIndexBy($items) + #[DataProvider('indexByProvider')] + public function testIndexBy(iterable $items): void { $collection = new Collection($items); $grouped = $collection->indexBy('id'); @@ -821,16 +855,14 @@ public function testIndexBy($items) 2 => ['id' => 2, 'name' => 'bar', 'parent_id' => 11], ]; $this->assertEquals($expected, iterator_to_array($grouped)); - $this->assertInstanceOf('Cake\Collection\Collection', $grouped); + $this->assertInstanceOf(Collection::class, $grouped); } /** * Tests indexBy - * - * @dataProvider indexByProvider - * @return void */ - public function testIndexByCallback($items) + #[DataProvider('indexByProvider')] + public function testIndexByCallback(iterable $items): void { $collection = new Collection($items); $grouped = $collection->indexBy(function ($element) { @@ -844,12 +876,50 @@ public function testIndexByCallback($items) $this->assertEquals($expected, iterator_to_array($grouped)); } + /** + * Tests indexBy with an enum + */ + public function testIndexByEnum(): void + { + $items = [ + ['id' => 1, 'name' => 'foo', 'thing' => NonBacked::Basic], + ['id' => 2, 'name' => 'bar', 'thing' => NonBacked::Advanced], + ]; + $collection = new Collection($items); + $grouped = $collection->indexBy(function ($element) { + return $element['thing']; + }); + $expected = [ + NonBacked::Basic->name => ['id' => 1, 'name' => 'foo', 'thing' => NonBacked::Basic], + NonBacked::Advanced->name => ['id' => 2, 'name' => 'bar', 'thing' => NonBacked::Advanced], + ]; + $this->assertEquals($expected, iterator_to_array($grouped)); + } + + /** + * Tests indexBy with a backed enum + */ + public function testIndexByBackedEnum(): void + { + $items = [ + ['id' => 1, 'name' => 'foo', 'thing' => Priority::Medium], + ['id' => 2, 'name' => 'bar', 'thing' => Priority::High], + ]; + $collection = new Collection($items); + $grouped = $collection->indexBy(function ($element) { + return $element['thing']; + }); + $expected = [ + Priority::Medium->value => ['id' => 1, 'name' => 'foo', 'thing' => Priority::Medium], + Priority::High->value => ['id' => 2, 'name' => 'bar', 'thing' => Priority::High], + ]; + $this->assertEquals($expected, iterator_to_array($grouped)); + } + /** * Tests indexBy with a deep property - * - * @return void */ - public function testIndexByDeep() + public function testIndexByDeep(): void { $items = [ ['id' => 1, 'name' => 'foo', 'thing' => ['parent_id' => 10]], @@ -865,36 +935,70 @@ public function testIndexByDeep() $this->assertEquals($expected, iterator_to_array($grouped)); } + /** + * Tests passing an invalid path to indexBy. + */ + public function testIndexByInvalidPath(): void + { + $items = [ + ['id' => 1, 'name' => 'foo'], + ['id' => 2, 'name' => 'bar'], + ['id' => 3, 'name' => 'baz'], + ]; + $collection = new Collection($items); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot index by path that does not exist or contains a null value'); + + $collection->indexBy('missing'); + } + + /** + * Tests passing an invalid path to indexBy. + */ + public function testIndexByInvalidPathCallback(): void + { + $items = [ + ['id' => 1, 'name' => 'foo'], + ['id' => 2, 'name' => 'bar'], + ['id' => 3, 'name' => 'baz'], + ]; + $collection = new Collection($items); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot index by path that does not exist or contains a null value'); + + $collection->indexBy(function ($e) { + return null; + }); + } + /** * Tests countBy - * - * @dataProvider groupByProvider - * @return void */ - public function testCountBy($items) + #[DataProvider('groupByProvider')] + public function testCountBy(iterable $items): void { $collection = new Collection($items); $grouped = $collection->countBy('parent_id'); $expected = [ 10 => 2, - 11 => 1 + 11 => 1, ]; $result = iterator_to_array($grouped); - $this->assertInstanceOf('Cake\Collection\Collection', $grouped); + $this->assertInstanceOf(Collection::class, $grouped); $this->assertEquals($expected, $result); } /** * Tests countBy - * - * @dataProvider groupByProvider - * @return void */ - public function testCountByCallback($items) + #[DataProvider('groupByProvider')] + public function testCountByCallback(iterable $items): void { $expected = [ 10 => 2, - 11 => 1 + 11 => 1, ]; $collection = new Collection($items); $grouped = $collection->countBy(function ($element) { @@ -905,11 +1009,9 @@ public function testCountByCallback($items) /** * Tests shuffle - * - * @dataProvider simpleProvider - * @return void */ - public function testShuffle($data) + #[DataProvider('simpleProvider')] + public function testShuffle(iterable $data): void { $collection = (new Collection($data))->shuffle(); $result = $collection->toArray(); @@ -920,13 +1022,24 @@ public function testShuffle($data) $this->assertContains(4, $result); } + /** + * Tests shuffle with duplicate keys. + */ + public function testShuffleDuplicateKeys(): void + { + $collection = (new Collection(['a' => 1]))->append(['a' => 2])->shuffle(); + $result = $collection->toArray(); + $this->assertCount(2, $result); + $this->assertEquals([0, 1], array_keys($result)); + $this->assertContainsEquals(1, $result); + $this->assertContainsEquals(2, $result); + } + /** * Tests sample - * - * @dataProvider simpleProvider - * @return void */ - public function testSample($data) + #[DataProvider('simpleProvider')] + public function testSample(iterable $data): void { $result = (new Collection($data))->sample(2)->toArray(); $this->assertCount(2, $result); @@ -937,10 +1050,8 @@ public function testSample($data) /** * Tests the sample() method with a traversable non-iterator - * - * @return void */ - public function testSampleWithTraversableNonIterator() + public function testSampleWithTraversableNonIterator(): void { $collection = new Collection($this->datePeriod('2017-01-01', '2017-01-07')); $result = $collection->sample(3)->toList(); @@ -960,10 +1071,8 @@ public function testSampleWithTraversableNonIterator() /** * Test toArray method - * - * @return void */ - public function testToArray() + public function testToArray(): void { $data = ['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4]; $collection = new Collection($data); @@ -972,22 +1081,18 @@ public function testToArray() /** * Test toList method - * - * @dataProvider simpleProvider - * @return void */ - public function testToList($data) + #[DataProvider('simpleProvider')] + public function testToList(iterable $data): void { $collection = new Collection($data); $this->assertEquals([1, 2, 3, 4], $collection->toList()); } /** - * Test json encoding - * - * @return void + * Test JSON encoding */ - public function testToJson() + public function testToJson(): void { $data = [1, 2, 3, 4]; $collection = new Collection($data); @@ -995,36 +1100,31 @@ public function testToJson() } /** - * Tests that only arrays and Traversables are allowed in the constructor - * - * @return void + * Tests that Count returns the number of elements */ - public function testInvalidConstructorArgument() + #[DataProvider('simpleProvider')] + public function testCollectionCount(iterable $list): void { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Only an array or \Traversable is allowed for Collection'); - new Collection('Derp'); + $list = (new Collection($list))->buffered(); + $collection = new Collection($list); + $this->assertSame(8, $collection->append($list)->count()); } /** - * Tests that issuing a count will throw an exception - * - * @return void + * Tests that countKeys returns the number of unique keys */ - public function testCollectionCount() + #[DataProvider('simpleProvider')] + public function testCollectionCountKeys(iterable $list): void { - $this->expectException(\LogicException::class); - $data = [1, 2, 3, 4]; - $collection = new Collection($data); - $collection->count(); + $list = (new Collection($list))->buffered(); + $collection = new Collection($list); + $this->assertSame(4, $collection->append($list)->countKeys()); } /** * Tests take method - * - * @return void */ - public function testTake() + public function testTake(): void { $data = [1, 2, 3, 4]; $collection = new Collection($data); @@ -1050,27 +1150,23 @@ public function testTake() /** * Tests the take() method with a traversable non-iterator - * - * @return void */ - public function testTakeWithTraversableNonIterator() + public function testTakeWithTraversableNonIterator(): void { $collection = new Collection($this->datePeriod('2017-01-01', '2017-01-07')); $result = $collection->take(3, 1)->toList(); $expected = [ - new \DateTime('2017-01-02'), - new \DateTime('2017-01-03'), - new \DateTime('2017-01-04'), + new DateTime('2017-01-02'), + new DateTime('2017-01-03'), + new DateTime('2017-01-04'), ]; $this->assertEquals($expected, $result); } /** * Tests match - * - * @return void */ - public function testMatch() + public function testMatch(): void { $items = [ ['id' => 1, 'name' => 'foo', 'thing' => ['parent_id' => 10]], @@ -1084,7 +1180,7 @@ public function testMatch() $matched = $collection->match(['thing.parent_id' => 10]); $this->assertEquals( [0 => $items[0], 2 => $items[2]], - $matched->toArray() + $matched->toArray(), ); $matched = $collection->match(['thing.parent_id' => 500]); @@ -1096,10 +1192,8 @@ public function testMatch() /** * Tests firstMatch - * - * @return void */ - public function testFirstMatch() + public function testFirstMatch(): void { $items = [ ['id' => 1, 'name' => 'foo', 'thing' => ['parent_id' => 10]], @@ -1110,22 +1204,20 @@ public function testFirstMatch() $matched = $collection->firstMatch(['thing.parent_id' => 10]); $this->assertEquals( ['id' => 1, 'name' => 'foo', 'thing' => ['parent_id' => 10]], - $matched + $matched, ); $matched = $collection->firstMatch(['thing.parent_id' => 10, 'name' => 'baz']); $this->assertEquals( ['id' => 3, 'name' => 'baz', 'thing' => ['parent_id' => 10]], - $matched + $matched, ); } /** * Tests the append method - * - * @return void */ - public function testAppend() + public function testAppend(): void { $collection = new Collection([1, 2, 3]); $combined = $collection->append([4, 5, 6]); @@ -1137,17 +1229,76 @@ public function testAppend() } /** - * Tests the append method with iterator + * Tests the appendItem method */ - public function testAppendIterator() + public function testAppendItem(): void { $collection = new Collection([1, 2, 3]); - $iterator = new ArrayIterator([4, 5, 6]); - $combined = $collection->append($iterator); - $this->assertEquals([1, 2, 3, 4, 5, 6], $combined->toList()); + $combined = $collection->appendItem(4); + $this->assertEquals([1, 2, 3, 4], $combined->toArray(false)); + + $collection = new Collection(['a' => 1, 'b' => 2]); + $combined = $collection->appendItem(3, 'c'); + $combined = $combined->appendItem(4, 'a'); + $this->assertEquals(['a' => 4, 'b' => 2, 'c' => 3], $combined->toArray()); + } + + /** + * Tests the prepend method + */ + public function testPrepend(): void + { + $collection = new Collection([1, 2, 3]); + $combined = $collection->prepend(['a']); + $this->assertEquals(['a', 1, 2, 3], $combined->toList()); + + $collection = new Collection(['c' => 3, 'd' => 4]); + $combined = $collection->prepend(['a' => 1, 'b' => 2]); + $this->assertEquals(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4], $combined->toArray()); + } + + /** + * Tests prependItem method + */ + public function testPrependItem(): void + { + $collection = new Collection([1, 2, 3]); + $combined = $collection->prependItem('a'); + $this->assertEquals(['a', 1, 2, 3], $combined->toList()); + + $collection = new Collection(['c' => 3, 'd' => 4]); + $combined = $collection->prependItem(2, 'b'); + $combined = $combined->prependItem(1, 'a'); + $this->assertEquals(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4], $combined->toArray()); + } + + /** + * Tests prependItem method + */ + public function testPrependItemPreserveKeys(): void + { + $collection = new Collection([1, 2, 3]); + $combined = $collection->prependItem('a'); + $this->assertEquals(['a', 1, 2, 3], $combined->toList()); + + $collection = new Collection(['c' => 3, 'd' => 4]); + $combined = $collection->prependItem(2, 'b'); + $combined = $combined->prependItem(1, 'a'); + $this->assertEquals(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4], $combined->toArray()); } - public function testAppendNotCollectionInstance() + /** + * Tests the append method with iterator + */ + public function testAppendIterator(): void + { + $collection = new Collection([1, 2, 3]); + $iterator = new ArrayIterator([4, 5, 6]); + $combined = $collection->append($iterator); + $this->assertEquals([1, 2, 3, 4, 5, 6], $combined->toList()); + } + + public function testAppendNotCollectionInstance(): void { $collection = new TestCollection([1, 2, 3]); $combined = $collection->append([4, 5, 6]); @@ -1157,41 +1308,30 @@ public function testAppendNotCollectionInstance() /** * Tests that by calling compile internal iteration operations are not done * more than once - * - * @return void */ - public function testCompile() + public function testCompile(): void { $items = ['a' => 1, 'b' => 2, 'c' => 3]; $collection = new Collection($items); - $callable = $this->getMockBuilder(\StdClass::class) - ->setMethods(['__invoke']) - ->getMock(); - $callable->expects($this->at(0)) - ->method('__invoke') - ->with(1, 'a') - ->will($this->returnValue(4)); - $callable->expects($this->at(1)) - ->method('__invoke') - ->with(2, 'b') - ->will($this->returnValue(5)); - $callable->expects($this->at(2)) - ->method('__invoke') - ->with(3, 'c') - ->will($this->returnValue(6)); - $compiled = $collection->map($callable)->compile(); - $this->assertEquals(['a' => 4, 'b' => 5, 'c' => 6], $compiled->toArray()); - $this->assertEquals(['a' => 4, 'b' => 5, 'c' => 6], $compiled->toArray()); + $results = []; + $compiled = $collection + ->map(function ($value, $key) use (&$results) { + $results[] = [$key => $value]; + + return $value + 3; + }) + ->compile(); + $this->assertSame(['a' => 4, 'b' => 5, 'c' => 6], $compiled->toArray()); + $this->assertSame(['a' => 4, 'b' => 5, 'c' => 6], $compiled->toArray()); + $this->assertSame([['a' => 1], ['b' => 2], ['c' => 3]], $results); } /** * Tests converting a non rewindable iterator into a rewindable one using * the buffered method. - * - * @return void */ - public function testBuffered() + public function testBuffered(): void { $items = new NoRewindIterator(new ArrayIterator(['a' => 4, 'b' => 5, 'c' => 6])); $buffered = (new Collection($items))->buffered(); @@ -1199,17 +1339,34 @@ public function testBuffered() $this->assertEquals(['a' => 4, 'b' => 5, 'c' => 6], $buffered->toArray()); } + public function testBufferedIterator(): void + { + $data = [ + ['myField' => '1'], + ['myField' => '2'], + ['myField' => '3'], + ]; + $buffered = (new Collection($data))->buffered(); + // Check going forwards + $this->assertNotEmpty($buffered->firstMatch(['myField' => '1'])); + $this->assertNotEmpty($buffered->firstMatch(['myField' => '2'])); + $this->assertNotEmpty($buffered->firstMatch(['myField' => '3'])); + + // And backwards. + $this->assertNotEmpty($buffered->firstMatch(['myField' => '3'])); + $this->assertNotEmpty($buffered->firstMatch(['myField' => '2'])); + $this->assertNotEmpty($buffered->firstMatch(['myField' => '1'])); + } + /** * Tests the combine method - * - * @return void */ - public function testCombine() + public function testCombine(): void { $items = [ ['id' => 1, 'name' => 'foo', 'parent' => 'a'], ['id' => 2, 'name' => 'bar', 'parent' => 'b'], - ['id' => 3, 'name' => 'baz', 'parent' => 'a'] + ['id' => 3, 'name' => 'baz', 'parent' => 'a'], ]; $collection = (new Collection($items))->combine('id', 'name'); $expected = [1 => 'foo', 2 => 'bar', 3 => 'baz']; @@ -1226,7 +1383,7 @@ public function testCombine() $expected = [ '0-1' => ['foo-0-1' => '0-1-foo'], '1-2' => ['bar-1-2' => '1-2-bar'], - '2-3' => ['baz-2-3' => '2-3-baz'] + '2-3' => ['baz-2-3' => '2-3-baz'], ]; $collection = (new Collection($items))->combine( function ($value, $key) { @@ -1237,20 +1394,75 @@ function ($value, $key) { }, function ($value, $key) { return $key . '-' . $value['id']; - } + }, ); $this->assertEquals($expected, $collection->toArray()); $collection = (new Collection($items))->combine('id', 'crazy'); $this->assertEquals([1 => null, 2 => null, 3 => null], $collection->toArray()); + + $collection = (new Collection([ + ['amount' => 10, 'article_status' => ArticleStatus::from('Y')], + ['amount' => 2, 'article_status' => ArticleStatus::from('N')], + ]))->combine('article_status', 'amount'); + $this->assertEquals(['Y' => 10, 'N' => 2], $collection->toArray()); + } + + public function testCombineWithNonBackedEnum(): void + { + $collection = (new Collection([ + ['amount' => 10, 'type' => NonBacked::Basic], + ['amount' => 2, 'type' => NonBacked::Advanced], + ]))->combine('type', 'amount'); + $this->assertEquals(['Basic' => 10, 'Advanced' => 2], $collection->toArray()); + } + + public function testCombineNullKey(): void + { + $items = [ + ['id' => 1, 'name' => 'foo', 'parent' => 'a'], + ['id' => null, 'name' => 'bar', 'parent' => 'b'], + ['id' => 3, 'name' => 'baz', 'parent' => 'a'], + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot index by path that does not exist or contains a null value'); + + (new Collection($items))->combine('id', 'name'); + } + + public function testCombineNullGroup(): void + { + $items = [ + ['id' => 1, 'name' => 'foo', 'parent' => 'a'], + ['id' => 2, 'name' => 'bar', 'parent' => 'b'], + ['id' => 3, 'name' => 'baz', 'parent' => null], + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot group by path that does not exist or contains a null value'); + + (new Collection($items))->combine('id', 'name', 'parent'); + } + + public function testCombineGroupNullKey(): void + { + $items = [ + ['id' => 1, 'name' => 'foo', 'parent' => 'a'], + ['id' => 2, 'name' => 'bar', 'parent' => 'b'], + ['id' => null, 'name' => 'baz', 'parent' => 'a'], + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot index by path that does not exist or contains a null value'); + + (new Collection($items))->combine('id', 'name', 'parent'); } /** * Tests the nest method with only one level - * - * @return void */ - public function testNest() + public function testNest(): void { $items = [ ['id' => 1, 'parent_id' => null], @@ -1262,7 +1474,7 @@ public function testNest() ['id' => 7, 'parent_id' => 1], ['id' => 8, 'parent_id' => 6], ['id' => 9, 'parent_id' => 6], - ['id' => 10, 'parent_id' => 6] + ['id' => 10, 'parent_id' => 6], ]; $collection = (new Collection($items))->nest('id', 'parent_id'); $expected = [ @@ -1273,8 +1485,8 @@ public function testNest() ['id' => 2, 'parent_id' => 1, 'children' => []], ['id' => 3, 'parent_id' => 1, 'children' => []], ['id' => 4, 'parent_id' => 1, 'children' => []], - ['id' => 7, 'parent_id' => 1, 'children' => []] - ] + ['id' => 7, 'parent_id' => 1, 'children' => []], + ], ], [ 'id' => 6, @@ -1283,19 +1495,17 @@ public function testNest() ['id' => 5, 'parent_id' => 6, 'children' => []], ['id' => 8, 'parent_id' => 6, 'children' => []], ['id' => 9, 'parent_id' => 6, 'children' => []], - ['id' => 10, 'parent_id' => 6, 'children' => []] - ] - ] + ['id' => 10, 'parent_id' => 6, 'children' => []], + ], + ], ]; $this->assertEquals($expected, $collection->toArray()); } /** * Tests the nest method with alternate nesting key - * - * @return void */ - public function testNestAlternateNestingKey() + public function testNestAlternateNestingKey(): void { $items = [ ['id' => 1, 'parent_id' => null], @@ -1307,7 +1517,7 @@ public function testNestAlternateNestingKey() ['id' => 7, 'parent_id' => 1], ['id' => 8, 'parent_id' => 6], ['id' => 9, 'parent_id' => 6], - ['id' => 10, 'parent_id' => 6] + ['id' => 10, 'parent_id' => 6], ]; $collection = (new Collection($items))->nest('id', 'parent_id', 'nodes'); $expected = [ @@ -1318,8 +1528,8 @@ public function testNestAlternateNestingKey() ['id' => 2, 'parent_id' => 1, 'nodes' => []], ['id' => 3, 'parent_id' => 1, 'nodes' => []], ['id' => 4, 'parent_id' => 1, 'nodes' => []], - ['id' => 7, 'parent_id' => 1, 'nodes' => []] - ] + ['id' => 7, 'parent_id' => 1, 'nodes' => []], + ], ], [ 'id' => 6, @@ -1328,19 +1538,17 @@ public function testNestAlternateNestingKey() ['id' => 5, 'parent_id' => 6, 'nodes' => []], ['id' => 8, 'parent_id' => 6, 'nodes' => []], ['id' => 9, 'parent_id' => 6, 'nodes' => []], - ['id' => 10, 'parent_id' => 6, 'nodes' => []] - ] - ] + ['id' => 10, 'parent_id' => 6, 'nodes' => []], + ], + ], ]; $this->assertEquals($expected, $collection->toArray()); } /** * Tests the nest method with more than one level - * - * @return void */ - public function testNestMultiLevel() + public function testNestMultiLevel(): void { $items = [ ['id' => 1, 'parent_id' => null], @@ -1352,7 +1560,7 @@ public function testNestMultiLevel() ['id' => 7, 'parent_id' => 3], ['id' => 8, 'parent_id' => 4], ['id' => 9, 'parent_id' => 6], - ['id' => 10, 'parent_id' => 6] + ['id' => 10, 'parent_id' => 6], ]; $collection = (new Collection($items))->nest('id', 'parent_id', 'nodes'); $expected = [ @@ -1369,38 +1577,36 @@ public function testNestMultiLevel() 'parent_id' => 2, 'nodes' => [ ['id' => 5, 'parent_id' => 3, 'nodes' => []], - ['id' => 7, 'parent_id' => 3, 'nodes' => []] - ] + ['id' => 7, 'parent_id' => 3, 'nodes' => []], + ], ], [ 'id' => 4, 'parent_id' => 2, 'nodes' => [ - ['id' => 8, 'parent_id' => 4, 'nodes' => []] - ] - ] - ] - ] - ] + ['id' => 8, 'parent_id' => 4, 'nodes' => []], + ], + ], + ], + ], + ], ], [ 'id' => 6, 'parent_id' => null, 'nodes' => [ ['id' => 9, 'parent_id' => 6, 'nodes' => []], - ['id' => 10, 'parent_id' => 6, 'nodes' => []] - ] - ] + ['id' => 10, 'parent_id' => 6, 'nodes' => []], + ], + ], ]; $this->assertEquals($expected, $collection->toArray()); } /** * Tests the nest method with more than one level - * - * @return void */ - public function testNestMultiLevelAlternateNestingKey() + public function testNestMultiLevelAlternateNestingKey(): void { $items = [ ['id' => 1, 'parent_id' => null], @@ -1412,7 +1618,7 @@ public function testNestMultiLevelAlternateNestingKey() ['id' => 7, 'parent_id' => 3], ['id' => 8, 'parent_id' => 4], ['id' => 9, 'parent_id' => 6], - ['id' => 10, 'parent_id' => 6] + ['id' => 10, 'parent_id' => 6], ]; $collection = (new Collection($items))->nest('id', 'parent_id'); $expected = [ @@ -1429,38 +1635,36 @@ public function testNestMultiLevelAlternateNestingKey() 'parent_id' => 2, 'children' => [ ['id' => 5, 'parent_id' => 3, 'children' => []], - ['id' => 7, 'parent_id' => 3, 'children' => []] - ] + ['id' => 7, 'parent_id' => 3, 'children' => []], + ], ], [ 'id' => 4, 'parent_id' => 2, 'children' => [ - ['id' => 8, 'parent_id' => 4, 'children' => []] - ] - ] - ] - ] - ] + ['id' => 8, 'parent_id' => 4, 'children' => []], + ], + ], + ], + ], + ], ], [ 'id' => 6, 'parent_id' => null, 'children' => [ ['id' => 9, 'parent_id' => 6, 'children' => []], - ['id' => 10, 'parent_id' => 6, 'children' => []] - ] - ] + ['id' => 10, 'parent_id' => 6, 'children' => []], + ], + ], ]; $this->assertEquals($expected, $collection->toArray()); } /** * Tests the nest method with more than one level - * - * @return void */ - public function testNestObjects() + public function testNestObjects(): void { $items = [ new ArrayObject(['id' => 1, 'parent_id' => null]), @@ -1472,7 +1676,7 @@ public function testNestObjects() new ArrayObject(['id' => 7, 'parent_id' => 3]), new ArrayObject(['id' => 8, 'parent_id' => 4]), new ArrayObject(['id' => 9, 'parent_id' => 6]), - new ArrayObject(['id' => 10, 'parent_id' => 6]) + new ArrayObject(['id' => 10, 'parent_id' => 6]), ]; $collection = (new Collection($items))->nest('id', 'parent_id'); $expected = [ @@ -1489,38 +1693,36 @@ public function testNestObjects() 'parent_id' => 2, 'children' => [ new ArrayObject(['id' => 5, 'parent_id' => 3, 'children' => []]), - new ArrayObject(['id' => 7, 'parent_id' => 3, 'children' => []]) - ] + new ArrayObject(['id' => 7, 'parent_id' => 3, 'children' => []]), + ], ]), new ArrayObject([ 'id' => 4, 'parent_id' => 2, 'children' => [ - new ArrayObject(['id' => 8, 'parent_id' => 4, 'children' => []]) - ] - ]) - ] - ]) - ] + new ArrayObject(['id' => 8, 'parent_id' => 4, 'children' => []]), + ], + ]), + ], + ]), + ], ]), new ArrayObject([ 'id' => 6, 'parent_id' => null, 'children' => [ new ArrayObject(['id' => 9, 'parent_id' => 6, 'children' => []]), - new ArrayObject(['id' => 10, 'parent_id' => 6, 'children' => []]) - ] - ]) + new ArrayObject(['id' => 10, 'parent_id' => 6, 'children' => []]), + ], + ]), ]; $this->assertEquals($expected, $collection->toArray()); } /** * Tests the nest method with more than one level - * - * @return void */ - public function testNestObjectsAlternateNestingKey() + public function testNestObjectsAlternateNestingKey(): void { $items = [ new ArrayObject(['id' => 1, 'parent_id' => null]), @@ -1532,7 +1734,7 @@ public function testNestObjectsAlternateNestingKey() new ArrayObject(['id' => 7, 'parent_id' => 3]), new ArrayObject(['id' => 8, 'parent_id' => 4]), new ArrayObject(['id' => 9, 'parent_id' => 6]), - new ArrayObject(['id' => 10, 'parent_id' => 6]) + new ArrayObject(['id' => 10, 'parent_id' => 6]), ]; $collection = (new Collection($items))->nest('id', 'parent_id', 'nodes'); $expected = [ @@ -1549,70 +1751,66 @@ public function testNestObjectsAlternateNestingKey() 'parent_id' => 2, 'nodes' => [ new ArrayObject(['id' => 5, 'parent_id' => 3, 'nodes' => []]), - new ArrayObject(['id' => 7, 'parent_id' => 3, 'nodes' => []]) - ] + new ArrayObject(['id' => 7, 'parent_id' => 3, 'nodes' => []]), + ], ]), new ArrayObject([ 'id' => 4, 'parent_id' => 2, 'nodes' => [ - new ArrayObject(['id' => 8, 'parent_id' => 4, 'nodes' => []]) - ] - ]) - ] - ]) - ] + new ArrayObject(['id' => 8, 'parent_id' => 4, 'nodes' => []]), + ], + ]), + ], + ]), + ], ]), new ArrayObject([ 'id' => 6, 'parent_id' => null, 'nodes' => [ new ArrayObject(['id' => 9, 'parent_id' => 6, 'nodes' => []]), - new ArrayObject(['id' => 10, 'parent_id' => 6, 'nodes' => []]) - ] - ]) + new ArrayObject(['id' => 10, 'parent_id' => 6, 'nodes' => []]), + ], + ]), ]; $this->assertEquals($expected, $collection->toArray()); } /** * Tests insert - * - * @return void */ - public function testInsert() + public function testInsert(): void { $items = [['a' => 1], ['b' => 2]]; $collection = new Collection($items); $iterator = $collection->insert('c', [3, 4]); - $this->assertInstanceOf('Cake\Collection\Iterator\InsertIterator', $iterator); + $this->assertInstanceOf(InsertIterator::class, $iterator); $this->assertEquals( [['a' => 1, 'c' => 3], ['b' => 2, 'c' => 4]], - iterator_to_array($iterator) + iterator_to_array($iterator), ); } /** * Provider for testing each of the directions for listNested * - * @return void + * @return array */ - public function nestedListProvider() + public static function nestedListProvider(): array { return [ ['desc', [1, 2, 3, 5, 7, 4, 8, 6, 9, 10]], ['asc', [5, 7, 3, 8, 4, 2, 1, 9, 10, 6]], - ['leaves', [5, 7, 8, 9, 10]] + ['leaves', [5, 7, 8, 9, 10]], ]; } /** * Tests the listNested method with the default 'children' nesting key - * - * @dataProvider nestedListProvider - * @return void */ - public function testListNested($dir, $expected) + #[DataProvider('nestedListProvider')] + public function testListNested(string $dir, array $expected): void { $items = [ ['id' => 1, 'parent_id' => null], @@ -1624,22 +1822,45 @@ public function testListNested($dir, $expected) ['id' => 7, 'parent_id' => 3], ['id' => 8, 'parent_id' => 4], ['id' => 9, 'parent_id' => 6], - ['id' => 10, 'parent_id' => 6] + ['id' => 10, 'parent_id' => 6], ]; $collection = (new Collection($items))->nest('id', 'parent_id')->listNested($dir); $this->assertEquals($expected, $collection->extract('id')->toArray(false)); } + /** + * Tests the listNested spacer output. + */ + public function testListNestedSpacer(): void + { + $items = [ + ['id' => 1, 'parent_id' => null, 'name' => 'Birds'], + ['id' => 2, 'parent_id' => 1, 'name' => 'Land Birds'], + ['id' => 3, 'parent_id' => 1, 'name' => 'Eagle'], + ['id' => 4, 'parent_id' => 1, 'name' => 'Seagull'], + ['id' => 5, 'parent_id' => 6, 'name' => 'Clown Fish'], + ['id' => 6, 'parent_id' => null, 'name' => 'Fish'], + ]; + $collection = (new Collection($items))->nest('id', 'parent_id')->listNested(); + $expected = [ + 'Birds', + '---Land Birds', + '---Eagle', + '---Seagull', + 'Fish', + '---Clown Fish', + ]; + $this->assertSame($expected, $collection->printer('name', 'id', '---')->toList()); + } + /** * Tests using listNested with a different nesting key - * - * @return void */ - public function testListNestedCustomKey() + public function testListNestedCustomKey(): void { $items = [ ['id' => 1, 'stuff' => [['id' => 2, 'stuff' => [['id' => 3]]]]], - ['id' => 4, 'stuff' => [['id' => 5]]] + ['id' => 4, 'stuff' => [['id' => 5]]], ]; $collection = (new Collection($items))->listNested('desc', 'stuff'); $this->assertEquals(range(1, 5), $collection->extract('id')->toArray(false)); @@ -1647,17 +1868,15 @@ public function testListNestedCustomKey() /** * Tests flattening the collection using a custom callable function - * - * @return void */ - public function testListNestedWithCallable() + public function testListNestedWithCallable(): void { $items = [ ['id' => 1, 'stuff' => [['id' => 2, 'stuff' => [['id' => 3]]]]], - ['id' => 4, 'stuff' => [['id' => 5]]] + ['id' => 4, 'stuff' => [['id' => 5]]], ]; $collection = (new Collection($items))->listNested('desc', function ($item) { - return isset($item['stuff']) ? $item['stuff'] : []; + return $item['stuff'] ?? []; }); $this->assertEquals(range(1, 5), $collection->extract('id')->toArray(false)); } @@ -1667,51 +1886,56 @@ public function testListNestedWithCallable() * * @return array */ - public function sumOfProvider() + public static function sumOfProvider(): array { $items = [ ['invoice' => ['total' => 100]], - ['invoice' => ['total' => 200]] + ['invoice' => ['total' => 200]], + ]; + + $floatItems = [ + ['invoice' => ['total' => 100.0]], + ['invoice' => ['total' => 200.0]], ]; return [ - 'array' => [$items], - 'iterator' => [$this->yieldItems($items)] + 'array' => [$items, 300], + 'iterator' => [self::yieldItems($items), 300], + 'floatArray' => [$floatItems, 300.0], + 'floatIterator' => [self::yieldItems($floatItems), 300.0], ]; } /** * Tests the sumOf method * - * @dataProvider sumOfProvider - * @return void + * @param float|int $expected */ - public function testSumOf($items) + #[DataProvider('sumOfProvider')] + public function testSumOf(iterable $items, $expected): void { - $this->assertEquals(300, (new Collection($items))->sumOf('invoice.total')); + $this->assertEquals($expected, (new Collection($items))->sumOf('invoice.total')); } /** * Tests the sumOf method * - * @dataProvider sumOfProvider - * @return void + * @param float|int $expected */ - public function testSumOfCallable($items) + #[DataProvider('sumOfProvider')] + public function testSumOfCallable(iterable $items, $expected): void { $sum = (new Collection($items))->sumOf(function ($v) { - return $v['invoice']['total'] * 2; + return $v['invoice']['total']; }); - $this->assertEquals(600, $sum); + $this->assertEquals($expected, $sum); } /** * Tests the stopWhen method with a callable - * - * @dataProvider simpleProvider - * @return void */ - public function testStopWhenCallable($items) + #[DataProvider('simpleProvider')] + public function testStopWhenCallable(iterable $items): void { $collection = (new Collection($items))->stopWhen(function ($v) { return $v > 3; @@ -1721,15 +1945,13 @@ public function testStopWhenCallable($items) /** * Tests the stopWhen method with a matching array - * - * @return void */ - public function testStopWhenWithArray() + public function testStopWhenWithArray(): void { $items = [ ['foo' => 'bar'], ['foo' => 'baz'], - ['foo' => 'foo'] + ['foo' => 'foo'], ]; $collection = (new Collection($items))->stopWhen(['foo' => 'baz']); $this->assertEquals([['foo' => 'bar']], $collection->toArray()); @@ -1737,15 +1959,13 @@ public function testStopWhenWithArray() /** * Tests the unfold method - * - * @return void */ - public function testUnfold() + public function testUnfold(): void { $items = [ [1, 2, 3, 4], [5, 6], - [7, 8] + [7, 8], ]; $collection = (new Collection($items))->unfold(); @@ -1753,7 +1973,7 @@ public function testUnfold() $items = [ [1, 2], - new Collection([3, 4]) + new Collection([3, 4]), ]; $collection = (new Collection($items))->unfold(); $this->assertEquals(range(1, 4), $collection->toArray(false)); @@ -1761,10 +1981,8 @@ public function testUnfold() /** * Tests the unfold method with empty levels - * - * @return void */ - public function testUnfoldEmptyLevels() + public function testUnfoldEmptyLevels(): void { $items = [[], [1, 2], []]; $collection = (new Collection($items))->unfold(); @@ -1777,10 +1995,8 @@ public function testUnfoldEmptyLevels() /** * Tests the unfold when passing a callable - * - * @return void */ - public function testUnfoldWithCallable() + public function testUnfoldWithCallable(): void { $items = [1, 2, 3]; $collection = (new Collection($items))->unfold(function ($item) { @@ -1792,10 +2008,8 @@ public function testUnfoldWithCallable() /** * Tests the through() method - * - * @return void */ - public function testThrough() + public function testThrough(): void { $items = [1, 2, 3]; $collection = (new Collection($items))->through(function ($collection) { @@ -1807,10 +2021,8 @@ public function testThrough() /** * Tests the through method when it returns an array - * - * @return void */ - public function testThroughReturnArray() + public function testThroughReturnArray(): void { $items = [1, 2, 3]; $collection = (new Collection($items))->through(function ($collection) { @@ -1825,16 +2037,14 @@ public function testThroughReturnArray() /** * Tests that the sortBy method does not die when something that is not a * collection is passed - * - * @return void */ - public function testComplexSortBy() + public function testComplexSortBy(): void { $results = collection([3, 7]) ->unfold(function ($value) { return [ ['sorting' => $value * 2], - ['sorting' => $value * 2] + ['sorting' => $value * 2], ]; }) ->sortBy('sorting') @@ -1845,10 +2055,8 @@ public function testComplexSortBy() /** * Tests __debugInfo() or debug() usage - * - * @return void */ - public function testDebug() + public function testDebug(): void { $items = [1, 2, 3]; @@ -1857,6 +2065,7 @@ public function testDebug() $result = $collection->__debugInfo(); $expected = [ 'count' => 3, + 'items' => [1, 2, 3], ]; $this->assertSame($expected, $result); @@ -1864,6 +2073,7 @@ public function testDebug() $result = $collection->__debugInfo(); $expected = [ 'count' => 3, + 'items' => [1, 2, 3], ]; $this->assertSame($expected, $result); @@ -1872,25 +2082,26 @@ public function testDebug() $collection = new Collection($iterator); $result = $collection->__debugInfo(); - $expected = [ - 'count' => 3, - ]; - $this->assertSame($expected, $result); + $this->assertStringContainsString('NoRewindIterator', $result['innerIterator']::class); // Calling it again will in this case not rewind $result = $collection->__debugInfo(); - $expected = [ - 'count' => 0, - ]; - $this->assertSame($expected, $result); + $this->assertStringContainsString('NoRewindIterator', $result['innerIterator']::class); + + $filter = function ($value): void { + throw new Exception('filter exception'); + }; + $iterator = new CallbackFilterIterator(new ArrayIterator($items), $filter); + $collection = new Collection($iterator); + + $result = $collection->__debugInfo(); + $this->assertStringContainsString('CallbackFilterIterator', $result['innerIterator']::class); } /** * Tests the isEmpty() method - * - * @return void */ - public function testIsEmpty() + public function testIsEmpty(): void { $collection = new Collection([1, 2, 3]); $this->assertFalse($collection->isEmpty()); @@ -1907,13 +2118,11 @@ public function testIsEmpty() /** * Tests the isEmpty() method does not consume data * from buffered iterators. - * - * @return void */ - public function testIsEmptyDoesNotConsume() + public function testIsEmptyDoesNotConsume(): void { - $array = new \ArrayIterator([1, 2, 3]); - $inner = new \Cake\Collection\Iterator\BufferedIterator($array); + $array = new ArrayIterator([1, 2, 3]); + $inner = new BufferedIterator($array); $collection = new Collection($inner); $this->assertFalse($collection->isEmpty()); $this->assertCount(3, $collection->toArray()); @@ -1921,10 +2130,8 @@ public function testIsEmptyDoesNotConsume() /** * Tests the zip() method - * - * @return void */ - public function testZip() + public function testZip(): void { $collection = new Collection([1, 2]); $zipped = $collection->zip([3, 4]); @@ -1938,16 +2145,14 @@ public function testZip() $zipped = $collection->zip([3, 4], [5, 6], [7, 8], [9, 10, 11]); $this->assertEquals([ [1, 3, 5, 7, 9], - [2, 4, 6, 8, 10] + [2, 4, 6, 8, 10], ], $zipped->toList()); } /** * Tests the zipWith() method - * - * @return void */ - public function testZipWith() + public function testZipWith(): void { $collection = new Collection([1, 2]); $zipped = $collection->zipWith([3, 4], function ($a, $b) { @@ -1963,69 +2168,71 @@ public function testZipWith() /** * Tests the skip() method - * - * @return void */ - public function testSkip() + public function testSkip(): void { $collection = new Collection([1, 2, 3, 4, 5]); $this->assertEquals([3, 4, 5], $collection->skip(2)->toList()); + $this->assertEquals([1, 2, 3, 4, 5], $collection->skip(0)->toList()); + $this->assertEquals([4, 5], $collection->skip(3)->toList()); $this->assertEquals([5], $collection->skip(4)->toList()); } + /** + * Test skip() with an overflow + */ + public function testSkipOverflow(): void + { + $collection = new Collection([1, 2, 3]); + $this->assertEquals([], $collection->skip(3)->toArray()); + $this->assertEquals([], $collection->skip(4)->toArray()); + } + /** * Tests the skip() method with a traversable non-iterator - * - * @return void */ - public function testSkipWithTraversableNonIterator() + public function testSkipWithTraversableNonIterator(): void { $collection = new Collection($this->datePeriod('2017-01-01', '2017-01-07')); $result = $collection->skip(3)->toList(); $expected = [ - new \DateTime('2017-01-04'), - new \DateTime('2017-01-05'), - new \DateTime('2017-01-06'), + new DateTime('2017-01-04'), + new DateTime('2017-01-05'), + new DateTime('2017-01-06'), ]; $this->assertEquals($expected, $result); } /** * Tests the first() method with a traversable non-iterator - * - * @return void */ - public function testFirstWithTraversableNonIterator() + public function testFirstWithTraversableNonIterator(): void { $collection = new Collection($this->datePeriod('2017-01-01', '2017-01-07')); $date = $collection->first(); $this->assertInstanceOf('DateTime', $date); - $this->assertEquals('2017-01-01', $date->format('Y-m-d')); + $this->assertSame('2017-01-01', $date->format('Y-m-d')); } /** * Tests the last() method - * - * @return void */ - public function testLast() + public function testLast(): void { $collection = new Collection([1, 2, 3]); - $this->assertEquals(3, $collection->last()); + $this->assertSame(3, $collection->last()); $collection = $collection->map(function ($e) { return $e * 2; }); - $this->assertEquals(6, $collection->last()); + $this->assertSame(6, $collection->last()); } /** * Tests the last() method when on an empty collection - * - * @return void */ - public function testLastWithEmptyCollection() + public function testLastWithEmptyCollection(): void { $collection = new Collection([]); $this->assertNull($collection->last()); @@ -2033,21 +2240,17 @@ public function testLastWithEmptyCollection() /** * Tests the last() method with a countable object - * - * @return void */ - public function testLastWithCountable() + public function testLastWithCountable(): void { $collection = new Collection(new ArrayObject([1, 2, 3])); - $this->assertEquals(3, $collection->last()); + $this->assertSame(3, $collection->last()); } /** * Tests the last() method with an empty countable object - * - * @return void */ - public function testLastWithEmptyCountable() + public function testLastWithEmptyCountable(): void { $collection = new Collection(new ArrayObject([])); $this->assertNull($collection->last()); @@ -2055,49 +2258,115 @@ public function testLastWithEmptyCountable() /** * Tests the last() method with a non-rewindable iterator - * - * @return void */ - public function testLastWithNonRewindableIterator() + public function testLastWithNonRewindableIterator(): void { $iterator = new NoRewindIterator(new ArrayIterator([1, 2, 3])); $collection = new Collection($iterator); - $this->assertEquals(3, $collection->last()); + $this->assertSame(3, $collection->last()); } /** * Tests the last() method with a traversable non-iterator - * - * @return void */ - public function testLastWithTraversableNonIterator() + public function testLastWithTraversableNonIterator(): void { $collection = new Collection($this->datePeriod('2017-01-01', '2017-01-07')); $date = $collection->last(); $this->assertInstanceOf('DateTime', $date); - $this->assertEquals('2017-01-06', $date->format('Y-m-d')); + $this->assertSame('2017-01-06', $date->format('Y-m-d')); } /** - * Tests sumOf with no parameters + * Tests the takeLast() method + * + * @param iterable $data The data to test with. + */ + #[DataProvider('simpleProvider')] + public function testLastN($data): void + { + $collection = new Collection($data); + $result = $collection->takeLast(3)->toArray(); + $expected = ['b' => 2, 'c' => 3, 'd' => 4]; + $this->assertEquals($expected, $result); + } + + /** + * Tests the takeLast() method with overflow + * + * @param iterable $data The data to test with. + */ + #[DataProvider('simpleProvider')] + public function testLastNtWithOverflow($data): void + { + $collection = new Collection($data); + $result = $collection->takeLast(10)->toArray(); + $expected = ['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4]; + $this->assertEquals($expected, $result); + } + + /** + * Tests the takeLast() with an odd numbers collection + * + * @param iterable $data The data to test with. + */ + #[DataProvider('simpleProvider')] + public function testLastNtWithOddData($data): void + { + $collection = new Collection($data); + $result = $collection->take(3)->takeLast(2)->toArray(); + $expected = ['b' => 2, 'c' => 3]; + $this->assertEquals($expected, $result); + } + + /** + * Tests the takeLast() with countable collection + */ + public function testLastNtWithCountable(): void + { + $rangeZeroToFive = range(0, 5); + + $collection = new Collection(new CountableIterator($rangeZeroToFive)); + $result = $collection->takeLast(2)->toList(); + $this->assertEquals([4, 5], $result); + + $collection = new Collection(new CountableIterator($rangeZeroToFive)); + $result = $collection->takeLast(1)->toList(); + $this->assertEquals([5], $result); + } + + /** + * Tests the takeLast() with countable collection * - * @return void + * @param iterable $data The data to test with. + */ + #[DataProvider('simpleProvider')] + public function testLastNtWithNegative($data): void + { + $collection = new Collection($data); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The takeLast method requires a number greater than 0.'); + + $collection->takeLast(-1)->toArray(); + } + + /** + * Tests sumOf with no parameters */ - public function testSumOfWithIdentity() + public function testSumOfWithIdentity(): void { $collection = new Collection([1, 2, 3]); - $this->assertEquals(6, $collection->sumOf()); + $this->assertSame(6, $collection->sumOf()); $collection = new Collection(['a' => 1, 'b' => 4, 'c' => 6]); - $this->assertEquals(11, $collection->sumOf()); + $this->assertSame(11, $collection->sumOf()); } /** * Tests using extract with the {*} notation - * - * @return void */ - public function testUnfoldedExtract() + public function testUnfoldedExtract(): void { $items = [ ['comments' => [['id' => 1], ['id' => 2]]], @@ -2112,32 +2381,32 @@ public function testUnfoldedExtract() [ 'comments' => [ [ - 'voters' => [['id' => 1], ['id' => 2]] - ] - ] + 'voters' => [['id' => 1], ['id' => 2]], + ], + ], ], [ 'comments' => [ [ - 'voters' => [['id' => 3], ['id' => 4]] - ] - ] + 'voters' => [['id' => 3], ['id' => 4]], + ], + ], ], [ 'comments' => [ [ - 'voters' => [['id' => 5], ['nope' => 'fail'], ['id' => 6]] - ] - ] + 'voters' => [['id' => 5], ['nope' => 'fail'], ['id' => 6]], + ], + ], ], [ 'comments' => [ [ - 'not_voters' => [['id' => 5]] - ] - ] + 'not_voters' => [['id' => 5]], + ], + ], ], - ['not_comments' => []] + ['not_comments' => []], ]; $extracted = (new Collection($items))->extract('comments.{*}.voters.{*}.id'); $expected = [1, 2, 3, 4, 5, null, 6]; @@ -2147,10 +2416,8 @@ public function testUnfoldedExtract() /** * Tests serializing a simple collection - * - * @return void */ - public function testSerializeSimpleCollection() + public function testSerializeSimpleCollection(): void { $collection = new Collection([1, 2, 3]); $serialized = serialize($collection); @@ -2161,10 +2428,8 @@ public function testSerializeSimpleCollection() /** * Tests serialization when using append - * - * @return void */ - public function testSerializeWithAppendIterators() + public function testSerializeWithAppendIterators(): void { $collection = new Collection([1, 2, 3]); $collection = $collection->append(['a' => 4, 'b' => 5, 'c' => 6]); @@ -2176,10 +2441,8 @@ public function testSerializeWithAppendIterators() /** * Tests serialization when using nested iterators - * - * @return void */ - public function testSerializeWithNestedIterators() + public function testSerializeWithNestedIterators(): void { $collection = new Collection([1, 2, 3]); $collection = $collection->map(function ($e) { @@ -2198,10 +2461,8 @@ public function testSerializeWithNestedIterators() /** * Tests serializing a zip() call - * - * @return void */ - public function testSerializeWithZipIterator() + public function testSerializeWithZipIterator(): void { $collection = new Collection([4, 5]); $collection = $collection->zip([1, 2]); @@ -2215,23 +2476,21 @@ public function testSerializeWithZipIterator() * * @return array */ - public function chunkProvider() + public static function chunkProvider(): array { $items = range(1, 10); return [ 'array' => [$items], - 'iterator' => [$this->yieldItems($items)] + 'iterator' => [self::yieldItems($items)], ]; } /** * Tests the chunk method with exact chunks - * - * @dataProvider chunkProvider - * @return void */ - public function testChunk($items) + #[DataProvider('chunkProvider')] + public function testChunk(iterable $items): void { $collection = new Collection($items); $chunked = $collection->chunk(2)->toList(); @@ -2241,10 +2500,8 @@ public function testChunk($items) /** * Tests the chunk method with overflowing chunk size - * - * @return void */ - public function testChunkOverflow() + public function testChunkOverflow(): void { $collection = new Collection(range(1, 11)); $chunked = $collection->chunk(2)->toList(); @@ -2254,10 +2511,8 @@ public function testChunkOverflow() /** * Tests the chunk method with non-scalar items - * - * @return void */ - public function testChunkNested() + public function testChunkNested(): void { $collection = new Collection([1, 2, 3, [4, 5], 6, [7, [8, 9], 10], 11]); $chunked = $collection->chunk(2)->toList(); @@ -2267,10 +2522,8 @@ public function testChunkNested() /** * Tests the chunkWithKeys method with exact chunks - * - * @return void */ - public function testChunkWithKeys() + public function testChunkWithKeys(): void { $collection = new Collection(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5, 'f' => 6]); $chunked = $collection->chunkWithKeys(2)->toList(); @@ -2280,10 +2533,8 @@ public function testChunkWithKeys() /** * Tests the chunkWithKeys method with overflowing chunk size - * - * @return void */ - public function testChunkWithKeysOverflow() + public function testChunkWithKeysOverflow(): void { $collection = new Collection(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5, 'f' => 6, 'g' => 7]); $chunked = $collection->chunkWithKeys(2)->toList(); @@ -2293,10 +2544,8 @@ public function testChunkWithKeysOverflow() /** * Tests the chunkWithKeys method with non-scalar items - * - * @return void */ - public function testChunkWithKeysNested() + public function testChunkWithKeysNested(): void { $collection = new Collection(['a' => 1, 'b' => 2, 'c' => 3, 'd' => [4, 5], 'e' => 6, 'f' => [7, [8, 9], 10], 'g' => 11]); $chunked = $collection->chunkWithKeys(2)->toList(); @@ -2306,10 +2555,8 @@ public function testChunkWithKeysNested() /** * Tests the chunkWithKeys method without preserving keys - * - * @return void */ - public function testChunkWithKeysNoPreserveKeys() + public function testChunkWithKeysNoPreserveKeys(): void { $collection = new Collection(['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5, 'f' => 6, 'g' => 7]); $chunked = $collection->chunkWithKeys(2, false)->toList(); @@ -2319,10 +2566,8 @@ public function testChunkWithKeysNoPreserveKeys() /** * Tests cartesianProduct - * - * @return void */ - public function testCartesianProduct() + public function testCartesianProduct(): void { $collection = new Collection([]); @@ -2450,29 +2695,28 @@ public function testCartesianProduct() /** * Tests that an exception is thrown if the cartesian product is called with multidimensional arrays - * - * @return void */ - public function testCartesianProductMultidimensionalArray() + public function testCartesianProductMultidimensionalArray(): void { - $this->expectException(\LogicException::class); + $this->expectException(LogicException::class); + $collection = new Collection([ [ 'names' => [ - 'alex', 'kostas', 'leon' - ] + 'alex', 'kostas', 'leon', + ], ], [ 'locations' => [ - 'crete', 'london', 'paris' - ] + 'crete', 'london', 'paris', + ], ], ]); - $result = $collection->cartesianProduct(); + $collection->cartesianProduct(); } - public function testTranspose() + public function testTranspose(): void { $collection = new Collection([ ['Products', '2012', '2013', '2014'], @@ -2494,12 +2738,11 @@ public function testTranspose() /** * Tests that provided arrays do not have even length - * - * @return void */ - public function testTransposeUnEvenLengthShouldThrowException() + public function testTransposeUnEvenLengthShouldThrowException(): void { - $this->expectException(\LogicException::class); + $this->expectException(LogicException::class); + $collection = new Collection([ ['Products', '2012', '2013', '2014'], ['Product A', '200', '100', '50'], @@ -2513,10 +2756,10 @@ public function testTransposeUnEvenLengthShouldThrowException() /** * Yields all the elements as passed * - * @param array $itmes the elements to be yielded - * @return void + * @param iterable $items the elements to be yielded + * @return \Generator */ - protected function yieldItems(array $items) + protected static function yieldItems(iterable $items): Generator { foreach ($items as $k => $v) { yield $k => $v; @@ -2528,36 +2771,327 @@ protected function yieldItems(array $items) * * @param string $start Start date * @param string $end End date - * @return \DatePeriod */ - protected function datePeriod($start, $end) + protected function datePeriod($start, $end): DatePeriod + { + return new DatePeriod(new DateTime($start), new DateInterval('P1D'), new DateTime($end)); + } + + /** + * Tests that elements in a lazy collection are not fetched immediately. + */ + public function testLazy(): void { - return new \DatePeriod(new \DateTime($start), new \DateInterval('P1D'), new \DateTime($end)); + $items = ['a' => 1, 'b' => 2, 'c' => 3]; + $collection = (new Collection($items))->lazy(); + $callable = new class { + public function __invoke(): never + { + throw new Exception('This should not be called'); + } + }; + + $collection->filter($callable)->filter($callable); + $this->assertTrue(true); } /** - * Tests to ensure that collection classes extending ArrayIterator work as expected. + * Tests that extending Collection does not cause infinite loops + * when iterating and calling methods like every() inside the loop. * - * @return void + * @see https://github.com/cakephp/cakephp/issues/17483 */ - public function testArrayIteratorExtend() + public function testExtendedCollectionNoInfiniteLoop(): void { - $iterator = new TestIterator(range(0, 10)); + $items = [ + ['id' => 1, 'name' => 'foo'], + ['id' => 2, 'name' => 'bar'], + ['id' => 3, 'name' => 'baz'], + ]; - $this->assertTrue(method_exists($iterator, 'checkValues')); - $this->assertTrue($iterator->checkValues()); + $collection = new class ($items) extends Collection { + }; - //We need to perform at least two collection operation to trigger the issue. - $newIterator = $iterator - ->filter(function ($item) { - return $item < 5; - }) - ->reject(function ($item) { - return $item > 2; - }); + $count = 0; + foreach ($collection as $item) { + $count++; + // Calling every() inside foreach should not cause infinite loop + $result = $collection->every(fn($i) => isset($i['id'])); + $this->assertTrue($result); + } + + $this->assertSame(3, $count); + } + + /** + * Tests the keys() method returns collection of keys + */ + public function testKeys(): void + { + $items = ['a' => 1, 'b' => 2, 'c' => 3]; + $collection = new Collection($items); + $keys = $collection->keys()->toList(); + + $this->assertSame(['a', 'b', 'c'], $keys); + } + + /** + * Tests keys() with numeric keys + */ + public function testKeysNumeric(): void + { + $items = [10 => 'a', 20 => 'b', 30 => 'c']; + $collection = new Collection($items); + $keys = $collection->keys()->toList(); + + $this->assertSame([10, 20, 30], $keys); + } + + /** + * Tests keys() on empty collection + */ + public function testKeysEmpty(): void + { + $collection = new Collection([]); + $keys = $collection->keys()->toList(); + + $this->assertSame([], $keys); + } + + /** + * Tests the values() method returns re-indexed collection + */ + public function testValues(): void + { + $items = ['a' => 1, 'b' => 2, 'c' => 3]; + $collection = new Collection($items); + $values = $collection->values()->toArray(); + + $this->assertSame([0 => 1, 1 => 2, 2 => 3], $values); + } + + /** + * Tests values() maintains order + */ + public function testValuesOrder(): void + { + $items = ['z' => 3, 'a' => 1, 'm' => 2]; + $collection = new Collection($items); + $values = $collection->values()->toList(); + + $this->assertSame([3, 1, 2], $values); + } + + /** + * Tests values() on empty collection + */ + public function testValuesEmpty(): void + { + $collection = new Collection([]); + $values = $collection->values()->toList(); + + $this->assertSame([], $values); + } + + /** + * Tests the implode() method + */ + public function testImplode(): void + { + $items = ['a', 'b', 'c']; + $collection = new Collection($items); + $result = $collection->implode(', '); + + $this->assertSame('a, b, c', $result); + } + + /** + * Tests implode() with path extraction + */ + public function testImplodeWithPath(): void + { + $items = [ + ['name' => 'foo'], + ['name' => 'bar'], + ['name' => 'baz'], + ]; + $collection = new Collection($items); + $result = $collection->implode(', ', 'name'); + + $this->assertSame('foo, bar, baz', $result); + } + + /** + * Tests implode() with nested path + */ + public function testImplodeWithNestedPath(): void + { + $items = [ + ['user' => ['name' => 'foo']], + ['user' => ['name' => 'bar']], + ]; + $collection = new Collection($items); + $result = $collection->implode(' - ', 'user.name'); + + $this->assertSame('foo - bar', $result); + } + + /** + * Tests implode() with callable + */ + public function testImplodeWithCallable(): void + { + $items = [1, 2, 3]; + $collection = new Collection($items); + $result = $collection->implode(', ', fn($v) => $v * 2); + + $this->assertSame('2, 4, 6', $result); + } + + /** + * Tests implode() with empty collection + */ + public function testImplodeEmpty(): void + { + $collection = new Collection([]); + $result = $collection->implode(', '); + + $this->assertSame('', $result); + } + + /** + * Tests the when() method applies callback when condition is truthy + */ + public function testWhenTruthy(): void + { + $items = [1, 2, 3, 4, 5]; + $collection = new Collection($items); + + $result = $collection->when(true, function ($collection) { + return $collection->filter(fn($v) => $v > 2); + })->toList(); + + $this->assertSame([3, 4, 5], $result); + } + + /** + * Tests when() does not apply callback when condition is falsy + */ + public function testWhenFalsy(): void + { + $items = [1, 2, 3, 4, 5]; + $collection = new Collection($items); + + $result = $collection->when(false, function ($collection) { + return $collection->filter(fn($v) => $v > 2); + })->toList(); + + $this->assertSame([1, 2, 3, 4, 5], $result); + } + + /** + * Tests when() passes condition value to callback + */ + public function testWhenPassesCondition(): void + { + $items = [1, 2, 3, 4, 5]; + $collection = new Collection($items); + + $result = $collection->when(3, function ($collection, $threshold) { + return $collection->filter(fn($v) => $v > $threshold); + })->toList(); + + $this->assertSame([4, 5], $result); + } + + /** + * Tests when() with zero as falsy condition + */ + public function testWhenZero(): void + { + $items = [1, 2, 3]; + $collection = new Collection($items); + + $result = $collection->when(0, function ($collection) { + return $collection->filter(fn($v) => $v > 1); + })->toList(); + + $this->assertSame([1, 2, 3], $result); + } + + /** + * Tests the unless() method applies callback when condition is falsy + */ + public function testUnlessFalsy(): void + { + $items = [1, 2, 3, 4, 5]; + $collection = new Collection($items); + + $result = $collection->unless(false, function ($collection) { + return $collection->filter(fn($v) => $v > 2); + })->toList(); + + $this->assertSame([3, 4, 5], $result); + } + + /** + * Tests unless() does not apply callback when condition is truthy + */ + public function testUnlessTruthy(): void + { + $items = [1, 2, 3, 4, 5]; + $collection = new Collection($items); + + $result = $collection->unless(true, function ($collection) { + return $collection->filter(fn($v) => $v > 2); + })->toList(); + + $this->assertSame([1, 2, 3, 4, 5], $result); + } + + /** + * Tests unless() with null as falsy condition + */ + public function testUnlessNull(): void + { + $items = [1, 2, 3]; + $collection = new Collection($items); + + $result = $collection->unless(null, function ($collection) { + return $collection->filter(fn($v) => $v > 1); + })->toList(); + + $this->assertSame([2, 3], $result); + } + + /** + * Tests unless() with empty string as falsy condition + */ + public function testUnlessEmptyString(): void + { + $items = [1, 2, 3]; + $collection = new Collection($items); + + $result = $collection->unless('', function ($collection) { + return $collection->filter(fn($v) => $v > 1); + })->toList(); + + $this->assertSame([2, 3], $result); + } + + /** + * Tests chaining when() and unless() together + */ + public function testWhenUnlessChaining(): void + { + $items = [1, 2, 3, 4, 5]; + $collection = new Collection($items); + + $result = $collection + ->when(true, fn($c) => $c->filter(fn($v) => $v > 1)) + ->unless(false, fn($c) => $c->filter(fn($v) => $v < 5)) + ->toList(); - $this->assertTrue(method_exists($newIterator, 'checkValues'), 'Our method has gone missing!'); - $this->assertTrue($newIterator->checkValues()); - $this->assertCount(3, $newIterator->toArray()); + $this->assertSame([2, 3, 4], $result); } } diff --git a/tests/TestCase/Collection/FunctionsGlobalTest.php b/tests/TestCase/Collection/FunctionsGlobalTest.php new file mode 100644 index 00000000000..85f3c7ce6ca --- /dev/null +++ b/tests/TestCase/Collection/FunctionsGlobalTest.php @@ -0,0 +1,39 @@ +assertInstanceOf(Collection::class, $collection); + $this->assertSame($items, $collection->toArray()); + } +} diff --git a/tests/TestCase/Collection/FunctionsTest.php b/tests/TestCase/Collection/FunctionsTest.php new file mode 100644 index 00000000000..80e38bfa854 --- /dev/null +++ b/tests/TestCase/Collection/FunctionsTest.php @@ -0,0 +1,38 @@ +assertInstanceOf(Collection::class, $collection); + $this->assertSame($items, $collection->toArray()); + } +} diff --git a/tests/TestCase/Collection/Iterator/BufferedIteratorTest.php b/tests/TestCase/Collection/Iterator/BufferedIteratorTest.php index 3d9ffd808e5..590c9ca72cf 100644 --- a/tests/TestCase/Collection/Iterator/BufferedIteratorTest.php +++ b/tests/TestCase/Collection/Iterator/BufferedIteratorTest.php @@ -1,4 +1,6 @@ 1, 'b' => 2, - 'c' => 3 + 'c' => 3, ]); $iterator = new BufferedIterator($items); $expected = (array)$items; @@ -48,15 +47,13 @@ public function testBuffer() /** * Tests that items are cached once iterated over them - * - * @return void */ - public function testCount() + public function testCount(): void { $items = new ArrayObject([ 'a' => 1, 'b' => 2, - 'c' => 3 + 'c' => 3, ]); $iterator = new BufferedIterator($items); $this->assertCount(3, $iterator); @@ -68,4 +65,42 @@ public function testCount() $buffered = $iterator->toArray(); $this->assertSame((array)$items, $buffered); } + + /** + * Tests that partial iteration can be reset. + */ + public function testBufferPartial(): void + { + $items = new ArrayObject([1, 2, 3]); + $iterator = new BufferedIterator($items); + foreach ($iterator as $key => $value) { + if ($key == 1) { + break; + } + } + $result = []; + foreach ($iterator as $value) { + $result[] = $value; + } + $this->assertEquals([1, 2, 3], $result); + } + + /** + * Testing serialize and unserialize features. + */ + public function testSerialization(): void + { + $items = new ArrayObject([ + 'a' => 1, + 'b' => 2, + 'c' => 3, + ]); + $expected = (array)$items; + + $iterator = new BufferedIterator($items); + + $serialized = serialize($iterator); + $outcome = unserialize($serialized); + $this->assertEquals($expected, $outcome->toArray()); + } } diff --git a/tests/TestCase/Collection/Iterator/ExtractIteratorTest.php b/tests/TestCase/Collection/Iterator/ExtractIteratorTest.php index c6eacec7fa7..53b73051436 100644 --- a/tests/TestCase/Collection/Iterator/ExtractIteratorTest.php +++ b/tests/TestCase/Collection/Iterator/ExtractIteratorTest.php @@ -1,4 +1,6 @@ 1, 'b' => 2], - ['a' => 3, 'b' => 4] + ['a' => 3, 'b' => 4], ]; $extractor = new ExtractIterator($items, 'a'); $this->assertEquals([1, 3], iterator_to_array($extractor)); @@ -47,14 +46,12 @@ public function testExtractFromArrayShallow() /** * Tests it is possible to extract a column in the first level of an object - * - * @return void */ - public function testExtractFromObjectShallow() + public function testExtractFromObjectShallow(): void { $items = [ new ArrayObject(['a' => 1, 'b' => 2]), - new ArrayObject(['a' => 3, 'b' => 4]) + new ArrayObject(['a' => 3, 'b' => 4]), ]; $extractor = new ExtractIterator($items, 'a'); $this->assertEquals([1, 3], iterator_to_array($extractor)); @@ -68,10 +65,8 @@ public function testExtractFromObjectShallow() /** * Tests it is possible to extract a column deeply nested in the structure - * - * @return void */ - public function testExtractFromArrayDeep() + public function testExtractFromArrayDeep(): void { $items = [ ['a' => ['b' => ['c' => 10]], 'b' => 2], @@ -85,14 +80,12 @@ public function testExtractFromArrayDeep() /** * Tests that it is possible to pass a callable as the extractor. - * - * @return void */ - public function testExtractWithCallable() + public function testExtractWithCallable(): void { $items = [ ['a' => 1, 'b' => 2], - ['a' => 3, 'b' => 4] + ['a' => 3, 'b' => 4], ]; $extractor = new ExtractIterator($items, function ($item) { return $item['b']; diff --git a/tests/TestCase/Collection/Iterator/FilterIteratorTest.php b/tests/TestCase/Collection/Iterator/FilterIteratorTest.php index c278579989b..5b40c0aa922 100644 --- a/tests/TestCase/Collection/Iterator/FilterIteratorTest.php +++ b/tests/TestCase/Collection/Iterator/FilterIteratorTest.php @@ -1,4 +1,6 @@ getMockBuilder(\StdClass::class) - ->setMethods(['__invoke']) - ->getMock(); - $callable->expects($this->at(0)) - ->method('__invoke') - ->with(1, 0, $items) - ->will($this->returnValue(false)); - $callable->expects($this->at(1)) - ->method('__invoke') - ->with(2, 1, $items) - ->will($this->returnValue(true)); - $callable->expects($this->at(2)) - ->method('__invoke') - ->with(3, 2, $items) - ->will($this->returnValue(false)); + $items = new ArrayIterator([1, 2, 3]); + $callable = function ($value, $key, $itemArg) use ($items) { + $this->assertSame($items, $itemArg); + $this->assertContains($value, $items); + $this->assertContains($key, [0, 1, 2]); + + return $value === 2; + }; $filter = new FilterIterator($items, $callable); $this->assertEquals([1 => 2], iterator_to_array($filter)); diff --git a/tests/TestCase/Collection/Iterator/InsertIteratorTest.php b/tests/TestCase/Collection/Iterator/InsertIteratorTest.php index a70feefd736..d36c86ec199 100644 --- a/tests/TestCase/Collection/Iterator/InsertIteratorTest.php +++ b/tests/TestCase/Collection/Iterator/InsertIteratorTest.php @@ -1,4 +1,6 @@ ['name' => 'Derp'], - 'b' => ['name' => 'Derpina'] + 'b' => ['name' => 'Derpina'], ]; $values = [20, 21]; $iterator = new InsertIterator($items, 'age', $values); $result = $iterator->toArray(); $expected = [ 'a' => ['name' => 'Derp', 'age' => 20], - 'b' => ['name' => 'Derpina', 'age' => 21] + 'b' => ['name' => 'Derpina', 'age' => 21], ]; $this->assertSame($expected, $result); } /** * Test insert deep path - * - * @return void */ - public function testInsertDeepPath() + public function testInsertDeepPath(): void { $items = [ 'a' => ['name' => 'Derp', 'a' => ['deep' => ['thing' => 1]]], 'b' => ['name' => 'Derpina', 'a' => ['deep' => ['thing' => 2]]], ]; - $values = new \ArrayIterator([20, 21]); + $values = new ArrayIterator([20, 21]); $iterator = new InsertIterator($items, 'a.deep.path', $values); $result = $iterator->toArray(); $expected = [ @@ -67,10 +65,8 @@ public function testInsertDeepPath() /** * Test that missing properties in the path will skip inserting - * - * @return void */ - public function testInsertDeepPathMissingStep() + public function testInsertDeepPathMissingStep(): void { $items = [ 'a' => ['name' => 'Derp', 'a' => ['deep' => ['thing' => 1]]], @@ -89,21 +85,19 @@ public function testInsertDeepPathMissingStep() /** * Tests that the iterator will insert values as long as there still exist * some in the values array - * - * @return void */ - public function testInsertTargetCountBigger() + public function testInsertTargetCountBigger(): void { $items = [ 'a' => ['name' => 'Derp'], - 'b' => ['name' => 'Derpina'] + 'b' => ['name' => 'Derpina'], ]; $values = [20]; $iterator = new InsertIterator($items, 'age', $values); $result = $iterator->toArray(); $expected = [ 'a' => ['name' => 'Derp', 'age' => 20], - 'b' => ['name' => 'Derpina'] + 'b' => ['name' => 'Derpina'], ]; $this->assertSame($expected, $result); } @@ -111,31 +105,27 @@ public function testInsertTargetCountBigger() /** * Tests that the iterator will insert values as long as there still exist * some in the values array - * - * @return void */ - public function testInsertSourceBigger() + public function testInsertSourceBigger(): void { $items = [ 'a' => ['name' => 'Derp'], - 'b' => ['name' => 'Derpina'] + 'b' => ['name' => 'Derpina'], ]; $values = [20, 21, 23]; $iterator = new InsertIterator($items, 'age', $values); $result = $iterator->toArray(); $expected = [ 'a' => ['name' => 'Derp', 'age' => 20], - 'b' => ['name' => 'Derpina', 'age' => 21] + 'b' => ['name' => 'Derpina', 'age' => 21], ]; $this->assertSame($expected, $result); } /** * Tests the iterator can be rewound - * - * @return void */ - public function testRewind() + public function testRewind(): void { $items = [ 'a' => ['name' => 'Derp'], @@ -150,7 +140,7 @@ public function testRewind() $result = $iterator->toArray(); $expected = [ 'a' => ['name' => 'Derp', 'age' => 20], - 'b' => ['name' => 'Derpina', 'age' => 21] + 'b' => ['name' => 'Derpina', 'age' => 21], ]; $this->assertSame($expected, $result); } diff --git a/tests/TestCase/Collection/Iterator/MapReduceTest.php b/tests/TestCase/Collection/Iterator/MapReduceTest.php index daffabd609b..13d7c1eee9c 100644 --- a/tests/TestCase/Collection/Iterator/MapReduceTest.php +++ b/tests/TestCase/Collection/Iterator/MapReduceTest.php @@ -1,4 +1,6 @@ 'Dogs are the most amazing animal in history', 'document_2' => 'History is not only amazing but boring', - 'document_3' => 'One thing that is not boring is dogs' + 'document_3' => 'One thing that is not boring is dogs', ]; - $mapper = function ($row, $document, $mr) { + $mapper = function ($row, $document, $mr): void { $words = array_map('strtolower', explode(' ', $row)); foreach ($words as $word) { $mr->emitIntermediate($document, $word); } }; - $reducer = function ($documents, $word, $mr) { + $reducer = function ($documents, $word, $mr): void { $mr->emit(array_unique($documents), $word); }; $results = new MapReduce(new ArrayIterator($data), $mapper, $reducer); @@ -62,20 +62,44 @@ public function testInvertedIndexCreation() 'boring' => ['document_2', 'document_3'], 'one' => ['document_3'], 'thing' => ['document_3'], - 'that' => ['document_3'] + 'that' => ['document_3'], + ]; + $this->assertEquals($expected, iterator_to_array($results)); + } + + public function testSpecifyingKeyWhenEmittingIntermediate(): void + { + $data = [ + 'document_1' => 'one two three', + 'document_2' => 'one three', + 'document_3' => 'two four', + ]; + $mapper = function ($row, $document, $mr): void { + $words = array_map('strtolower', explode(' ', $row)); + foreach ($words as $word) { + $mr->emitIntermediate($document, $word, $document); + } + }; + $reducer = function ($documents, $word, $mr): void { + $mr->emit(array_unique($documents), $word); + }; + $results = new MapReduce(new ArrayIterator($data), $mapper, $reducer); + $expected = [ + 'one' => ['document_1' => 'document_1', 'document_2' => 'document_2'], + 'two' => ['document_1' => 'document_1', 'document_3' => 'document_3'], + 'three' => ['document_1' => 'document_1', 'document_2' => 'document_2'], + 'four' => ['document_3' => 'document_3'], ]; $this->assertEquals($expected, iterator_to_array($results)); } /** * Tests that it is possible to use the emit function directly in the mapper - * - * @return void */ - public function testEmitFinalInMapper() + public function testEmitFinalInMapper(): void { $data = ['a' => ['one', 'two'], 'b' => ['three', 'four']]; - $mapper = function ($row, $key, $mr) { + $mapper = function ($row, $key, $mr): void { foreach ($row as $number) { $mr->emit($number); } @@ -87,14 +111,12 @@ public function testEmitFinalInMapper() /** * Tests that a reducer is required when there are intermediate results - * - * @return void */ - public function testReducerRequired() + public function testReducerRequired(): void { - $this->expectException(\LogicException::class); + $this->expectException(LogicException::class); $data = ['a' => ['one', 'two'], 'b' => ['three', 'four']]; - $mapper = function ($row, $key, $mr) { + $mapper = function ($row, $key, $mr): void { foreach ($row as $number) { $mr->emitIntermediate('a', $number); } diff --git a/tests/TestCase/Collection/Iterator/ReplaceIteratorTest.php b/tests/TestCase/Collection/Iterator/ReplaceIteratorTest.php index 0287d8ce30b..4c9c9535d6c 100644 --- a/tests/TestCase/Collection/Iterator/ReplaceIteratorTest.php +++ b/tests/TestCase/Collection/Iterator/ReplaceIteratorTest.php @@ -1,4 +1,6 @@ getMockBuilder(\StdClass::class) - ->setMethods(['__invoke']) - ->getMock(); - $callable->expects($this->at(0)) - ->method('__invoke') - ->with(1, 0, $items) - ->will($this->returnValue(1)); - $callable->expects($this->at(1)) - ->method('__invoke') - ->with(2, 1, $items) - ->will($this->returnValue(4)); - $callable->expects($this->at(2)) - ->method('__invoke') - ->with(3, 2, $items) - ->will($this->returnValue(9)); + $items = new ArrayIterator([1, 2, 3]); + $callable = function ($value, $key, $itemsArg) use ($items) { + $this->assertSame($items, $itemsArg); + $this->assertContains($value, $items); + $this->assertContains($key, [0, 1, 2]); + + return $value > 1 ? $value * $value : $value; + }; $map = new ReplaceIterator($items, $callable); $this->assertEquals([1, 4, 9], iterator_to_array($map)); diff --git a/tests/TestCase/Collection/Iterator/SortIteratorTest.php b/tests/TestCase/Collection/Iterator/SortIteratorTest.php index c1caf8f5c7d..ad41f87c395 100644 --- a/tests/TestCase/Collection/Iterator/SortIteratorTest.php +++ b/tests/TestCase/Collection/Iterator/SortIteratorTest.php @@ -1,4 +1,6 @@ 1, 'bar' => 'a'], @@ -101,10 +105,8 @@ public function testSortComplexNumeric() /** * Tests sorting a complex structure with natural sort - * - * @return void */ - public function testSortComplexNatural() + public function testSortComplexNatural(): void { $items = new ArrayObject([ ['foo' => 'foo_1', 'bar' => 'a'], @@ -137,10 +139,8 @@ public function testSortComplexNatural() /** * Tests sorting a complex structure with natural sort with string callback - * - * @return void */ - public function testSortComplexNaturalWithPath() + public function testSortComplexNaturalWithPath(): void { $items = new ArrayObject([ ['foo' => 'foo_1', 'bar' => 'a'], @@ -170,10 +170,8 @@ public function testSortComplexNaturalWithPath() /** * Tests sorting a complex structure with a deep path - * - * @return void */ - public function testSortComplexDeepPath() + public function testSortComplexDeepPath(): void { $items = new ArrayObject([ ['foo' => ['bar' => 1], 'bar' => 'a'], @@ -193,40 +191,106 @@ public function testSortComplexDeepPath() /** * Tests sorting datetime - * - * @return void */ - public function testSortDateTime() + public function testSortDateTime(): void { $items = new ArrayObject([ - new \DateTime('2014-07-21'), - new \DateTime('2015-06-30'), - new \DateTimeImmutable('2013-08-12') + new DateTime('2014-07-21'), + new DateTime('2015-06-30'), + new DateTimeImmutable('2013-08-12'), ]); $callback = function ($a) { - return $a->add(new \DateInterval('P1Y')); + return $a->add(new DateInterval('P1Y')); }; $sorted = new SortIterator($items, $callback); $expected = [ - new \DateTime('2016-06-30'), - new \DateTime('2015-07-21'), - new \DateTimeImmutable('2013-08-12') + new DateTime('2016-06-30'), + new DateTime('2015-07-21'), + new DateTimeImmutable('2013-08-12'), + + ]; + $this->assertEquals($expected, $sorted->toList()); + + $items = new ArrayObject([ + new DateTime('2014-07-21'), + new DateTime('2015-06-30'), + new DateTimeImmutable('2013-08-12'), + ]); + + $sorted = new SortIterator($items, $callback, SORT_ASC); + $expected = [ + new DateTimeImmutable('2013-08-12'), + new DateTime('2015-07-21'), + new DateTime('2016-06-30'), + ]; + $this->assertEquals($expected, $sorted->toList()); + } + + /** + * Tests sorting with Chronos datetime + */ + public function testSortWithChronosDateTime(): void + { + $items = new ArrayObject([ + new Chronos('2014-07-21'), + new ChronosDate('2015-06-30'), + new DateTimeImmutable('2013-08-12'), + ]); + $callback = fn($d) => $d; + $sorted = new SortIterator($items, $callback); + $expected = [ + new ChronosDate('2015-06-30'), + new Chronos('2014-07-21'), + new DateTimeImmutable('2013-08-12'), + ]; + $this->assertEquals($expected, $sorted->toList()); + + $items = new ArrayObject([ + new Chronos('2014-07-21'), + new ChronosDate('2015-06-30'), + new DateTimeImmutable('2013-08-12'), + ]); + + $sorted = new SortIterator($items, $callback, SORT_ASC); + $expected = [ + new DateTimeImmutable('2013-08-12'), + new Chronos('2014-07-21'), + new ChronosDate('2015-06-30'), + ]; + $this->assertEquals($expected, $sorted->toList()); + } + /** + * Tests sorting with Chronos time instances + */ + public function testSortWithChronosTime(): void + { + $items = new ArrayObject([ + new ChronosTime('12:00:00'), + new ChronosTime('10:00:01'), + new ChronosTime('11:00:00'), + ]); + $callback = fn($d) => $d; + $sorted = new SortIterator($items, $callback); + $expected = [ + new ChronosTime('12:00:00'), + new ChronosTime('11:00:00'), + new ChronosTime('10:00:01'), ]; $this->assertEquals($expected, $sorted->toList()); $items = new ArrayObject([ - new \DateTime('2014-07-21'), - new \DateTime('2015-06-30'), - new \DateTimeImmutable('2013-08-12') + new ChronosTime('12:00:00'), + new ChronosTime('10:00:01'), + new ChronosTime('11:00:00'), ]); $sorted = new SortIterator($items, $callback, SORT_ASC); $expected = [ - new \DateTimeImmutable('2013-08-12'), - new \DateTime('2015-07-21'), - new \DateTime('2016-06-30'), + new ChronosTime('10:00:01'), + new ChronosTime('11:00:00'), + new ChronosTime('12:00:00'), ]; $this->assertEquals($expected, $sorted->toList()); } diff --git a/tests/TestCase/Collection/Iterator/TreeIteratorTest.php b/tests/TestCase/Collection/Iterator/TreeIteratorTest.php index 8f350bb7495..092ba74ffa8 100644 --- a/tests/TestCase/Collection/Iterator/TreeIteratorTest.php +++ b/tests/TestCase/Collection/Iterator/TreeIteratorTest.php @@ -1,4 +1,6 @@ 1, 'name' => 'a', 'stuff' => [ - ['id' => 2, 'name' => 'b', 'stuff' => [['id' => 3, 'name' => 'c']]] - ] + ['id' => 2, 'name' => 'b', 'stuff' => [['id' => 3, 'name' => 'c']]], + ], ], - ['id' => 4, 'name' => 'd', 'stuff' => [['id' => 5, 'name' => 'e']]] + ['id' => 4, 'name' => 'd', 'stuff' => [['id' => 5, 'name' => 'e']]], ]; $items = new NestIterator($items, 'stuff'); $result = (new TreeIterator($items))->printer('name')->toArray(); @@ -48,27 +47,25 @@ public function testPrinter() '__b', '____c', 'd', - '__e' + '__e', ]; $this->assertEquals($expected, $result); } /** * Tests the printer function with a custom key extractor and spacer - * - * @return void */ - public function testPrinterCustomKeyAndSpacer() + public function testPrinterCustomKeyAndSpacer(): void { $items = [ [ 'id' => 1, 'name' => 'a', 'stuff' => [ - ['id' => 2, 'name' => 'b', 'stuff' => [['id' => 3, 'name' => 'c']]] - ] + ['id' => 2, 'name' => 'b', 'stuff' => [['id' => 3, 'name' => 'c']]], + ], ], - ['id' => 4, 'name' => 'd', 'stuff' => [['id' => 5, 'name' => 'e']]] + ['id' => 4, 'name' => 'd', 'stuff' => [['id' => 5, 'name' => 'e']]], ]; $items = new NestIterator($items, 'stuff'); $result = (new TreeIterator($items))->printer('id', 'name', '@@')->toArray(); @@ -77,40 +74,38 @@ public function testPrinterCustomKeyAndSpacer() 'b' => '@@2', 'c' => '@@@@3', 'd' => '4', - 'e' => '@@5' + 'e' => '@@5', ]; $this->assertEquals($expected, $result); } /** * Tests the printer function with a closure extractor - * - * @return void */ - public function testPrinterWithClosure() + public function testPrinterWithClosure(): void { $items = [ [ 'id' => 1, 'name' => 'a', 'stuff' => [ - ['id' => 2, 'name' => 'b', 'stuff' => [['id' => 3, 'name' => 'c']]] - ] + ['id' => 2, 'name' => 'b', 'stuff' => [['id' => 3, 'name' => 'c']]], + ], ], - ['id' => 4, 'name' => 'd', 'stuff' => [['id' => 5, 'name' => 'e']]] + ['id' => 4, 'name' => 'd', 'stuff' => [['id' => 5, 'name' => 'e']]], ]; $items = new NestIterator($items, 'stuff'); $result = (new TreeIterator($items)) ->printer(function ($element, $key, $iterator) { return ($iterator->getDepth() + 1 ) . '.' . $key . ' ' . $element['name']; - }, null, null) + }, null, '') ->toArray(); $expected = [ '1.0 a', '2.0 b', '3.0 c', '1.1 d', - '2.0 e' + '2.0 e', ]; $this->assertEquals($expected, $result); } diff --git a/tests/TestCase/Command/CacheCommandsTest.php b/tests/TestCase/Command/CacheCommandsTest.php new file mode 100644 index 00000000000..03b4c316c7d --- /dev/null +++ b/tests/TestCase/Command/CacheCommandsTest.php @@ -0,0 +1,181 @@ + 'File', 'path' => CACHE, 'groups' => ['test_group']]); + Cache::setConfig('test2', ['engine' => 'File', 'path' => CACHE, 'groups' => ['test_group']]); + $this->setAppNamespace(); + } + + /** + * Teardown + */ + protected function tearDown(): void + { + parent::tearDown(); + Cache::drop('test'); + Cache::drop('test2'); + } + + /** + * Test help output + */ + public function testClearHelp(): void + { + $this->exec('cache clear -h'); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('engine to clear'); + } + + /** + * Test help output + */ + public function testClearAllHelp(): void + { + $this->exec('cache clear_all -h'); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('Clear all'); + } + + /** + * Test list output + */ + public function testList(): void + { + $this->exec('cache list'); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('- test'); + $this->assertOutputContains('- _cake_translations_'); + $this->assertOutputContains('- _cake_model_'); + } + + /** + * Test help output + */ + public function testListHelp(): void + { + $this->exec('cache list -h'); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('Show a list'); + } + + /** + * Test that clear() throws \Cake\Console\Exception\StopException if cache prefix is invalid + */ + public function testClearInvalidPrefix(): void + { + $this->exec('cache clear foo'); + $this->assertExitCode(CommandInterface::CODE_ERROR); + $this->assertErrorContains('The `foo` cache configuration does not exist'); + } + + /** + * Test that clear() clears the specified cache when a valid prefix is used + */ + public function testClearValidPrefix(): void + { + Cache::add('key', 'value', 'test'); + $this->exec('cache clear test'); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertNull(Cache::read('key', 'test')); + } + + /** + * Test that clear() only clears the specified cache + */ + public function testClearIgnoresOtherCaches(): void + { + Cache::add('key', 'value', 'test'); + $this->exec('cache clear _cake_translations_'); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertSame('value', Cache::read('key', 'test')); + } + + /** + * Test that clearAll() clears values from all defined caches + */ + public function testClearAll(): void + { + Cache::add('key', 'value1', 'test'); + Cache::add('key', 'value3', '_cake_translations_'); + $this->exec('cache clear_all'); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertNull(Cache::read('key', 'test')); + $this->assertNull(Cache::read('key', '_cake_translations_')); + } + + public function testClearGroup(): void + { + Cache::add('key', 'value1', 'test'); + Cache::add('key', 'value1', 'test2'); + $this->exec('cache clear_group test_group'); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertNull(Cache::read('key', 'test')); + $this->assertNull(Cache::read('key', 'test2')); + } + + public function testClearGroupWithConfig(): void + { + Cache::add('key', 'value1', 'test'); + $this->exec('cache clear_group test_group test'); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertNull(Cache::read('key', 'test')); + } + + public function testClearGroupInvalidConfig(): void + { + $this->exec('cache clear_group test_group does_not_exist'); + + $this->assertExitCode(CommandInterface::CODE_ERROR); + $this->assertErrorContains('Cache config "does_not_exist" not found'); + } + + public function testClearInvalidGroup(): void + { + $this->exec('cache clear_group does_not_exist'); + + $this->assertExitCode(CommandInterface::CODE_ERROR); + $this->assertErrorContains('Cache group "does_not_exist" not found'); + } +} diff --git a/tests/TestCase/Command/CompletionCommandTest.php b/tests/TestCase/Command/CompletionCommandTest.php new file mode 100644 index 00000000000..9156bca234d --- /dev/null +++ b/tests/TestCase/Command/CompletionCommandTest.php @@ -0,0 +1,398 @@ +clearPlugins(); + Configure::delete('Plugins.autoload'); + } + + /** + * test that the startup method suppresses the command header + */ + public function testStartup(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion'); + }); + $this->assertExitCode(CommandInterface::CODE_ERROR); + + $this->assertOutputNotContains('Welcome to CakePHP'); + } + + /** + * test commands method that list all available commands + */ + public function testCommands(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion commands'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $expected = [ + 'example', + 'unique', + 'welcome', + 'cache', + 'i18n', + 'plugin', + 'routes', + 'schema_cache', + 'server', + 'version', + 'abort', + 'auto_load_model', + 'demo', + 'integration', + 'sample', + ]; + foreach ($expected as $value) { + $this->assertOutputContains($value); + } + + $this->assertOutputNotContains('hidden', 'Hidden commands should not appear in completion output'); + } + + /** + * test commands excludes plugin-prefixed aliases only for true duplicates + */ + public function testCommandsExcludesPluginAliasesForDuplicates(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion commands'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + // Plugin-prefixed aliases should be excluded when they point to the + // same class as the short form (true duplicates) + $this->assertOutputNotContains('test_plugin.example'); + $this->assertOutputNotContains('test_plugin_two.unique'); + $this->assertOutputNotContains('test_plugin_two.welcome'); + + // Short forms should still be present + $this->assertOutputContains('example'); + + // Plugin-prefixed aliases should be included when multiple plugins + // have commands with the same name (different classes) + $this->assertOutputContains('test_plugin.sample'); + $this->assertOutputContains('test_plugin_two.example'); + } + + /** + * test commands includes plugin-prefixed aliases in verbose mode + */ + public function testCommandsIncludesPluginAliasesInVerboseMode(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion commands -v'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + // Plugin-prefixed aliases should be in the output in verbose mode + $this->assertOutputContains('test_plugin.example'); + $this->assertOutputContains('test_plugin.sample'); + $this->assertOutputContains('test_plugin_two.example'); + } + + /** + * test that options without argument returns nothing + */ + public function testOptionsNoArguments(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion options'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputEmpty(); + } + + /** + * test that options with a nonexistent command returns nothing + */ + public function testOptionsNonExistentCommand(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion options foo'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputEmpty(); + } + + /** + * test that options with an existing command returns the proper options + */ + public function testOptionsCommand(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion options schema_cache'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $expected = [ + '--connection -c', + '--help -h', + '--quiet -q', + '--verbose -v', + ]; + foreach ($expected as $value) { + $this->assertOutputContains($value); + } + } + + /** + * test that options with an existing command / subcommand pair returns the proper options + */ + public function testOptionsSubCommand(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion options cache list'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $expected = [ + '--help -h', + '--quiet -q', + '--verbose -v', + ]; + foreach ($expected as $value) { + $this->assertOutputContains($value); + } + } + + /** + * test that nested command returns subcommand's options not command. + */ + public function testOptionsNestedCommand(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion options i18n extract'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $expected = [ + '--plugin', + '--app', + ]; + foreach ($expected as $value) { + $this->assertOutputContains($value); + } + } + + /** + * test that subCommands with a existing CORE command returns the proper sub commands + */ + public function testSubCommandsCorePlugin(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion subcommands schema_cache'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $expected = 'build clear'; + $this->assertOutputContains($expected); + } + + /** + * test that subCommands with a existing APP command returns the proper sub commands (in this case none) + */ + public function testSubCommandsAppPlugin(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion subcommands sample'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('sub'); + } + + /** + * test that subCommands with a existing CORE command + */ + public function testSubCommandsCoreMultiwordCommand(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion subcommands cache'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $expected = [ + 'list', 'clear', 'clear_all', + ]; + foreach ($expected as $value) { + $this->assertOutputContains($value); + } + } + + /** + * test that subCommands with an existing plugin command returns the proper sub commands + * when the command name is unique and the dot notation not mandatory + */ + public function testSubCommandsPlugin(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion subcommands welcome'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $expected = 'say_hello'; + $this->assertOutputContains($expected); + } + + /** + * test that using the dot notation when not mandatory works to provide backward compatibility + */ + public function testSubCommandsPluginDotNotationBackwardCompatibility(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion subcommands test_plugin_two.welcome'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $expected = 'say_hello'; + $this->assertOutputContains($expected); + } + + /** + * test that subCommands with an app command that is also defined in a plugin and without the prefix "app." + * returns proper sub commands + */ + public function testSubCommandsAppDuplicatePluginNoDot(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion subcommands sample'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('sub'); + } + + /** + * test that subCommands with a plugin command that is also defined in the returns proper sub commands + */ + public function testSubCommandsPluginDuplicateApp(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion subcommands test_plugin.sample'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $expected = 'sub'; + $this->assertOutputContains($expected); + } + + /** + * test that subcommands without arguments returns nothing + */ + public function testSubCommandsNoArguments(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion subcommands'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $this->assertOutputEmpty(); + } + + /** + * test that subcommands with a nonexistent command returns nothing + */ + public function testSubCommandsNonExistentCommand(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion subcommands foo'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $this->assertOutputEmpty(); + } + + /** + * test that subcommands returns the available subcommands for the given command + */ + public function testSubCommands(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion subcommands schema_cache'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $expected = 'build clear'; + $this->assertOutputContains($expected); + } + + /** + * test that help returns content + */ + public function testHelp(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('completion --help'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $this->assertOutputContains('Output a list of available commands'); + $this->assertOutputContains('Output a list of available sub-commands'); + } +} diff --git a/tests/TestCase/Command/CounterCacheCommandTest.php b/tests/TestCase/Command/CounterCacheCommandTest.php new file mode 100644 index 00000000000..059022f2908 --- /dev/null +++ b/tests/TestCase/Command/CounterCacheCommandTest.php @@ -0,0 +1,84 @@ +setAppNamespace(); + $connection = ConnectionManager::get('test'); + + $this->getTableLocator()->get('Users', [ + 'table' => 'counter_cache_users', + 'connection' => $connection, + ]); + + $comments = $this->getTableLocator()->get('Comments', [ + 'table' => 'counter_cache_comments', + 'connection' => $connection, + ]); + + $comments->belongsTo('Users', [ + 'foreignKey' => 'user_id', + ]); + + $comments->addBehavior('CounterCache', [ + 'Users' => ['comment_count'], + ]); + } + + public function testExecute(): void + { + $this->exec('counter_cache Comments'); + $this->assertExitSuccess(); + $this->assertOutputContains('Counter cache updated successfully.'); + } + + public function testExecuteWithOptions(): void + { + $this->exec('counter_cache Comments --assoc Users --limit 1 --page 1'); + $this->assertExitSuccess(); + } + + public function testExecuteFailure(): void + { + $this->exec('counter_cache Users'); + $this->assertExitError(); + $this->assertErrorContains('The specified model does not have the CounterCache behavior attached.'); + } +} diff --git a/tests/TestCase/Command/I18nCommandTest.php b/tests/TestCase/Command/I18nCommandTest.php new file mode 100644 index 00000000000..72f7e06c84f --- /dev/null +++ b/tests/TestCase/Command/I18nCommandTest.php @@ -0,0 +1,167 @@ +localeDir = TMP . 'Locale' . DS; + $this->setAppNamespace(); + } + + /** + * Teardown + */ + protected function tearDown(): void + { + parent::tearDown(); + + $deDir = $this->localeDir . 'de_DE' . DS; + + if (file_exists($this->localeDir . 'default.pot')) { + unlink($this->localeDir . 'default.pot'); + unlink($this->localeDir . 'cake.pot'); + } + if (file_exists($deDir . 'default.po')) { + unlink($deDir . 'default.po'); + unlink($deDir . 'cake.po'); + } + } + + /** + * Tests that init() creates the PO files from POT files. + */ + public function testInit(): void + { + $deDir = $this->localeDir . 'de_DE' . DS; + if (!is_dir($deDir)) { + mkdir($deDir, 0770, true); + } + file_put_contents($this->localeDir . 'default.pot', 'Testing POT file.'); + file_put_contents($this->localeDir . 'cake.pot', 'Testing POT file.'); + if (file_exists($deDir . 'default.po')) { + unlink($deDir . 'default.po'); + } + if (file_exists($deDir . 'cake.po')) { + unlink($deDir . 'cake.po'); + } + + $this->exec('i18n init --verbose', [ + 'de_DE', + $this->localeDir, + ]); + + $this->assertExitSuccess(); + $this->assertOutputContains('Generated 2 PO files'); + $this->assertFileExists($deDir . 'default.po'); + $this->assertFileExists($deDir . 'cake.po'); + } + + /** + * Tests that init() creates the PO files from POT files when App.path.locales contains an associative array + */ + public function testInitWithAssociativePaths(): void + { + $deDir = $this->localeDir . 'de_DE' . DS; + if (!is_dir($deDir)) { + mkdir($deDir, 0770, true); + } + file_put_contents($this->localeDir . 'default.pot', 'Testing POT file.'); + file_put_contents($this->localeDir . 'cake.pot', 'Testing POT file.'); + if (file_exists($deDir . 'default.po')) { + unlink($deDir . 'default.po'); + } + if (file_exists($deDir . 'cake.po')) { + unlink($deDir . 'cake.po'); + } + + Configure::write('App.paths.locales', ['customKey' => TEST_APP . 'resources' . DS . 'locales' . DS]); + + $this->exec('i18n init --verbose', [ + 'de_DE', + $this->localeDir, + ]); + + $this->assertExitSuccess(); + $this->assertOutputContains('Generated 2 PO files'); + $this->assertFileExists($deDir . 'default.po'); + $this->assertFileExists($deDir . 'cake.po'); + } + + /** + * Test that the option parser is shaped right. + */ + public function testGetOptionParser(): void + { + $this->exec('i18n -h'); + + $this->assertExitSuccess(); + $this->assertOutputContains('cake i18n'); + } + + /** + * Tests main interactive mode + */ + public function testInteractiveQuit(): void + { + $this->exec('i18n', ['q']); + $this->assertExitSuccess(); + } + + /** + * Tests main interactive mode + */ + public function testInteractiveHelp(): void + { + $this->exec('i18n', ['h', 'q']); + $this->assertExitSuccess(); + $this->assertOutputContains('cake i18n'); + } + + /** + * Tests main interactive mode + */ + public function testInteractiveInit(): void + { + $this->exec('i18n', [ + 'i', + 'x', + ]); + $this->assertExitError(); + $this->assertErrorContains('Invalid language code'); + } +} diff --git a/tests/TestCase/Command/I18nExtractCommandTest.php b/tests/TestCase/Command/I18nExtractCommandTest.php new file mode 100644 index 00000000000..496d01b6427 --- /dev/null +++ b/tests/TestCase/Command/I18nExtractCommandTest.php @@ -0,0 +1,454 @@ +setAppNamespace(); + + $this->path = TMP . 'tests/extract_task_test'; + $fs = new Filesystem(); + $fs->deleteDir($this->path); + $fs->mkdir($this->path . DS . 'locale'); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + + $fs = new Filesystem(); + $fs->deleteDir($this->path); + $this->clearPlugins(); + } + + /** + * testExecute method + */ + public function testExecute(): void + { + $this->exec( + 'i18n extract ' . + '--merge=no ' . + '--extract-core=no ' . + '--paths=' . TEST_APP . 'templates' . DS . 'Pages ' . + '--output=' . $this->path . DS, + ); + $this->assertExitSuccess(); + $this->assertFileExists($this->path . DS . 'default.pot'); + $result = file_get_contents($this->path . DS . 'default.pot'); + + $this->assertFileDoesNotExist($this->path . DS . 'cake.pot'); + + // The additional "./tests/test_app" is just due to the wonky folder structure of the test app. + // In a regular app the path would start with "./templates". + $pattern = '@\#: \./tests/test_app/templates/Pages/extract\.php:\d+\n'; + $pattern .= '\#: \./tests/test_app/templates/Pages/extract\.php:\d+\n'; + $pattern .= 'msgid "You have %d new message."\nmsgid_plural "You have %d new messages."@'; + $this->assertMatchesRegularExpression($pattern, $result); + + $pattern = '/msgid "You have %d new message."\nmsgstr ""/'; + $this->assertDoesNotMatchRegularExpression($pattern, $result, 'No duplicate msgid'); + + $pattern = '@\#: \./tests/test_app/templates/Pages/extract\.php:\d+\n'; + $pattern .= 'msgid "You deleted %d message."\nmsgid_plural "You deleted %d messages."@'; + $this->assertMatchesRegularExpression($pattern, $result); + + $pattern = '@\#: \./tests/test_app/templates/Pages/extract\.php:\d+\nmsgid "'; + $pattern .= 'Hot features!'; + $pattern .= '\\\n - No Configuration: Set-up the database and let the magic begin'; + $pattern .= '\\\n - Extremely Simple: Just look at the name...It\'s Cake'; + $pattern .= '\\\n - Active, Friendly Community: Join us #cakephp on IRC. We\'d love to help you get started'; + $pattern .= '"\nmsgstr ""@'; + $this->assertMatchesRegularExpression($pattern, $result); + + $this->assertStringContainsString('msgid "double \\"quoted\\""', $result, 'Strings with quotes not handled correctly'); + $this->assertStringContainsString("msgid \"single 'quoted'\"", $result, 'Strings with quotes not handled correctly'); + + $pattern = '@\#: \./tests/test_app/templates/Pages/extract\.php:\d+\n'; + $pattern .= 'msgctxt "mail"\n'; + $pattern .= 'msgid "letter"@'; + $this->assertMatchesRegularExpression($pattern, $result); + + $pattern = '@\#: \./tests/test_app/templates/Pages/extract\.php:\d+\n'; + $pattern .= 'msgctxt "alphabet"\n'; + $pattern .= 'msgid "letter"@'; + $this->assertMatchesRegularExpression($pattern, $result); + + // extract.php - reading the domain.pot + $result = file_get_contents($this->path . DS . 'domain.pot'); + + $pattern = '/msgid "You have %d new message."\nmsgid_plural "You have %d new messages."/'; + $this->assertDoesNotMatchRegularExpression($pattern, $result); + $pattern = '/msgid "You deleted %d message."\nmsgid_plural "You deleted %d messages."/'; + $this->assertDoesNotMatchRegularExpression($pattern, $result); + + $pattern = '/msgid "You have %d new message \(domain\)."\nmsgid_plural "You have %d new messages \(domain\)."/'; + $this->assertMatchesRegularExpression($pattern, $result); + $pattern = '/msgid "You deleted %d message \(domain\)."\nmsgid_plural "You deleted %d messages \(domain\)."/'; + $this->assertMatchesRegularExpression($pattern, $result); + } + + /** + * testExecute with no paths + */ + public function testExecuteNoPathOption(): void + { + $this->exec( + 'i18n extract ' . + '--merge=no ' . + '--extract-core=no ' . + '--output=' . $this->path . DS, + [ + TEST_APP . 'templates' . DS, + 'D', + ], + ); + $this->assertExitSuccess(); + $this->assertFileExists($this->path . DS . 'default.pot'); + } + + /** + * testExecute with merging on method + */ + public function testExecuteMerge(): void + { + $this->exec( + 'i18n extract ' . + '--merge=yes ' . + '--extract-core=no ' . + '--paths=' . TEST_APP . 'templates' . DS . 'Pages ' . + '--output=' . $this->path . DS, + ); + $this->assertExitSuccess(); + $this->assertFileExists($this->path . DS . 'default.pot'); + $this->assertFileDoesNotExist($this->path . DS . 'cake.pot'); + $this->assertFileDoesNotExist($this->path . DS . 'domain.pot'); + } + + /** + * test exclusions + */ + public function testExtractWithExclude(): void + { + $this->exec( + 'i18n extract ' . + '--extract-core=no ' . + '--exclude=Pages,Layout ' . + '--paths=' . TEST_APP . 'templates' . DS . ' ' . + '--output=' . $this->path . DS, + ); + $this->assertExitSuccess(); + $this->assertFileExists($this->path . DS . 'default.pot'); + $result = file_get_contents($this->path . DS . 'default.pot'); + + $pattern = '/\#: .*extract\.php:\d+\n/'; + $this->assertDoesNotMatchRegularExpression($pattern, $result); + + $pattern = '/\#: .*default\.php:\d+\n/'; + $this->assertDoesNotMatchRegularExpression($pattern, $result); + } + + /** + * testExtractWithoutLocations method + */ + public function testExtractWithoutLocations(): void + { + $this->exec( + 'i18n extract ' . + '--extract-core=no ' . + '--no-location=true ' . + '--exclude=Pages,Layout ' . + '--paths=' . TEST_APP . 'templates' . DS . ' ' . + '--output=' . $this->path . DS, + ); + $this->assertExitSuccess(); + $this->assertFileExists($this->path . DS . 'default.pot'); + + $result = file_get_contents($this->path . DS . 'default.pot'); + + $pattern = '/\n\#: .*\n/'; + $this->assertDoesNotMatchRegularExpression($pattern, $result); + } + + /** + * test extract can read more than one path. + */ + public function testExtractMultiplePaths(): void + { + $this->exec( + 'i18n extract ' . + '--extract-core=no ' . + '--exclude=Pages,Layout ' . + '--paths=' . TEST_APP . 'templates/Pages,' . + TEST_APP . 'templates/Posts ' . + '--output=' . $this->path . DS, + ); + $this->assertExitSuccess(); + $result = file_get_contents($this->path . DS . 'default.pot'); + + $pattern = '/msgid "Add User"/'; + $this->assertMatchesRegularExpression($pattern, $result); + } + + /** + * Tests that it is possible to exclude plugin paths by enabling the param option for the ExtractTask + */ + public function testExtractExcludePlugins(): void + { + static::setAppNamespace(); + $this->exec( + 'i18n extract ' . + '--extract-core=no ' . + '--exclude-plugins=true ' . + '--paths=' . TEST_APP . 'TestApp/ ' . + '--output=' . $this->path . DS, + ); + $this->assertExitSuccess(); + + $result = file_get_contents($this->path . DS . 'default.pot'); + $this->assertDoesNotMatchRegularExpression('#TestPlugin#', $result); + } + + /** + * Test that is possible to extract messages from a single plugin + */ + public function testExtractPlugin(): void + { + Configure::write('Plugins.autoload', ['TestPlugin']); + + $this->exec( + 'i18n extract ' . + '--extract-core=no ' . + '--plugin=TestPlugin ' . + '--output=' . $this->path . DS, + ); + $this->assertExitSuccess(); + + $result = file_get_contents($this->path . DS . 'default.pot'); + $this->assertDoesNotMatchRegularExpression('#Pages#', $result); + $this->assertMatchesRegularExpression('/translate\.php:\d+/', $result); + $this->assertStringContainsString('This is a translatable string', $result); + + Configure::delete('Plugins.autoload'); + } + + /** + * Test that is possible to extract messages from a vendor prefixed plugin. + */ + public function testExtractVendorPrefixedPlugin(): void + { + $this->loadPlugins(['Company/TestPluginThree']); + + $this->exec( + 'i18n extract ' . + '--extract-core=no ' . + '--plugin=Company/TestPluginThree ' . + '--output=' . $this->path . DS, + ); + $this->assertExitSuccess(); + + $result = file_get_contents($this->path . DS . 'company_test_plugin_three.pot'); + $this->assertDoesNotMatchRegularExpression('#Pages#', $result); + $this->assertMatchesRegularExpression('/default\.php:\d+/', $result); + $this->assertStringContainsString('A vendor message', $result); + } + + /** + * Test that the extract shell overwrites existing files with the overwrite parameter + */ + public function testExtractOverwrite(): void + { + file_put_contents($this->path . DS . 'default.pot', 'will be overwritten'); + $this->assertFileExists($this->path . DS . 'default.pot'); + $original = file_get_contents($this->path . DS . 'default.pot'); + + $this->exec( + 'i18n extract ' . + '--extract-core=no ' . + '--overwrite ' . + '--paths=' . TEST_APP . 'TestApp/ ' . + '--output=' . $this->path . DS, + ); + $this->assertExitSuccess(); + + $result = file_get_contents($this->path . DS . 'default.pot'); + $this->assertNotEquals($original, $result); + } + + /** + * Test that the extract shell scans the core libs + */ + public function testExtractCore(): void + { + $this->exec( + 'i18n extract ' . + '--extract-core=yes ' . + '--paths=' . TEST_APP . 'TestApp/ ' . + '--output=' . $this->path . DS, + ); + $this->assertNotNull($this->_err); + $this->assertEmpty($this->_err->messages(), 'Should not have output to stderr'); + $this->assertExitSuccess(); + + $this->assertFileExists($this->path . DS . 'cake.pot'); + $result = file_get_contents($this->path . DS . 'cake.pot'); + $this->assertTrue(is_string($result)); + + $pattern = '/#: Console\/Templates\//'; + $this->assertDoesNotMatchRegularExpression($pattern, $result); + + $pattern = '/#: Test\//'; + $this->assertDoesNotMatchRegularExpression($pattern, $result); + } + + /** + * Test when marker-error option is set + * When marker-error is unset, it's already test + * with other functions like testExecute that not detects error because err never called + */ + public function testMarkerErrorSets(): void + { + $this->exec( + 'i18n extract ' . + '--marker-error ' . + '--merge=no ' . + '--extract-core=no ' . + '--paths=' . TEST_APP . 'templates/Pages ' . + '--output=' . $this->path . DS, + ); + $this->assertExitSuccess(); + $this->assertErrorContains('Invalid marker content in'); + $this->assertErrorContains('extract.php'); + } + + /** + * Test extraction of Label attribute strings from enum cases. + */ + public function testExtractLabelAttributes(): void + { + $this->exec( + 'i18n extract ' . + '--merge=no ' . + '--extract-core=no ' . + '--paths=' . TEST_APP . 'TestApp/Model/Enum ' . + '--output=' . $this->path . DS, + ); + $this->assertExitSuccess(); + $this->assertFileExists($this->path . DS . 'default.pot'); + $result = file_get_contents($this->path . DS . 'default.pot'); + + $this->assertStringContainsString('msgid "Published"', $result); + $this->assertStringContainsString('msgid "Unpublished"', $result); + + $pattern = '/msgctxt "article_status"\nmsgid "Archived"/'; + $this->assertMatchesRegularExpression($pattern, $result); + } + + /** + * test relative-paths option + */ + public function testExtractWithRelativePaths(): void + { + $this->exec( + 'i18n extract ' . + '--extract-core=no ' . + '--paths=' . TEST_APP . 'templates ' . + '--output=' . $this->path . DS, + ); + $this->assertExitSuccess(); + $this->assertFileExists($this->path . DS . 'default.pot'); + $result = file_get_contents($this->path . DS . 'default.pot'); + + $expected = '#: ./tests/test_app/templates/Pages/extract.php:'; + $this->assertStringContainsString($expected, $result); + } + + /** + * test invalid path options + */ + public function testExtractWithInvalidPaths(): void + { + $this->exec( + 'i18n extract ' . + '--extract-core=no ' . + '--paths=' . TEST_APP . 'templates,' . TEST_APP . 'unknown ' . + '--output=' . $this->path . DS, + ); + $this->assertExitSuccess(); + $this->assertFileExists($this->path . DS . 'default.pot'); + $result = file_get_contents($this->path . DS . 'default.pot'); + + $expected = '#: ./tests/test_app/templates/Pages/extract.php:'; + $this->assertStringContainsString($expected, $result); + } + + /** + * Test with associative arrays in App.path.locales and App.path.templates. + */ + public function testExtractWithAssociativePaths(): void + { + Configure::write('App.paths', [ + 'plugins' => ['customKey' => TEST_APP . 'Plugin' . DS], + 'templates' => ['customKey' => TEST_APP . 'templates' . DS], + 'locales' => ['customKey' => TEST_APP . 'resources' . DS . 'locales' . DS], + ]); + + $this->exec( + 'i18n extract ' . + '--merge=no ' . + '--extract-core=no ', + [ + // Sending two empty inputs so \Cake\Command\I18nExtractCommand::_getPaths() + // loops through all paths + '', + '', + 'D', + $this->path . DS, + ], + ); + $this->assertExitSuccess(); + $this->assertFileExists($this->path . DS . 'default.pot'); + $result = file_get_contents($this->path . DS . 'default.pot'); + + $expected = '#: ./tests/test_app/templates/Pages/extract.php:'; + $this->assertStringContainsString($expected, $result); + } +} diff --git a/tests/TestCase/Command/PluginAssetsCommandsTest.php b/tests/TestCase/Command/PluginAssetsCommandsTest.php new file mode 100644 index 00000000000..8286dc92472 --- /dev/null +++ b/tests/TestCase/Command/PluginAssetsCommandsTest.php @@ -0,0 +1,379 @@ +wwwRoot = TMP . 'assets_task_webroot' . DS; + Configure::write('App.wwwRoot', $this->wwwRoot); + + $this->fs = new Filesystem(); + $this->fs->deleteDir($this->wwwRoot); + $this->fs->copyDir(WWW_ROOT, $this->wwwRoot); + + $this->setAppNamespace(); + $this->configApplication(Configure::read('App.namespace') . '\ApplicationWithDefaultRoutes', []); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + $this->clearPlugins(); + } + + /** + * testSymlink method + */ + public function testSymlink(): void + { + $this->loadPlugins(['TestPlugin' => ['routes' => false], 'Company/TestPluginThree']); + + $this->exec('plugin assets symlink'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $path = $this->wwwRoot . 'test_plugin'; + $this->assertFileExists($path . DS . 'root.js'); + $this->assertTrue(is_link($path)); + + $path = $this->wwwRoot . 'company' . DS . 'test_plugin_three'; + $this->assertFileExists($path . DS . 'css' . DS . 'company.css'); + $this->assertTrue(is_link($path)); + } + + /** + * testRelativeSymlink method + */ + public function testRelativeSymlink(): void + { + $this->skipIf(DS === '\\', 'Cant perform operations with symlinks windows.'); + $this->loadPlugins(['TestPlugin' => ['routes' => false], 'Company/TestPluginThree']); + + $this->exec('plugin assets symlink --relative'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $path = $this->wwwRoot . 'test_plugin'; + $this->assertFileExists($path . DS . 'root.js'); + $this->assertTrue(is_link($path)); + + $path = $this->wwwRoot . 'company' . DS . 'test_plugin_three'; + $this->assertFileExists($path . DS . 'css' . DS . 'company.css'); + $this->assertTrue(is_link($path)); + + // Verify that the symlink is relative + $target = readlink($path); + $this->assertStringStartsWith('../', $target); + } + + public function testSymlinkWhenVendorDirectoryExists(): void + { + $this->loadPlugins(['Company/TestPluginThree']); + + mkdir($this->wwwRoot . 'company'); + + $this->exec('plugin assets symlink'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $path = $this->wwwRoot . 'company' . DS . 'test_plugin_three'; + $this->assertFileExists($path . DS . 'css' . DS . 'company.css'); + $this->assertTrue(is_link($path)); + } + + public function testSymlinkWhenTargetAlreadyExits(): void + { + $this->loadPlugins(['TestPlugin']); + + // Run once to create the symlink + $this->exec('plugin assets symlink'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $path = $this->wwwRoot . 'test_plugin'; + $this->assertTrue(is_link($path)); + $this->assertFileExists($path . DS . 'root.js'); + + // Re-run the symlink command + $this->exec('plugin assets symlink'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $this->assertTrue(is_link($path)); + $this->assertFileExists($path . DS . 'root.js'); + } + + /** + * Tests symlink is re-created when it points to a missing target + */ + public function testSymlinkWhenSymlinkExistsButTargetMissing(): void + { + $this->loadPlugins(['TestPlugin']); + + $this->exec('plugin assets symlink'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $path = $this->wwwRoot . 'test_plugin'; + $this->assertFileExists($path . DS . 'root.js'); + $this->assertTrue(is_link($path)); + + // Point the symlink to a missing target + $fakeTarget = $this->wwwRoot . 'target_dir'; + mkdir($fakeTarget); + + DIRECTORY_SEPARATOR === '\\' ? rmdir($path) : unlink($path); + symlink($fakeTarget, $path); + rmdir($fakeTarget); + + $this->assertFileDoesNotExist($fakeTarget); + $this->assertTrue(is_link($path)); + + // Re-run the symlink command + $this->exec('plugin assets symlink'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $this->assertFileExists($path . DS . 'root.js'); + $this->assertTrue(is_link($path)); + } + + public function testSymlinkWhenTargetAlreadyExitsAsDir(): void + { + $this->loadPlugins(['TestPlugin']); + + $path = $this->wwwRoot . 'test_plugin'; + mkdir($path, recursive: true); + + $this->exec('plugin assets symlink'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $this->assertFileExists($path . DS . 'root.js'); + $this->assertTrue(is_link($path)); + } + + /** + * test that plugins without webroot are not processed + */ + public function testForPluginWithoutWebroot(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->loadPlugins(['TestPluginTwo']); + $this->exec('plugin assets symlink'); + }); + $this->assertFileDoesNotExist($this->wwwRoot . 'test_plugin_two'); + } + + /** + * testSymlinkingSpecifiedPlugin + */ + public function testSymlinkingSpecifiedPlugin(): void + { + $this->loadPlugins(['TestPlugin' => ['routes' => false], 'Company/TestPluginThree']); + + $this->exec('plugin assets symlink TestPlugin'); + + $path = $this->wwwRoot . 'test_plugin'; + $this->assertFileExists($path . DS . 'root.js'); + + $path = $this->wwwRoot . 'company' . DS . 'test_plugin_three'; + $this->assertDirectoryDoesNotExist($path); + $this->assertFalse(is_link($path)); + } + + /** + * testCopy + */ + public function testCopy(): void + { + $this->loadPlugins(['TestPlugin' => ['routes' => false], 'Company/TestPluginThree']); + + $this->exec('plugin assets copy'); + + $path = $this->wwwRoot . 'test_plugin'; + $this->assertDirectoryExists($path); + $this->assertFileExists($path . DS . 'root.js'); + + $path = $this->wwwRoot . 'company' . DS . 'test_plugin_three'; + $this->assertDirectoryExists($path); + $this->assertFileExists($path . DS . 'css' . DS . 'company.css'); + } + + public function testCopyWithExistingSymlink(): void + { + $this->loadPlugins(['TestPlugin']); + + // Run once to create the symlink + $this->exec('plugin assets symlink'); + + $path = $this->wwwRoot . 'test_plugin'; + $this->assertTrue(is_link($path)); + + // Re-run as copy + $this->exec('plugin assets copy'); + + $path = $this->wwwRoot . 'test_plugin'; + $this->assertFalse(is_link($path)); + $this->assertDirectoryExists($path); + $this->assertFileExists($path . DS . 'root.js'); + } + + /** + * testCopyOverwrite + */ + public function testCopyOverwrite(): void + { + $this->loadPlugins(['TestPlugin' => ['routes' => false]]); + + $this->exec('plugin assets copy'); + + $pluginPath = TEST_APP . 'Plugin' . DS . 'TestPlugin' . DS . 'webroot'; + + $path = $this->wwwRoot . 'test_plugin'; + $dir = new SplFileInfo($path); + $this->assertTrue($dir->isDir()); + $this->assertFileExists($path . DS . 'root.js'); + + file_put_contents($path . DS . 'root.js', 'updated'); + + $this->exec('plugin assets copy'); + + $this->assertFileNotEquals($path . DS . 'root.js', $pluginPath . DS . 'root.js'); + + $this->exec('plugin assets copy --overwrite'); + + $this->assertFileEquals($path . DS . 'root.js', $pluginPath . DS . 'root.js'); + } + + public function testCopyOverwriteWithExistingSymlink(): void + { + $this->loadPlugins(['TestPlugin']); + + // Run once to create the symlink + $this->exec('plugin assets symlink'); + + $path = $this->wwwRoot . 'test_plugin'; + $this->assertTrue(is_link($path)); + + // Re-run as copy + $this->exec('plugin assets copy --overwrite'); + + $path = $this->wwwRoot . 'test_plugin'; + $this->assertFalse(is_link($path)); + $this->assertDirectoryExists($path); + $this->assertFileExists($path . DS . 'root.js'); + } + + /** + * testRemoveSymlink method + */ + public function testRemoveSymlink(): void + { + $this->loadPlugins(['TestPlugin' => ['routes' => false], 'Company/TestPluginThree']); + + mkdir($this->wwwRoot . 'company'); + + $this->exec('plugin assets symlink'); + + $this->assertTrue(is_link($this->wwwRoot . 'test_plugin')); + + $path = $this->wwwRoot . 'company' . DS . 'test_plugin_three'; + $this->assertTrue(is_link($path)); + + $this->exec('plugin assets remove'); + + $this->assertFalse(is_link($this->wwwRoot . 'test_plugin')); + $this->assertFalse(is_link($path)); + $this->assertDirectoryExists($this->wwwRoot . 'company', "Ensure namespace folder isn't removed"); + } + + /** + * testRemoveFolder method + */ + public function testRemoveFolder(): void + { + $this->loadPlugins(['TestPlugin' => ['routes' => false], 'Company/TestPluginThree']); + + $this->exec('plugin assets copy'); + + $this->assertTrue(is_dir($this->wwwRoot . 'test_plugin')); + + $this->assertTrue(is_dir($this->wwwRoot . 'company' . DS . 'test_plugin_three')); + + $this->exec('plugin assets remove'); + + $this->assertDirectoryDoesNotExist($this->wwwRoot . 'test_plugin'); + $this->assertDirectoryDoesNotExist($this->wwwRoot . 'company' . DS . 'test_plugin_three'); + $this->assertDirectoryExists($this->wwwRoot . 'company', "Ensure namespace folder isn't removed"); + } + + /** + * testOverwrite + */ + public function testOverwrite(): void + { + $this->loadPlugins(['TestPlugin' => ['routes' => false], 'Company/TestPluginThree']); + + $path = $this->wwwRoot . 'test_plugin'; + + mkdir($path); + $filectime = filectime($path); + + sleep(1); + $this->exec('plugin assets symlink TestPlugin --overwrite'); + $this->assertTrue(is_link($path)); + + $newfilectime = filectime($path); + $this->assertTrue($newfilectime !== $filectime); + + $path = $this->wwwRoot . 'company' . DS . 'test_plugin_three'; + mkdir($path, 0777, true); + $filectime = filectime($path); + + sleep(1); + $this->exec('plugin assets copy Company/TestPluginThree --overwrite'); + + $newfilectime = filectime($path); + $this->assertTrue($newfilectime > $filectime); + } +} diff --git a/tests/TestCase/Command/PluginConfigFileTrait.php b/tests/TestCase/Command/PluginConfigFileTrait.php new file mode 100644 index 00000000000..3a20eb1dc1f --- /dev/null +++ b/tests/TestCase/Command/PluginConfigFileTrait.php @@ -0,0 +1,52 @@ +invalidatePhpFileCache($path); + } + + protected function deletePhpFile(string $path): void + { + $this->invalidatePhpFileCache($path); + unlink($path); + } + + /** + * @return array + */ + protected function includePhpConfig(string $path): array + { + $this->invalidatePhpFileCache($path); + $config = include $path; + assert(is_array($config)); + + return $config; + } +} diff --git a/tests/TestCase/Command/PluginListCommandTest.php b/tests/TestCase/Command/PluginListCommandTest.php new file mode 100644 index 00000000000..ae9f6a66c5b --- /dev/null +++ b/tests/TestCase/Command/PluginListCommandTest.php @@ -0,0 +1,221 @@ +setAppNamespace(); + $this->pluginsListPath = ROOT . DS . 'cakephp-plugins.php'; + if (file_exists($this->pluginsListPath)) { + $this->deletePhpFile($this->pluginsListPath); + } + $this->pluginsConfigPath = CONFIG . 'plugins.php'; + if (file_exists($this->pluginsConfigPath)) { + $this->originalPluginsConfigContent = file_get_contents($this->pluginsConfigPath); + } + } + + protected function tearDown(): void + { + parent::tearDown(); + Configure::delete('plugins'); + PluginConfig::clearCache(); + if (file_exists($this->pluginsListPath)) { + $this->deletePhpFile($this->pluginsListPath); + } + if (file_exists($this->pluginsConfigPath)) { + $this->writePhpFile($this->pluginsConfigPath, $this->originalPluginsConfigContent); + } + } + + /** + * Test generating help succeeds + */ + public function testHelp(): void + { + $this->exec('plugin list --help'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('plugin list'); + } + + /** + * Test plugin names are being displayed correctly + */ + public function testList(): void + { + $file = << [ + 'TestPlugin' => '/config/path/', + 'OtherPlugin' => '/config/path/' + ] +]; +PHP; + $this->writePhpFile($this->pluginsListPath, $file); + + $this->exec('plugin list'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('TestPlugin'); + $this->assertOutputContains('OtherPlugin'); + } + + /** + * Test empty plugins array + */ + public function testListEmpty(): void + { + $file = <<writePhpFile($this->pluginsListPath, $file); + + $this->exec('plugin list'); + $this->assertExitCode(CommandInterface::CODE_ERROR); + $this->assertErrorContains('No plugins have been found.'); + } + + /** + * Test enabled plugins are being flagged as enabled + */ + public function testListEnabled(): void + { + $file = << [ + 'TestPlugin' => '/config/path/', + 'OtherPlugin' => '/config/path/' + ] +]; +PHP; + $this->writePhpFile($this->pluginsListPath, $file); + + $config = << ['onlyDebug' => true, 'onlyCli' => true, 'optional' => true] +]; +PHP; + $this->writePhpFile($this->pluginsConfigPath, $config); + + $this->deprecated(function (): void { + $this->exec('plugin list'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('TestPlugin'); + $this->assertOutputContains('OtherPlugin'); + } + + /** + * Test listing unknown plugins throws an exception + */ + public function testListUnknown(): void + { + $file = << [ + 'TestPlugin' => '/config/path/', + 'OtherPlugin' => '/config/path/' + ] +]; +PHP; + $this->writePhpFile($this->pluginsListPath, $file); + + $config = <<writePhpFile($this->pluginsConfigPath, $config); + + $this->expectException(MissingPluginException::class); + $this->expectExceptionMessage('Plugin `Unknown` could not be found.'); + + $this->exec('plugin list'); + } + + /** + * Test listing vendor plugins with versions + */ + public function testListWithVersions(): void + { + $file = << [ + 'Chronos' => ROOT . '/vendor/cakephp/chronos', + 'CodeSniffer' => ROOT . '/vendor/cakephp/cakephp-codesniffer' + ] +]; +PHP; + $this->writePhpFile($this->pluginsListPath, $file); + + $config = <<writePhpFile($this->pluginsConfigPath, $config); + + $path = ROOT . DS . 'tests' . DS . 'composer.lock'; + $this->deprecated(function () use ($path): void { + $this->exec(sprintf('plugin list --composer-path="%s"', $path)); + }); + $this->assertOutputContains('| Chronos | X | | | | 3.0.4 |'); + $this->assertOutputContains('| CodeSniffer | X | | | | 5.1.1 |'); + } +} diff --git a/tests/TestCase/Command/PluginLoadCommandTest.php b/tests/TestCase/Command/PluginLoadCommandTest.php new file mode 100644 index 00000000000..54c83bd1cad --- /dev/null +++ b/tests/TestCase/Command/PluginLoadCommandTest.php @@ -0,0 +1,151 @@ +configFile = CONFIG . 'plugins.php'; + $this->originalContent = file_get_contents($this->configFile); + + $this->setAppNamespace(); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + + $this->writePhpFile($this->configFile, $this->originalContent); + } + + /** + * Test generating help succeeds + */ + public function testHelp(): void + { + $this->exec('plugin load --help'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('plugin load'); + } + + /** + * Test loading a plugin modifies the config file + */ + public function testLoad(): void + { + $this->exec('plugin load TestPlugin'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + Plugin::getCollection()->remove('TestPlugin'); + + // Needed to not have duplicate named routes + Router::reload(); + $this->exec('plugin load TestPluginTwo --no-bootstrap --no-console --no-middleware --no-routes --no-services'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + Plugin::getCollection()->remove('TestPluginTwo'); + + // Needed to not have duplicate named routes + Router::reload(); + // Remove the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('plugin load Company/TestPluginThree --only-debug --only-cli'); + }); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + $config = $this->includePhpConfig($this->configFile); + $this->assertTrue(isset($config['TestPlugin'])); + $this->assertTrue(isset($config['TestPluginTwo'])); + $this->assertTrue(isset($config['Company/TestPluginThree'])); + $this->assertSame(['onlyDebug' => true, 'onlyCli' => true], $config['Company/TestPluginThree']); + $this->assertSame( + ['bootstrap' => false, 'console' => false, 'middleware' => false, 'routes' => false, 'services' => false], + $config['TestPluginTwo'], + ); + } + + /** + * Test recommendations for keywords in composer.json + */ + public function testLoadRecommendations(): void + { + $this->exec('plugin load TestPluginFour', ['y', 'y', 'y']); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + Plugin::getCollection()->remove('TestPluginFour'); + + $config = $this->includePhpConfig($this->configFile); + $expected = [ + 'onlyDebug' => true, + 'onlyCli' => true, + 'optional' => true, + ]; + $this->assertEquals($expected, $config['TestPluginFour']); + } + + /** + * Test loading an unknown plugin + */ + public function testLoadUnknownPlugin(): void + { + $this->exec('plugin load NopeNotThere'); + $this->assertExitCode(CommandInterface::CODE_ERROR); + $this->assertErrorContains('Plugin `NopeNotThere` could not be found'); + + $config = $this->includePhpConfig($this->configFile); + $this->assertFalse(isset($config['NopeNotThere'])); + } + + /** + * Test loading optional plugin + */ + public function testLoadOptionalPlugin(): void + { + $this->exec('plugin load NopeNotThere --optional'); + + $config = $this->includePhpConfig($this->configFile); + $this->assertTrue(isset($config['NopeNotThere'])); + $this->assertSame(['optional' => true], $config['NopeNotThere']); + } +} diff --git a/tests/TestCase/Command/PluginLoadedCommandTest.php b/tests/TestCase/Command/PluginLoadedCommandTest.php new file mode 100644 index 00000000000..a45ed06d3e9 --- /dev/null +++ b/tests/TestCase/Command/PluginLoadedCommandTest.php @@ -0,0 +1,55 @@ +setAppNamespace(); + } + + /** + * Tests that list of loaded plugins is shown with loaded command. + */ + public function testLoaded(): void + { + $expected = Plugin::loaded(); + + $this->exec('plugin loaded'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + + foreach ($expected as $value) { + $this->assertOutputContains($value); + } + } +} diff --git a/tests/TestCase/Command/PluginUnloadCommandTest.php b/tests/TestCase/Command/PluginUnloadCommandTest.php new file mode 100644 index 00000000000..0869f1e13d1 --- /dev/null +++ b/tests/TestCase/Command/PluginUnloadCommandTest.php @@ -0,0 +1,123 @@ +clear(); + + $this->configFile = CONFIG . 'plugins.php'; + $this->originalContent = file_get_contents($this->configFile); + + $contents = << ['routes' => false], + 'TestPluginTwo', + 'Company/TestPluginThree' + ]; + CONTENTS; + + $this->writePhpFile($this->configFile, $contents); + + $this->setAppNamespace(); + } + + /** + * tearDown method + */ + protected function tearDown(): void + { + parent::tearDown(); + + Plugin::getCollection()->clear(); + $this->writePhpFile($this->configFile, $this->originalContent); + } + + /** + * testUnload + */ + #[DataProvider('pluginNameProvider')] + public function testUnload($plugin): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function () use ($plugin): void { + $this->exec('plugin unload ' . $plugin); + }); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $contents = file_get_contents($this->configFile); + + $this->assertStringNotContainsString("'" . $plugin . "'", $contents); + $this->assertStringContainsString("'Company/TestPluginThree'", $contents); + } + + public static function pluginNameProvider(): array + { + return [ + ['TestPlugin'], + ['TestPluginTwo'], + ]; + } + + public function testUnloadNoConfigFile(): void + { + $this->deletePhpFile($this->configFile); + + $this->exec('plugin unload TestPlugin'); + $this->assertExitCode(CommandInterface::CODE_ERROR); + $this->assertErrorContains('`CONFIG/plugins.php` not found or does not return an array'); + } + + public function testUnloadUnknownPlugin(): void + { + // Removed the deprecated() wrapping when plugin class is added to TestPluginTwo + $this->deprecated(function (): void { + $this->exec('plugin unload NopeNotThere'); + }); + $this->assertExitCode(CommandInterface::CODE_ERROR); + $this->assertErrorContains('Plugin `NopeNotThere` could not be found'); + } +} diff --git a/tests/TestCase/Command/RoutesCommandTest.php b/tests/TestCase/Command/RoutesCommandTest.php new file mode 100644 index 00000000000..b1c0ed7138e --- /dev/null +++ b/tests/TestCase/Command/RoutesCommandTest.php @@ -0,0 +1,402 @@ +setAppNamespace(); + } + + /** + * tearDown + */ + protected function tearDown(): void + { + parent::tearDown(); + Router::reload(); + } + + /** + * Ensure help for `routes` works + */ + public function testRouteListHelp(): void + { + $this->exec('routes -h'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('list of routes'); + $this->assertErrorEmpty(); + } + + /** + * Test checking an nonexistent route. + */ + public function testRouteList(): void + { + $this->exec('routes'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContainsRow([ + 'Route name', + 'URI template', + 'Plugin', + 'Prefix', + 'Controller', + 'Action', + 'Method(s)', + ]); + $this->assertOutputContainsRow([ + 'articles:_action', + '/app/articles/{action}/*', + '', + '', + 'Articles', + 'index', + '', + ]); + $this->assertOutputContainsRow([ + 'bake._controller:_action', + '/bake/{controller}/{action}', + 'Bake', + '', + '', + 'index', + '', + ]); + $this->assertOutputContainsRow([ + 'testName', + '/app/tests/{action}/*', + '', + '', + 'Tests', + 'index', + '', + ]); + } + + /** + * Test routes with --verbose option + */ + public function testRouteListVerbose(): void + { + $this->exec('routes -v'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContainsRow([ + 'Route name', + 'URI template', + 'Plugin', + 'Prefix', + 'Controller', + 'Action', + 'Method(s)', + 'Middlewares', + 'Defaults', + ]); + $this->assertOutputContainsRow([ + 'articles:_action', + '/app/articles/{action}/*', + '', + '', + 'Articles', + 'index', + '', + 'dumb, sample', + '{"action":"index","controller":"Articles","plugin":null}', + ]); + } + + /** + * Test routes with --sort option + */ + public function testRouteListSorted(): void + { + Configure::write('TestApp.routes', function ($routes): void { + $routes->connect( + new Route('/a/route/sorted', [], ['_name' => '_aRoute']), + ); + }); + + $this->exec('routes -s'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('_aRoute', $this->_out->messages()[3]); + } + + /** + * Test routes with --with-middlewares option + */ + public function testRouteWithMiddlewares(): void + { + $this->exec('routes -m'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContainsRow([ + 'articles:_action', + '/app/articles/{action}/*', + '', + '', + 'Articles', + 'index', + '', + 'dumb, sample', + ]); + } + + /** + * Ensure help for `routes` works + */ + public function testCheckHelp(): void + { + $this->exec('routes check -h'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('Check a URL'); + $this->assertErrorEmpty(); + } + + /** + * Ensure routes check with no input + */ + public function testCheckNoInput(): void + { + $this->exec('routes check'); + $this->assertExitCode(CommandInterface::CODE_ERROR); + $this->assertErrorContains('`url` argument is required'); + } + + /** + * Test checking an existing route. + */ + public function testCheck(): void + { + $this->exec('routes check /app/articles/check'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContainsRow([ + 'Route name', + 'URI template', + 'Defaults', + ]); + $this->assertOutputContainsRow([ + 'articles:_action', + '/app/articles/check', + '{"_middleware":["dumb","sample"],"action":"check","controller":"Articles","pass":[],"plugin":null}', + ]); + } + + /** + * Test checking an existing route with named route. + */ + public function testCheckWithNamedRoute(): void + { + $this->exec('routes check /app/tests/index'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContainsRow([ + 'Route name', + 'URI template', + 'Defaults', + ]); + $this->assertOutputContainsRow([ + 'testName', + '/app/tests/index', + '{"_middleware":["dumb","sample"],"_name":"testName","action":"index","controller":"Tests","pass":[],"plugin":null}', + ]); + } + + /** + * Test checking an existing route with redirect route. + */ + public function testCheckWithRedirectRoute(): void + { + $this->exec('routes check /app/redirect'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContainsRow([ + 'URI template', + 'Redirect', + ]); + $this->assertOutputContainsRow([ + '/app/redirect', + 'http://example.com/test.html', + ]); + } + + /** + * Test checking an nonexistent route. + */ + public function testCheckNotFound(): void + { + $this->exec('routes check /nope'); + $this->assertExitCode(CommandInterface::CODE_ERROR); + $this->assertErrorContains('did not match'); + } + + /** + * Ensure help for `routes` works + */ + public function testGenerareHelp(): void + { + $this->exec('routes generate -h'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('Check a routing array'); + $this->assertErrorEmpty(); + } + + /** + * Test generating URLs + */ + public function testGenerateNoPassArgs(): void + { + $this->exec('routes generate controller:Articles action:index'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('> /app/articles'); + $this->assertErrorEmpty(); + } + + /** + * Test generating URLs with passed arguments + */ + public function testGeneratePassedArguments(): void + { + $this->exec('routes generate controller:Articles action:view 2 3'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('> /app/articles/view/2/3'); + $this->assertErrorEmpty(); + } + + /** + * Test generating URLs with bool params + */ + public function testGenerateBoolParams(): void + { + $this->exec('routes generate controller:Articles action:index _https:true _host:example.com'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('> https://example.com/app/articles'); + } + + public function testGenerateNameWithColon(): void + { + Configure::write('TestApp.routes', function ($routes): void { + $routes->connect( + '/example/update', + ['controller' => 'Example', 'action' => 'update'], + ['_name' => 'example:update'], + ); + }); + $this->exec('routes generate _name:example:update'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('> /example/update'); + } + + /** + * Test generating URLs + */ + public function testGenerateMissing(): void + { + $this->exec('routes generate plugin:Derp controller:Derp'); + $this->assertExitCode(CommandInterface::CODE_ERROR); + $this->assertErrorContains('do not match'); + } + + /** + * Test routes duplicate warning + */ + public function testRouteDuplicateWarning(): void + { + Configure::write('TestApp.routes', function ($builder): void { + $builder->connect( + new Route('/unique-path', [], ['_name' => '_aRoute']), + ); + $builder->connect( + new Route('/unique-path', [], ['_name' => '_bRoute']), + ); + + $builder->connect( + new Route('/blog', ['_method' => 'GET'], ['_name' => 'blog-get']), + ); + $builder->connect( + new Route('/blog', [], ['_name' => 'blog-all']), + ); + + $builder->connect( + new Route('/events', ['_method' => ['POST', 'PUT']], ['_name' => 'events-post']), + ); + $builder->connect( + new Route('/events', ['_method' => 'GET'], ['_name' => 'events-get']), + ); + }); + + $this->exec('routes'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContainsRow([ + 'Route name', + 'URI template', + 'Plugin', + 'Prefix', + 'Controller', + 'Action', + 'Method(s)', + ]); + $this->assertOutputContainsRow([ + '_aRoute', + '/unique-path', + '', + '', + '', + '', + '', + ]); + $this->assertOutputContainsRow([ + '_bRoute', + '/unique-path', + '', + '', + '', + '', + '', + ]); + $this->assertOutputContainsRow([ + 'blog-get', + '/blog', + '', + '', + '', + '', + '', + ]); + $this->assertOutputContainsRow([ + 'blog-all', + '/blog', + '', + '', + '', + '', + '', + ]); + } +} diff --git a/tests/TestCase/Command/SchemaCacheCommandsTest.php b/tests/TestCase/Command/SchemaCacheCommandsTest.php new file mode 100644 index 00000000000..8a04ffd7e88 --- /dev/null +++ b/tests/TestCase/Command/SchemaCacheCommandsTest.php @@ -0,0 +1,196 @@ + + */ + protected array $fixtures = ['core.Articles', 'core.Tags']; + + /** + * @var \Cake\Datasource\ConnectionInterface + */ + protected $connection; + + /** + * @var \Cake\Cache\Engine\NullEngine|\Mockery\MockInterface + */ + protected $cache; + + /** + * setup method + */ + protected function setUp(): void + { + parent::setUp(); + $this->setAppNamespace(); + + $this->cache = Mockery::mock(NullEngine::class)->makePartial(); + + Cache::setConfig('orm_cache', $this->cache); + + $this->connection = ConnectionManager::get('test'); + $this->connection->cacheMetadata('orm_cache'); + } + + /** + * Teardown + */ + protected function tearDown(): void + { + $this->connection->cacheMetadata(false); + parent::tearDown(); + + unset($this->connection); + Cache::drop('orm_cache'); + } + + /** + * Test that clear enables the cache if it was disabled. + */ + public function testClearEnablesMetadataCache(): void + { + $this->connection->cacheMetadata(false); + + $this->exec('schema_cache clear --connection test'); + $this->assertExitSuccess(); + $this->assertInstanceOf(CachedCollection::class, $this->connection->getSchemaCollection()); + } + + /** + * Test that build enables the cache if it was disabled. + */ + public function testBuildEnablesMetadataCache(): void + { + $this->connection->cacheMetadata(false); + + $this->exec('schema_cache build --connection test'); + $this->assertExitSuccess(); + $this->assertInstanceOf(CachedCollection::class, $this->connection->getSchemaCollection()); + } + + /** + * Test build() with no args. + */ + public function testBuildNoArgs(): void + { + $this->cache->shouldReceive('set') + ->atLeast()->once() + ->andReturn(true); + + $this->exec('schema_cache build --connection test'); + $this->assertExitSuccess(); + } + + /** + * Test build() with one arg. + */ + public function testBuildNamedModel(): void + { + $this->cache->shouldReceive('set') + ->once() + ->withSomeOfArgs('test_articles') + ->andReturn(true); + $this->cache->shouldReceive('delete') + ->never(); + + $this->exec('schema_cache build --connection test articles'); + $this->assertExitSuccess(); + } + + /** + * Test build() overwrites cached data. + */ + public function testBuildOverwritesExistingData(): void + { + $this->cache->shouldReceive('set') + ->once() + ->withSomeOfArgs('test_articles') + ->andReturn(true); + $this->cache->shouldReceive('get') + ->never(); + $this->cache->shouldReceive('delete') + ->never(); + + $this->exec('schema_cache build --connection test articles'); + $this->assertExitSuccess(); + } + + /** + * Test build() with a nonexistent connection name. + */ + public function testBuildInvalidConnection(): void + { + $this->exec('schema_cache build --connection derpy-derp articles'); + $this->assertExitError(); + } + + /** + * Test clear() with an invalid connection name. + */ + public function testClearInvalidConnection(): void + { + $this->exec('schema_cache clear --connection derpy-derp articles'); + $this->assertExitError(); + } + + /** + * Test clear() with no args. + */ + public function testClearNoArgs(): void + { + $this->cache->shouldReceive('delete') + ->atLeast()->once() + ->andReturn(true); + + $this->exec('schema_cache clear --connection test'); + $this->assertExitSuccess(); + } + + /** + * Test clear() with a model name. + */ + public function testClearNamedModel(): void + { + $this->cache->shouldReceive('set') + ->never(); + $this->cache->shouldReceive('delete') + ->once() + ->with('test_articles') + ->andReturn(false); + + $this->exec('schema_cache clear --connection test articles'); + $this->assertExitSuccess(); + } +} diff --git a/tests/TestCase/Command/ServerCommandTest.php b/tests/TestCase/Command/ServerCommandTest.php new file mode 100644 index 00000000000..4441d7c41fb --- /dev/null +++ b/tests/TestCase/Command/ServerCommandTest.php @@ -0,0 +1,54 @@ +command = new ServerCommand(); + } + + /** + * Test that the option parser is shaped right. + */ + public function testGetOptionParser(): void + { + $parser = $this->command->getOptionParser(); + $options = $parser->options(); + $this->assertArrayHasKey('host', $options); + $this->assertArrayHasKey('port', $options); + $this->assertArrayHasKey('ini_path', $options); + $this->assertArrayHasKey('document_root', $options); + $this->assertArrayHasKey('frankenphp', $options); + } +} diff --git a/tests/TestCase/Command/VersionCommandTest.php b/tests/TestCase/Command/VersionCommandTest.php new file mode 100644 index 00000000000..f30dbd1847a --- /dev/null +++ b/tests/TestCase/Command/VersionCommandTest.php @@ -0,0 +1,101 @@ +setAppNamespace(); + } + + /** + * Test basic version output + */ + public function testVersion(): void + { + $this->exec('version'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains(Configure::version()); + } + + /** + * Test verbose output with stable version + */ + public function testVerboseWithStableVersion(): void + { + $originalVersion = Configure::read('Cake.version'); + Configure::write('Cake.version', '5.2.9'); + + $this->exec('version --verbose'); + + Configure::write('Cake.version', $originalVersion); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('5.2.9'); + $this->assertOutputContains('https://github.com/cakephp/cakephp/releases/tag/5.2.9'); + $this->assertOutputContains('PHP:'); + $this->assertOutputContains(PHP_VERSION); + $this->assertOutputContains(PHP_SAPI); + } + + /** + * Test verbose output with RC version (shows release link) + */ + public function testVerboseWithRcVersion(): void + { + $originalVersion = Configure::read('Cake.version'); + Configure::write('Cake.version', '5.3.0-RC1'); + + $this->exec('version --verbose'); + + Configure::write('Cake.version', $originalVersion); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('5.3.0-RC1'); + $this->assertOutputContains('https://github.com/cakephp/cakephp/releases/tag/5.3.0-RC1'); + $this->assertOutputContains('PHP:'); + } + + /** + * Test verbose output with dev version (no release link) + */ + public function testVerboseWithDevVersion(): void + { + $originalVersion = Configure::read('Cake.version'); + Configure::write('Cake.version', '5.3.0-dev'); + + $this->exec('version --verbose'); + + Configure::write('Cake.version', $originalVersion); + + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('5.3.0-dev'); + $this->assertOutputNotContains('https://github.com/cakephp/cakephp/releases/tag/'); + $this->assertOutputContains('PHP:'); + } +} diff --git a/tests/TestCase/Console/ArgumentsTest.php b/tests/TestCase/Console/ArgumentsTest.php new file mode 100644 index 00000000000..fbb770e5ddc --- /dev/null +++ b/tests/TestCase/Console/ArgumentsTest.php @@ -0,0 +1,325 @@ +assertSame($values, $args->getArguments()); + } + + /** + * Get arguments by index. + */ + public function testGetArgumentAt(): void + { + $values = ['big', 'brown', 'bear']; + $args = new Arguments($values, [], []); + $this->assertSame($values[0], $args->getArgumentAt(0)); + $this->assertSame($values[1], $args->getArgumentAt(1)); + $this->assertNull($args->getArgumentAt(3)); + } + + /** + * Test get arguments by index is not a string. + */ + public function testGetArgumentAtNotString(): void + { + $values = [['one', 'two']]; + $args = new Arguments($values, [], []); + $this->expectException(ConsoleException::class); + $this->expectExceptionMessage('Argument at index `0` is not of type `string`, use `getArrayArgument()` instead.'); + $args->getArgumentAt(0); + } + + /** + * Get array arguments by index. + */ + public function testGetArrayArgumentAt(): void + { + $values = [['one', 'two'], []]; + $args = new Arguments($values, [], []); + $this->assertSame($values[0], $args->getArrayArgumentAt(0)); + $this->assertSame($values[1], $args->getArrayArgumentAt(1)); + $this->assertNull($args->getArrayArgumentAt(3)); + } + + /** + * Test get array arguments by index is not an array. + */ + public function testGetArrayArgumentAtNotArray(): void + { + $values = ['one two']; + $args = new Arguments($values, [], []); + $this->expectException(ConsoleException::class); + $this->expectExceptionMessage('Argument at index `0` is not of type `array`, use `getArgument()` instead.'); + $args->getArrayArgumentAt(0); + } + + /** + * check arguments by index + */ + public function testHasArgumentAt(): void + { + $values = ['big', 'brown', 'bear']; + $args = new Arguments($values, [], []); + $this->assertTrue($args->hasArgumentAt(0)); + $this->assertTrue($args->hasArgumentAt(1)); + $this->assertFalse($args->hasArgumentAt(3)); + $this->assertFalse($args->hasArgumentAt(-1)); + } + + /** + * check arguments by name + */ + public function testHasArgument(): void + { + $values = ['big', 'brown', 'bear']; + $names = ['size', 'color', 'species', 'odd']; + $args = new Arguments($values, [], $names); + $this->assertTrue($args->hasArgument('size')); + $this->assertTrue($args->hasArgument('color')); + $this->assertFalse($args->hasArgument('odd')); + $this->assertFalse($args->hasArgument('undefined')); + } + + /** + * get arguments by name + */ + public function testGetArgument(): void + { + $values = ['big', 'brown', 'bear']; + $names = ['size', 'color', 'species', 'odd']; + $args = new Arguments($values, [], $names); + $this->assertSame($values[0], $args->getArgument('size')); + $this->assertSame($values[1], $args->getArgument('color')); + $this->assertNull($args->getArgument('odd')); + } + + /** + * get arguments missing value + */ + public function testGetArgumentMissing(): void + { + $values = []; + $names = ['size', 'color']; + $args = new Arguments($values, [], $names); + $this->assertNull($args->getArgument('size')); + $this->assertNull($args->getArgument('color')); + } + + /** + * get arguments by name + */ + public function testGetArgumentInvalid(): void + { + $values = []; + $names = ['size']; + $args = new Arguments($values, [], $names); + + $this->expectException(ConsoleException::class); + $this->expectExceptionMessage('Argument `color` is not defined on this Command. Could this be an option maybe?'); + + $args->getArgument('color'); + } + + /** + * Test getArgument() could only return string. + */ + public function testGetArgumentNotString(): void + { + $values = [['one', 'two']]; + $names = ['types']; + $args = new Arguments($values, [], $names); + $this->expectException(ConsoleException::class); + $this->expectExceptionMessage('Argument `types` is not of type `string`, use `getArrayArgument()` instead.'); + $args->getArgument('types'); + } + + /** + * test getOptions() + */ + public function testGetOptions(): void + { + $options = [ + 'verbose' => true, + 'off' => false, + 'empty' => '', + ]; + $args = new Arguments([], $options, []); + $this->assertSame($options, $args->getOptions()); + } + + /** + * test hasOption() + */ + public function testHasOption(): void + { + $options = [ + 'verbose' => true, + 'off' => false, + 'zero' => 0, + 'empty' => '', + ]; + $args = new Arguments([], $options, []); + $this->assertTrue($args->hasOption('verbose')); + $this->assertTrue($args->hasOption('off')); + $this->assertTrue($args->hasOption('empty')); + $this->assertTrue($args->hasOption('zero')); + $this->assertFalse($args->hasOption('undef')); + } + + /** + * test getOption() + */ + public function testGetOption(): void + { + $options = [ + 'verbose' => true, + 'off' => false, + 'zero' => '0', + 'empty' => '', + ]; + $args = new Arguments([], $options, []); + $this->assertTrue($args->getOption('verbose')); + $this->assertFalse($args->getOption('off')); + $this->assertSame('', $args->getOption('empty')); + $this->assertSame('0', $args->getOption('zero')); + $this->assertNull($args->getOption('undef')); + } + + /** + * test getOption() checks types + */ + public function testGetOptionInvalidType(): void + { + $options = [ + 'list' => [1, 2], + ]; + $args = new Arguments([], $options, []); + $this->expectException(ConsoleException::class); + $args->getOption('list'); + } + + public function testGetBooleanOption(): void + { + $options = [ + 'verbose' => true, + ]; + $args = new Arguments([], $options, []); + $this->assertTrue($args->getBooleanOption('verbose')); + $this->assertNull($args->getBooleanOption('missing')); + } + + /** + * test getOption() checks types + */ + public function testGetOptionBooleanInvalidType(): void + { + $options = [ + 'list' => [1, 2], + ]; + $args = new Arguments([], $options, []); + $this->expectException(ConsoleException::class); + $args->getBooleanOption('list'); + } + + public function testGetMultipleOption(): void + { + $this->deprecated(function (): void { + $options = [ + 'types' => ['one', 'two', 'three'], + ]; + $args = new Arguments([], $options, []); + $this->assertSame(['one', 'two', 'three'], $args->getMultipleOption('types')); + $this->assertNull($args->getMultipleOption('missing')); + }); + } + + /** + * Test getArrayOption(). Consistent method (alias of getMultipleOption()) + */ + public function testGetArrayOption(): void + { + $options = [ + 'types' => ['one', 'two', 'three'], + ]; + $args = new Arguments([], $options, []); + $this->assertSame(['one', 'two', 'three'], $args->getArrayOption('types')); + $this->assertNull($args->getArrayOption('missing')); + } + + public function testGetArrayOptionInvalidType(): void + { + $options = [ + 'connection' => 'test', + ]; + $args = new Arguments([], $options, []); + $this->expectException(ConsoleException::class); + $args->getArrayOption('connection'); + } + + public function testGetArrayArgumentInvalid(): void + { + $values = ['XS']; + $names = ['size']; + $args = new Arguments($values, [], $names); + $this->expectException(ConsoleException::class); + $this->expectExceptionMessage('Argument `colors` is not defined on this Command. Could this be an option maybe?'); + $args->getArrayArgument('colors'); + } + + public function testGetArrayArgument(): void + { + $values = [ + ['one', 'two', 'three'], + ]; + $names = [ + 'types', + 'odd', + ]; + $args = new Arguments($values, [], $names); + $this->assertSame(['one', 'two', 'three'], $args->getArrayArgument('types')); + $this->assertNull($args->getArrayArgument('odd')); + } + + public function testGetArrayArgumentInvalidType(): void + { + $values = [ + 'one type', + ]; + $names = [ + 'types', + ]; + $args = new Arguments($values, [], $names); + $this->expectException(ConsoleException::class); + $this->expectExceptionMessage('Argument `types` is not of type `array`, use `getArgument()` instead.'); + $args->getArrayArgument('types'); + } +} diff --git a/tests/TestCase/Console/BaseCommandTest.php b/tests/TestCase/Console/BaseCommandTest.php new file mode 100644 index 00000000000..2a8e8c676e5 --- /dev/null +++ b/tests/TestCase/Console/BaseCommandTest.php @@ -0,0 +1,212 @@ +args is available inside execute() + */ + public function testRunHydratesArgs(): void + { + $command = new class extends Command { + public ?Arguments $capturedArgs = null; + + public function execute(Arguments $args, ConsoleIo $io): int + { + $this->capturedArgs = $this->args; + + return static::CODE_SUCCESS; + } + }; + $command->setName('cake test'); + $output = new StubConsoleOutput(); + $io = Mockery::mock(ConsoleIo::class, [$output, $output, null, null])->makePartial(); + + $command->run([], $io); + + $this->assertInstanceOf(Arguments::class, $command->capturedArgs); + } + + /** + * Test that $this->io is available inside execute() + */ + public function testRunHydratesIo(): void + { + $command = new class extends Command { + public ?ConsoleIo $capturedIo = null; + + public function execute(Arguments $args, ConsoleIo $io): int + { + $this->capturedIo = $this->io; + + return static::CODE_SUCCESS; + } + }; + $command->setName('cake test'); + $output = new StubConsoleOutput(); + $io = Mockery::mock(ConsoleIo::class, [$output, $output, null, null])->makePartial(); + + $command->run([], $io); + + $this->assertSame($io, $command->capturedIo); + } + + /** + * Test that $this->args matches the Arguments passed to execute() + */ + public function testRunHydratedArgsMatchExecuteArgs(): void + { + $command = new class extends Command { + public bool $argsMatch = false; + + public function execute(Arguments $args, ConsoleIo $io): int + { + $this->argsMatch = ($this->args === $args); + + return static::CODE_SUCCESS; + } + }; + $command->setName('cake test'); + $output = new StubConsoleOutput(); + $io = Mockery::mock(ConsoleIo::class, [$output, $output, null, null])->makePartial(); + + $command->run([], $io); + + $this->assertTrue($command->argsMatch); + } + + /** + * Test that $this->args and $this->io are hydrated before execute(), + * so traits/parent classes can rely on them without manual assignment. + */ + public function testRunHydratesPropertiesBeforeExecute(): void + { + $command = new class extends Command { + public bool $propsAvailable = false; + + public function execute(Arguments $args, ConsoleIo $io): int + { + $this->propsAvailable = isset($this->args) && isset($this->io); + + return static::CODE_SUCCESS; + } + }; + $command->setName('cake test'); + $output = new StubConsoleOutput(); + $io = Mockery::mock(ConsoleIo::class, [$output, $output, null, null])->makePartial(); + + $command->run([], $io); + + $this->assertTrue($command->propsAvailable); + } + + /** + * Test that $this->io is accessible in initialize() + */ + public function testInitializeCanAccessIo(): void + { + $command = new class extends Command { + public bool $ioAccessible = false; + + public function initialize(): void + { + $this->ioAccessible = isset($this->io); + } + + public function execute(Arguments $args, ConsoleIo $io): int + { + return static::CODE_SUCCESS; + } + }; + $command->setName('cake test'); + $output = new StubConsoleOutput(); + $io = Mockery::mock(ConsoleIo::class, [$output, $output, null, null])->makePartial(); + + $command->run([], $io); + + $this->assertTrue($command->ioAccessible); + } + + /** + * Test that $this->args is accessible in initialize() + */ + public function testInitializeCanAccessArgs(): void + { + $command = new class extends Command { + public bool $argsAccessible = false; + + public function initialize(): void + { + $this->argsAccessible = isset($this->args); + } + + public function execute(Arguments $args, ConsoleIo $io): int + { + return static::CODE_SUCCESS; + } + }; + $command->setName('cake test'); + $output = new StubConsoleOutput(); + $io = Mockery::mock(ConsoleIo::class, [$output, $output, null, null])->makePartial(); + + $command->run([], $io); + + $this->assertTrue($command->argsAccessible); + } + + /** + * Test that hydrated args contain the parsed arguments from the command line. + */ + public function testRunHydratedArgsContainParsedValues(): void + { + $command = new class extends Command { + public ?string $capturedName = null; + + protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser + { + $parser->addArgument('name', ['required' => true]); + + return $parser; + } + + public function execute(Arguments $args, ConsoleIo $io): int + { + $this->capturedName = $this->args->getArgument('name'); + + return static::CODE_SUCCESS; + } + }; + $command->setName('cake test'); + $output = new StubConsoleOutput(); + $io = Mockery::mock(ConsoleIo::class, [$output, $output, null, null])->makePartial(); + + $command->run(['Alice'], $io); + + $this->assertSame('Alice', $command->capturedName); + } +} diff --git a/tests/TestCase/Console/Command/HelpCommandTest.php b/tests/TestCase/Console/Command/HelpCommandTest.php new file mode 100644 index 00000000000..6247af66120 --- /dev/null +++ b/tests/TestCase/Console/Command/HelpCommandTest.php @@ -0,0 +1,190 @@ +setAppNamespace(); + $this->loadPlugins(['TestPlugin']); + } + + /** + * tearDown + */ + protected function tearDown(): void + { + parent::tearDown(); + $this->clearPlugins(); + } + + /** + * Test the verbose command listing + */ + public function testMainVerbose(): void + { + $this->exec('help -v'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('CakePHP:', 'header should appear in verbose mode'); + $this->assertCommandListVerbose(); + } + + /** + * Test the compact command listing (default) + */ + public function testMainCompact(): void + { + $this->exec('help'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('CakePHP:', 'header should appear in compact mode'); + $this->assertOutputContains('Available Commands:', 'single commands header'); + $this->assertOutputContains('routes:', 'routes group header'); + $this->assertOutputContains('cache:', 'cache group header'); + $this->assertOutputContains('cache clear', 'cache subcommand listed'); + $this->assertOutputContains('Clear all data in a single cache engine', 'inline description shown'); + $this->assertOutputNotContains('app:', 'no plugin group headers in compact mode'); + $this->assertOutputNotContains('help', 'help command should be hidden'); + $this->assertOutputContains('To run a command', 'more info present'); + } + + /** + * Test that the default header is omitted when Cake version is unknown. + */ + public function testMainCompactOmitsHeaderWhenVersionUnknown(): void + { + $version = Configure::read('Cake.version'); + Configure::write('Cake.version', 'unknown'); + + try { + $this->exec('help'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputNotContains('CakePHP:', 'header should be omitted when version is unknown'); + } finally { + Configure::write('Cake.version', $version); + } + } + + /** + * Assert the verbose help output. + */ + protected function assertCommandListVerbose(): void + { + $this->assertOutputContains('test_plugin', 'plugin header should appear'); + $this->assertOutputContains('sample', 'plugin command should appear'); + $this->assertOutputNotContains( + '- test_plugin.sample', + 'only short alias for plugin command.', + ); + $this->assertOutputNotContains( + ' - abstract', + 'Abstract command classes should not appear.', + ); + $this->assertOutputContains('app', 'app header should appear'); + $this->assertOutputContains('sample', 'app shell'); + $this->assertOutputContains('cakephp', 'cakephp header should appear'); + $this->assertOutputContains('routes', 'core shell'); + $this->assertOutputContains('sample', 'short plugin name'); + $this->assertOutputContains('abort', 'command object'); + $this->assertOutputContains('To run a command', 'more info present'); + $this->assertOutputContains('To get help', 'more info present'); + $this->assertOutputContains('This is a demo command', 'command description missing'); + $this->assertOutputContains('custom_group'); + $this->assertOutputContains('grouped'); + $this->assertOutputNotContains( + 'hidden', + 'Hidden commands should not appear in help output.', + ); + } + + /** + * Test filtering by command prefix (compact mode) + */ + public function testFilterByPrefixCompact(): void + { + $this->exec('help cache'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('cache:'); + $this->assertOutputContains('cache clear'); + $this->assertOutputContains('cache list'); + $this->assertOutputNotContains('routes'); + $this->assertOutputNotContains('sample'); + } + + /** + * Test filtering by command prefix with verbose mode shows descriptions + */ + public function testFilterByPrefixVerbose(): void + { + $this->exec('help cache -v'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains('Available Commands'); + $this->assertOutputContains('cache clear'); + $this->assertOutputContains('Clear all data in a single cache engine'); + $this->assertOutputNotContains('routes'); + } + + /** + * Test help --xml + */ + public function testMainAsXml(): void + { + $this->exec('help --xml'); + $this->assertExitCode(CommandInterface::CODE_SUCCESS); + $this->assertOutputContains(''); + + $find = 'assertOutputContains($find); + + $find = 'assertOutputContains($find); + + $find = 'assertOutputContains($find); + $this->assertOutputNotContains('
  • {{title}}
  • ', 'itemWithoutLink' => '
  • {{title}}
  • ', - ] + ], ]); $this->breadcrumbs ->add('Home', '/', ['class' => 'first', 'innerAttrs' => ['data-foo' => 'bar']]) @@ -439,7 +453,7 @@ public function testRenderCustomTemplate() $result = $this->breadcrumbs->render( ['data-stuff' => 'foo and bar'], - ['separator' => ' > ', 'class' => 'separator'] + ['separator' => ' > ', 'class' => 'separator'], ); $expected = [ ['ol' => ['itemtype' => 'http://schema.org/BreadcrumbList', 'data-stuff' => 'foo and bar']], @@ -455,24 +469,22 @@ public function testRenderCustomTemplate() 'Final crumb', '/span', '/li', - '/ol' + '/ol', ]; $this->assertHtml($expected, $result, true); } /** * Tests the render method with template vars - * - * @return void */ - public function testRenderCustomTemplateTemplateVars() + public function testRenderCustomTemplateTemplateVars(): void { $this->breadcrumbs = new BreadcrumbsHelper(new View(), [ 'templates' => [ 'wrapper' => '{{thing}}
      {{content}}
    ', 'item' => '
  • {{title}}{{foo}}
  • ', 'itemWithoutLink' => '
  • {{title}}{{barbaz}}
  • ', - ] + ], ]); $this->breadcrumbs ->add('Home', '/', ['class' => 'first', 'innerAttrs' => ['data-foo' => 'bar'], 'templateVars' => ['foo' => 'barbaz']]) @@ -480,7 +492,7 @@ public function testRenderCustomTemplateTemplateVars() $result = $this->breadcrumbs->render( ['data-stuff' => 'foo and bar', 'templateVars' => ['thing' => 'somestuff']], - ['separator' => ' > ', 'class' => 'separator'] + ['separator' => ' > ', 'class' => 'separator'], ); $expected = [ 'somestuff', @@ -499,8 +511,225 @@ public function testRenderCustomTemplateTemplateVars() '/span', 'foo', '/li', - '/ol' + '/ol', ]; $this->assertHtml($expected, $result, true); } + + /** + * Test adding multiple crumbs at once using addMany() + */ + public function testAddMany(): void + { + $this->breadcrumbs + ->addMany([ + [ + 'title' => 'Home', + 'url' => '/', + 'options' => ['class' => 'first'], + ], + [ + 'title' => 'Some text', + 'url' => ['controller' => 'Some', 'action' => 'text'], + ], + [ + 'title' => 'Final', + ], + ]); + + $result = $this->breadcrumbs->getCrumbs(); + $expected = [ + [ + 'title' => 'Home', + 'url' => '/', + 'options' => [ + 'class' => 'first', + ], + ], + [ + 'title' => 'Some text', + 'url' => [ + 'controller' => 'Some', + 'action' => 'text', + ], + 'options' => [], + ], + [ + 'title' => 'Final', + 'url' => null, + 'options' => [], + ], + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test adding multiple crumbs with shared options using addMany() + */ + public function testAddManyWithSharedOptions(): void + { + $this->breadcrumbs + ->addMany([ + ['title' => 'Home', 'url' => '/'], + ['title' => 'Products', 'url' => '/products'], + ['title' => 'Category'], + ], ['class' => 'breadcrumb-item']); + + $result = $this->breadcrumbs->getCrumbs(); + $expected = [ + [ + 'title' => 'Home', + 'url' => '/', + 'options' => ['class' => 'breadcrumb-item'], + ], + [ + 'title' => 'Products', + 'url' => '/products', + 'options' => ['class' => 'breadcrumb-item'], + ], + [ + 'title' => 'Category', + 'url' => null, + 'options' => ['class' => 'breadcrumb-item'], + ], + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test that individual crumb options override shared options in addMany() + */ + public function testAddManyWithSharedOptionsAndOverride(): void + { + $this->breadcrumbs + ->addMany([ + ['title' => 'Home', 'url' => '/', 'options' => ['class' => 'special']], + ['title' => 'Products', 'url' => '/products'], + ['title' => 'Category'], + ], ['class' => 'breadcrumb-item']); + + $result = $this->breadcrumbs->getCrumbs(); + $expected = [ + [ + 'title' => 'Home', + 'url' => '/', + 'options' => ['class' => 'special'], + ], + [ + 'title' => 'Products', + 'url' => '/products', + 'options' => ['class' => 'breadcrumb-item'], + ], + [ + 'title' => 'Category', + 'url' => null, + 'options' => ['class' => 'breadcrumb-item'], + ], + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test prepending multiple crumbs using prependMany() + */ + public function testPrependMany(): void + { + $this->breadcrumbs + ->add('Home', '/', ['class' => 'first']) + ->prependMany([ + ['title' => 'Some text', 'url' => ['controller' => 'Some', 'action' => 'text']], + ['title' => 'The root', 'url' => '/root', 'options' => ['data-name' => 'some-name']], + ]); + + $result = $this->breadcrumbs->getCrumbs(); + $expected = [ + [ + 'title' => 'Some text', + 'url' => [ + 'controller' => 'Some', + 'action' => 'text', + ], + 'options' => [], + ], + [ + 'title' => 'The root', + 'url' => '/root', + 'options' => ['data-name' => 'some-name'], + ], + [ + 'title' => 'Home', + 'url' => '/', + 'options' => [ + 'class' => 'first', + ], + ], + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test prepending multiple crumbs with shared options using prependMany() + */ + public function testPrependManyWithSharedOptions(): void + { + $this->breadcrumbs + ->add('Current', '/current') + ->prependMany([ + ['title' => 'Home', 'url' => '/'], + ['title' => 'Products', 'url' => '/products'], + ], ['class' => 'breadcrumb-item']); + + $result = $this->breadcrumbs->getCrumbs(); + $expected = [ + [ + 'title' => 'Home', + 'url' => '/', + 'options' => ['class' => 'breadcrumb-item'], + ], + [ + 'title' => 'Products', + 'url' => '/products', + 'options' => ['class' => 'breadcrumb-item'], + ], + [ + 'title' => 'Current', + 'url' => '/current', + 'options' => [], + ], + ]; + $this->assertEquals($expected, $result); + } + + /** + * Test that individual crumb options override shared options in prependMany() + */ + public function testPrependManyWithSharedOptionsAndOverride(): void + { + $this->breadcrumbs + ->add('Current', '/current') + ->prependMany([ + ['title' => 'Home', 'url' => '/', 'options' => ['class' => 'special']], + ['title' => 'Products', 'url' => '/products'], + ], ['class' => 'breadcrumb-item']); + + $result = $this->breadcrumbs->getCrumbs(); + $expected = [ + [ + 'title' => 'Home', + 'url' => '/', + 'options' => ['class' => 'special'], + ], + [ + 'title' => 'Products', + 'url' => '/products', + 'options' => ['class' => 'breadcrumb-item'], + ], + [ + 'title' => 'Current', + 'url' => '/current', + 'options' => [], + ], + ]; + $this->assertEquals($expected, $result); + } } diff --git a/tests/TestCase/View/Helper/FlashHelperTest.php b/tests/TestCase/View/Helper/FlashHelperTest.php index 6fb9517b7ae..94b8a654fa7 100644 --- a/tests/TestCase/View/Helper/FlashHelperTest.php +++ b/tests/TestCase/View/Helper/FlashHelperTest.php @@ -1,4 +1,6 @@ View = new View(); $session = new Session(); - $this->View->request = new ServerRequest(['session' => $session]); + $this->View = new View(new ServerRequest(['session' => $session])); $this->Flash = new FlashHelper($this->View); $session->write([ @@ -48,9 +55,9 @@ public function setUp() [ 'key' => 'flash', 'message' => 'This is a calling', - 'element' => 'Flash/default', - 'params' => [] - ] + 'element' => 'flash/default', + 'params' => [], + ], ], 'notification' => [ [ @@ -59,24 +66,24 @@ public function setUp() 'element' => 'flash_helper', 'params' => [ 'title' => 'Notice!', - 'name' => 'Alert!' - ] - ] + 'name' => 'Alert!', + ], + ], ], 'classy' => [ [ 'key' => 'classy', 'message' => 'Recorded', 'element' => 'flash_classy', - 'params' => [] - ] + 'params' => [], + ], ], 'stack' => [ [ 'key' => 'flash', 'message' => 'This is a calling', - 'element' => 'Flash/default', - 'params' => [] + 'element' => 'flash/default', + 'params' => [], ], [ 'key' => 'notification', @@ -84,45 +91,42 @@ public function setUp() 'element' => 'flash_helper', 'params' => [ 'title' => 'Notice!', - 'name' => 'Alert!' - ] + 'name' => 'Alert!', + ], ], [ 'key' => 'classy', 'message' => 'Recorded', 'element' => 'flash_classy', - 'params' => [] - ] - ] - ] + 'params' => [], + ], + ], + ], ]); } /** * tearDown method - * - * @return void */ - public function tearDown() + protected function tearDown(): void { parent::tearDown(); unset($this->View, $this->Flash); + $this->clearPlugins(); } /** * testFlash method - * - * @return void */ - public function testFlash() + public function testFlash(): void { $result = $this->Flash->render(); $expected = '
    This is a calling
    '; - $this->assertContains($expected, $result); + $this->assertStringContainsString($expected, $result); $expected = '
    Recorded
    '; $result = $this->Flash->render('classy'); - $this->assertEquals($expected, $result); + $this->assertSame($expected, $result); $result = $this->Flash->render('notification'); $expected = [ @@ -130,33 +134,20 @@ public function testFlash() 'assertHtml($expected, $result); - $this->assertNull($this->Flash->render('non-existent')); - } - - /** - * testFlashThrowsException - * - */ - public function testFlashThrowsException() - { - $this->expectException(\UnexpectedValueException::class); - $this->View->request->session()->write('Flash.foo', 'bar'); - $this->Flash->render('foo'); + $this->assertNull($this->Flash->render('nonexistent')); } /** * test setting the element from the attrs. - * - * @return void */ - public function testFlashElementInAttrs() + public function testFlashElementInAttrs(): void { $result = $this->Flash->render('notification', [ 'element' => 'flash_helper', - 'params' => ['title' => 'Notice!', 'name' => 'Alert!'] + 'params' => ['title' => 'Notice!', 'name' => 'Alert!'], ]); $expected = [ @@ -164,47 +155,41 @@ public function testFlashElementInAttrs() 'assertHtml($expected, $result); } /** * test using elements in plugins. - * - * @return void */ - public function testFlashWithPluginElement() + public function testFlashWithPluginElement(): void { - Plugin::load('TestPlugin'); + $this->loadPlugins(['TestPlugin']); - $result = $this->Flash->render('flash', ['element' => 'TestPlugin.Flash/plugin_element']); + $result = $this->Flash->render('flash', ['element' => 'TestPlugin.flash/plugin_element']); $expected = 'this is the plugin element'; - $this->assertEquals($expected, $result); + $this->assertSame($expected, $result); } /** * test that when View theme is set, flash element from that theme (plugin) is used. - * - * @return void */ - public function testFlashWithTheme() + public function testFlashWithTheme(): void { - Plugin::load('TestTheme'); + $this->loadPlugins(['TestTheme']); - $this->View->theme = 'TestTheme'; + $this->View->setTheme('TestTheme'); $result = $this->Flash->render('flash'); $expected = 'flash element from TestTheme'; - $this->assertContains($expected, $result); + $this->assertStringContainsString($expected, $result); } /** * Test that when rendering a stack, messages are displayed in their * respective element, in the order they were added in the stack - * - * @return void */ - public function testFlashWithStack() + public function testFlashWithStack(): void { $result = $this->Flash->render('stack'); $expected = [ @@ -214,23 +199,21 @@ public function testFlashWithStack() ' ['id' => 'classy-message']], 'Recorded', '/div' + ['div' => ['id' => 'classy-message']], 'Recorded', '/div', ]; $this->assertHtml($expected, $result); - $this->assertNull($this->View->request->session()->read('Flash.stack')); + $this->assertNull($this->View->getRequest()->getSession()->read('Flash.stack')); } /** * test that when View prefix is set, flash element from that prefix * is used if available. - * - * @return void */ - public function testFlashWithPrefix() + public function testFlashWithPrefix(): void { - $this->View->request->params['prefix'] = 'Admin'; + $this->View->setRequest($this->View->getRequest()->withParam('prefix', 'Admin')); $result = $this->Flash->render('flash'); $expected = 'flash element from Admin prefix folder'; - $this->assertContains($expected, $result); + $this->assertStringContainsString($expected, $result); } } diff --git a/tests/TestCase/View/Helper/FormHelperTest.php b/tests/TestCase/View/Helper/FormHelperTest.php index b6bda887ff5..a1b7d8c8454 100644 --- a/tests/TestCase/View/Helper/FormHelperTest.php +++ b/tests/TestCase/View/Helper/FormHelperTest.php @@ -1,4 +1,6 @@ ['type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'], - 'name' => ['type' => 'string', 'null' => '', 'default' => '', 'length' => '255'], - 'email' => ['type' => 'string', 'null' => '', 'default' => '', 'length' => '255'], - 'phone' => ['type' => 'string', 'null' => '', 'default' => '', 'length' => '255'], - 'password' => ['type' => 'string', 'null' => '', 'default' => '', 'length' => '255'], - 'published' => ['type' => 'date', 'null' => true, 'default' => null, 'length' => null], - 'created' => ['type' => 'date', 'null' => '1', 'default' => '', 'length' => ''], - 'updated' => ['type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null], - 'age' => ['type' => 'integer', 'null' => '', 'default' => '', 'length' => null], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - - /** - * Initializes the schema - * - * @return void - */ - public function initialize(array $config) - { - $this->schema($this->_schema); - } -} - -/** - * ValidateUser class - */ -class ValidateUsersTable extends Table -{ - - /** - * schema method - * - * @var array - */ - protected $_schema = [ - 'id' => ['type' => 'integer', 'null' => '', 'default' => '', 'length' => '8'], - 'name' => ['type' => 'string', 'null' => '', 'default' => '', 'length' => '255'], - 'email' => ['type' => 'string', 'null' => '', 'default' => '', 'length' => '255'], - 'balance' => ['type' => 'float', 'null' => false, 'length' => 5, 'precision' => 2], - 'cost_decimal' => ['type' => 'decimal', 'null' => false, 'length' => 6, 'precision' => 3], - 'null_decimal' => ['type' => 'decimal', 'null' => false, 'length' => null, 'precision' => null], - 'ratio' => ['type' => 'decimal', 'null' => false, 'length' => 10, 'precision' => 6], - 'population' => ['type' => 'decimal', 'null' => false, 'length' => 15, 'precision' => 0], - 'created' => ['type' => 'date', 'null' => '1', 'default' => '', 'length' => ''], - 'updated' => ['type' => 'datetime', 'null' => '1', 'default' => '', 'length' => null], - '_constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]] - ]; - - /** - * Initializes the schema - * - * @return void - */ - public function initialize(array $config) - { - $this->schema($this->_schema); - } -} +use Cake\View\Widget\LabelWidget; +use Cake\View\Widget\WidgetInterface; +use Cake\View\Widget\WidgetLocator; +use DOMDocument; +use DOMXPath; +use InvalidArgumentException; +use Mockery; +use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionProperty; +use TestApp\Model\Entity\Article; +use TestApp\Model\Enum\ArticleStatus; +use TestApp\Model\Enum\ArticleStatusLabel; +use TestApp\Model\Enum\ArticleStatusLabelInterface; +use TestApp\Model\Enum\Priority; +use TestApp\Model\Table\ContactsTable; +use TestApp\Model\Table\ValidateUsersTable; +use TestApp\View\Form\StubContext; /** * FormHelperTest class @@ -113,60 +66,60 @@ public function initialize(array $config) */ class FormHelperTest extends TestCase { - /** * Fixtures to be used * + * @var array + */ + protected array $fixtures = ['core.Articles', 'core.Comments']; + + /** * @var array */ - public $fixtures = ['core.articles', 'core.comments']; + protected $article = []; /** - * Do not load the fixtures by default - * - * @var bool + * @var string */ - public $autoFixtures = false; + protected $url; /** - * @var array + * @var \Cake\View\Helper\FormHelper */ - protected $article = []; + protected $Form; + + /** + * @var \Cake\View\View + */ + protected $View; /** * setUp method - * - * @return void */ - public function setUp() + protected function setUp(): void { parent::setUp(); Configure::write('Config.language', 'eng'); Configure::write('App.base', ''); static::setAppNamespace('Cake\Test\TestCase\View\Helper'); - $this->View = new View(); - $this->Form = new FormHelper($this->View); $request = new ServerRequest([ 'webroot' => '', 'base' => '', 'url' => '/articles/add', 'params' => [ - 'controller' => 'articles', + 'controller' => 'Articles', 'action' => 'add', - ] + 'plugin' => null, + ], ]); - $this->Form->Url->request = $this->Form->request = $request; + $this->View = new View($request); + Router::reload(); + Router::setRequest($request); - $this->dateRegex = [ - 'daysRegex' => 'preg:/(?: